返回 Skills 目錄
leonxlnx/taste-skill包含需要注意的行為

SKILL DETAIL

design-taste-frontend

leonxlnx/taste-skill/design-taste-frontend

This skill focuses on landing pages, portfolios, and redesigns, not dashboards, data tables, or multi-step product UI. It reads the brief first, infers the design direction, and then applies the appropriate design system or aesthetic to avoid templated output. The skill includes three core dials (design variance, motion intensity, visual density) and recommends real design systems (e.g., Fluent UI, Carbon, GOV.UK) or native CSS implementations based on the brief type. The skill emphasizes honest implementation, never inventing official packages, and follows a strict pre-flight check to ensure dependencies exist, component isolation is correct, and responsive layouts are robust. It encourages using real design systems, avoiding default AI styles (like purple gradients or centered heroes), and declaring a Design Read to clarify page kind, audience, and aesthetic direction.

安裝量 · 7,434查看來源

Installation

npx skills add https://github.com/leonxlnx/taste-skill --skill design-taste-frontend

技能檔案

SKILL.md

最近同步 · 2026年10月22日

assets/app.css›
/* tasteskill / app.css
   A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults
   measured in the study of real product screens (references/app-screens.md). Copy it into the project,
   replace the token values with the ones on the app sheet (draw.mjs --app), and build on top.
   Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */

/* ---------- tokens (replace with the dealt style) ---------- */
:root {
  /* names match the app sheet printed by draw.mjs --app */
  --ground: #f9fafb;          /* the app chrome and page */
  --surface: #ffffff;         /* panels and the work sheet */
  --raised: #f4f5f6;          /* a filled inner well, hover rows */
  --active: #eeeeee;          /* the active nav pill */
  --border: #e8e8e8;          /* panel edges: about 1.2:1 on white */
  --rule: #f1f2f3;            /* row rules inside tables */
  --control: #d1d5db;         /* inputs and outlined buttons: one step stronger */
  --text: #111111;
  --muted: #5f6368;           /* secondary text, labels (4.5:1 or more on its ground) */
  --faint: #6b6b6b;           /* meta and placeholders: the lightest grey that holds 4.5:1 on white */
  --accent: #1f6f5c;          /* one hue: data, focus, the selected thing */
  --primary: #1d1f20;         /* the primary button is near-black unless the style says otherwise */
  --primary-ink: #ffffff;
  --positive: #247b50; --negative: #c4242a; --warning: #92400e;  /* references/app-screens.md §9.5; a green accent is also --positive */
  --pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e;
  --pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e;
  --pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e;
  --pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d;
  --chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6;
  --chart-mark: var(--text);  /* a marked period in Charts.diverging: the second tone */
  --chart-span: 0.06;         /* opacity of a shaded span (a selling window, an outage) */
  --grid: var(--rule);        /* chart gridlines (charts.js) */
  --axis-ink: var(--muted);   /* chart tick labels (charts.js) */
  --ink: var(--text);

  --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  --fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px;

  --sidebar-width: 232px;
  --pad: 24px;                /* canvas padding */
  --gap: 16px;                /* one gutter, horizontal and vertical */
  --card-pad: 20px;
  --radius-card: 8px;         /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */
  --radius-control: 6px;
  --control-height: 32px;     /* every control in the title and filter rows */
  --row-height: 48px;         /* single-line table row */
  --nav-row: 32px;            /* sidebar item height, on a 34px pitch */

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms;
}
/* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */
:root[data-theme="dark"] {
  --ground: #000000; --surface: #111111; --raised: #222222; --active: #222222;
  --border: #1a1a1a; --rule: #161616; --control: #444444;
  --text: #ffffff; --muted: #b4b4b4; --faint: #949494;
  --primary: #ffffff; --primary-ink: #000000;
  --positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b;  /* a green or yellow-green accent is also --positive */
  --pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a;
  --pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f;
  --pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b;
  --pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff;
  --chart-muted: #4a4a4a; --grid: #2b2b2b;
  --chart-span: 0.12;
}

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; }
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.num, table, .kpi-value { font-variant-numeric: tabular-nums; }

/* ---------- shell: sidebar + canvas ---------- */
.app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; }
.sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; }
.sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); }
.nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; }
.nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; }
.nav-item:hover { background: var(--raised); color: var(--text); }
.nav-item[aria-current="page"] { background: var(--active); color: var(--text); }
.sidebar .bottom { margin-top: auto; }
.canvas { min-width: 0; padding: var(--pad); }

/* ---------- title block and filters ---------- */
.title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.title-row .sub { margin-top: 4px; color: var(--muted); }
.filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; }
.filters .spacer { flex: 1; }
.stamp { color: var(--faint); font-size: var(--fs-small); }

/* ---------- panels and grid ---------- */
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; }
.panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; }
.panel .meta { color: var(--faint); font-size: var(--fs-small); }
/* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps
   its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */
.grid.top { align-items: start; }

/* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */
.chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; }
.chart { position: relative; min-width: 0; }

/* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover,
   and no inline padding, so its text sits on the card's content edge */
.card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; }
.card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; }
.card-link:hover { text-decoration: underline; }
:root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }

/* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row
   that carries a rule has square corners */
.rows { display: grid; }
.row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--rule); border-radius: 0; }

/* KPIs as one divided strip: a label above a number, no icons, no tiles */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); }
.kpi { padding: 16px var(--card-pad); }
.kpi + .kpi { border-left: 1px solid var(--border); }
.kpi-label { color: var(--muted); }
.kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; }
.kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; }
.delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); }
.delta .up { color: var(--positive); } .delta .down { color: var(--negative); }
/* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and
   their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity
   breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */
.kpis[role="tablist"] .kpi { position: relative; cursor: pointer; }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; }

/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
/* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */
td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; }
tbody tr:last-child td { border-bottom: 0; }
/* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */
tbody tr[aria-selected="true"] td { background: var(--active); }
th.n, td.n { text-align: right; }
td .sub { display: block; font-size: var(--fs-small); color: var(--muted); }
.truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* status pill: a pale tint with dark ink of the same hue, one per row */
.pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; }
.pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
.pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); }
.pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); }

/* ---------- controls ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); }
.btn:hover { background: var(--raised); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; }
.btn.ghost { border-color: transparent; background: transparent; }
.field { display: grid; gap: 8px; }
.field label { font-weight: 500; }
.field .hint { color: var(--muted); font-size: var(--fs-small); }
.input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); }
.input::placeholder { color: var(--faint); }
.tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); }
.tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; }
.tabs a[aria-selected="true"] { color: var(--text); }
.tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
/* as wide as its options; full width only on a narrow phone */
.segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); }
.segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
.switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); }
.switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); }

/* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */
.empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; }
.skeleton { background: var(--raised); border-radius: 4px; }

/* ---------- phone: the navigation folds away and the canvas takes the width ---------- */
.menu-toggle { display: none; }
@media (max-width: 860px) {
  .app { grid-template-columns: minmax(0, 1fr); }
  /* the menu opens as a full-screen sheet */
  .sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); }
  .sidebar.open { transform: none; }
  .menu-toggle { display: inline-flex; }
  .canvas { padding: 16px; }
  .grid > * { grid-column: 1 / -1 !important; }
  .kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .kpi + .kpi { border-left: 0; }
  .kpi:nth-child(n + 3) { border-top: 1px solid var(--border); }
  .kpi:nth-child(2n) { border-left: 1px solid var(--border); }
  /* an odd count leaves no half-empty row: the last cell takes the full width */
  .kpi:last-child:nth-child(odd) { grid-column: 1 / -1; }
  /* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */
  .kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); }
  .kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; }
  .title-row { flex-wrap: wrap; align-items: flex-start; }
}
@media (max-width: 480px) {
  .segmented { display: flex; width: 100%; }
  .segmented button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } }

/* an empty message spans the block it stands in, never one squeezed column */
.rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; }
assets/base.css›
/* tasteskill / base.css
   A neutral foundation: reset, token names, fluid type steps, a 12-column grid, states and the motion
   helpers from references/motion.md. It has no look of its own. Copy it into the project, replace the
   token values with the ones on the direction sheet, and build on top. Delete what you do not use. */

/* ---------- tokens (replace with the dealt palette and type system) ---------- */
:root {
  --bg: #ffffff;
  --surface: #f2f2f2;
  --ink: #111111;
  --muted: #5c5c5c;
  --accent: #111111;
  --accent-ink: #ffffff;

  --font-display: system-ui, sans-serif;
  --font-text: system-ui, sans-serif;

  --step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem);
  --step-body: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
  --step-lead: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);
  --step-head: clamp(2rem, 1.3rem + 3.2vw, 4rem);
  --step-display: clamp(3rem, 1rem + 9vw, 9rem); /* set from the hero blueprint */

  --margin: clamp(1rem, 2.2vw, 2rem);    /* page side margin: tight, so content reaches the edges */
  --gutter: clamp(1rem, 2vw, 2rem);      /* column gap */
  --section: clamp(5rem, 12vw, 13rem);   /* space between sections */
  --radius: 0px;                          /* one radius for the whole page: 0, or large, decided once */

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);        /* entrances, moving hovers, UI under 600ms */
  --ease-expo: cubic-bezier(0.16, 1, 0.3, 1);        /* reveals of 800ms and longer, line rises, openings */
  --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);     /* wipes, panels, things that travel while visible */
  --ease-soft: cubic-bezier(0.33, 1, 0.68, 1);       /* slow scale and drift on photographs */
  --ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */
  --t-press: 140ms;
  --t-hover: 180ms;
  --t-ui: 240ms;
  --t-reveal: 800ms;
  --t-open: 1200ms;                                  /* picture openings and clip wipes */
  --stagger-line: 90ms;
  --stagger-item: 70ms;
}

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: auto; }
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font-text); font-size: var(--step-body); line-height: 1.5; font-kerning: normal; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, video, svg, canvas { display: block; max-width: 100%; }
img, video { height: auto; background: var(--surface); }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; }
:target { scroll-margin-top: 6rem; }
::selection { background: var(--accent); color: var(--accent-ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* ---------- type ---------- */
h1, h2, h3 { font-family: var(--font-display); font-weight: inherit; text-wrap: balance; hyphens: none; }
.display { font-size: var(--step-display); line-height: 0.95; letter-spacing: -0.035em; }
.head { font-size: var(--step-head); line-height: 1.02; letter-spacing: -0.025em; }
.lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; }
.small { font-size: var(--step-small); line-height: 1.45; }
.muted { color: var(--muted); }
p { max-width: 62ch; text-wrap: pretty; }
p + p { margin-top: 0.9em; }
.nums { font-variant-numeric: tabular-nums; }
.only-wide { display: none; }
@media (min-width: 48em) { .only-wide { display: inline; } }

/* ---------- layout ---------- */
.wrap { padding-inline: var(--margin); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
.section { padding-block: calc(var(--section) / 2); }
.section + .section { margin-top: 0; }
.bleed { margin-inline: calc(var(--margin) * -1); }
.cover { width: 100%; height: 100%; object-fit: cover; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- controls ---------- */
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: 0 1.4rem; background: var(--accent); color: var(--accent-ink); border-radius: var(--radius); text-decoration: none; white-space: nowrap; transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
.button:active { transform: scale(0.97); }
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
.link-plain { text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 0.07em; }
@media (hover: hover) and (pointer: fine) {
  .link:hover { text-decoration-color: currentColor; }
}
.field { width: 100%; min-height: 3rem; padding: 0.7rem 0.9rem; background: var(--surface); border: 0; border-radius: var(--radius); font-size: max(1rem, var(--step-body)); }

/* ---------- motion helpers (content stays visible without JavaScript) ---------- */
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.lines [data-line] { display: block; }
.shutter { overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
  .js [data-rise] { opacity: 0; transform: translateY(18px); animation: ad-rise var(--t-reveal) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 90ms + 120ms); }
  .js .lines [data-line] { transform: translateY(105%); animation: ad-line 900ms var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 110ms + 150ms); }
  .js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
  .js [data-reveal].is-in { opacity: 1; transform: none; }
  .js .shutter[data-reveal] { opacity: 1; transform: none; clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
  .js .shutter[data-reveal] img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
  .js .shutter[data-reveal].is-in { clip-path: inset(0); }
  .js .shutter[data-reveal].is-in img { transform: none; }
}
@keyframes ad-rise { to { opacity: 1; transform: none; } }
@keyframes ad-line { to { transform: none; } }
assets/base.js›
// tasteskill / base.js — the small amount of script the motion helpers in base.css need.
// Put this line in <head> so the hidden states only apply when scripting works:
//   <script>document.documentElement.classList.add('js')</script>
// Then load this file at the end of <body> (or with defer).

// Reveal once when an element enters the viewport. Never replays, never leaves content hidden.
(() => {
  const targets = document.querySelectorAll('[data-reveal]');
  if (!targets.length) return;
  if (!('IntersectionObserver' in window)) { targets.forEach((el) => el.classList.add('is-in')); return; }
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add('is-in');
      io.unobserve(entry.target);
    }
  }, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
  targets.forEach((el) => io.observe(el));
  // safety net: if anything is still hidden a few seconds after load (observer never fired), show it
  window.addEventListener('load', () => setTimeout(() => {
    targets.forEach((el) => { const r = el.getBoundingClientRect(); if (r.top < innerHeight && r.bottom > 0) el.classList.add('is-in'); });
  }, 2500));
})();

// Optional, for "colour by chapter": each chapter paints its own full-bleed ground in CSS
// (style="background: var(--field-2); color: var(--field-2-ink)"), so the colour changes exactly at
// section edges. This only lets a fixed header take the ink of the chapter under it. Give each chapter
// data-ink="#hex" and style the header with color: var(--header-ink, var(--ink)).
// Never recolour the whole page from an observer: mid-change, a paragraph sits on two colours.
(() => {
  const chapters = document.querySelectorAll('[data-ink]');
  if (!chapters.length || !('IntersectionObserver' in window)) return;
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) if (entry.isIntersecting) document.documentElement.style.setProperty('--header-ink', entry.target.dataset.ink);
  }, { rootMargin: '0px 0px -92% 0px', threshold: 0 });
  chapters.forEach((el) => io.observe(el));
})();
assets/charts.js›
// tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data.
// They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet
// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no
// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables:
//   --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default),
//   --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track),
//   --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative
// Usage (after this file loads):
//   Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact })
//   Charts.bars(el, { labels, values, highlight: 6, format })        one bar (or [indices]) may carry the accent; dim: [indices]
//   Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' })
//   Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside
//   Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin })  a ranked list where the row is the bar
//   Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' })  planned against spent
//   Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …])   share against a target
//   Charts.timeline(el, rows, kinds, { days, today, weekends })      who is away when, each kind of booking its own mark
//   Charts.matrix(el, rows, { cols, comfort: 2, today })             slack against a minimum, team by day
//   Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' })  when, within the day
// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans
// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no
// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its
// label set where no series passes.
// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed)
// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a
// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat:
// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step.
// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more:
// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in
// the card's header, or pass legend: element (or a selector) to have it filled.
// Numbers follow the page's language (<html lang>). Every chart gets role="img" and an aria-label that states what it shows.
(() => {
  const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0);
  const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb;
  const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; };
  const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; };
  const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; };
  const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
  // numbers follow the page's language (<html lang>), so separators match the rest of the screen
  const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
  const fmt = {
    int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
    dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
    pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
    money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
    compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v),
    ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`,
    // a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4'
    signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)),
    // minutes since midnight as a 24-hour time: 680 → '11:20'
    clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter
  };
  const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number)));
  // Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value.
  function ticks(min, max, count = 4) {
    if (max === min) max = min + 1;
    const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw));
    const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag;
    const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step;
    const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10));
    return out;
  }
  // x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that
  // lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels
  // ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00.
  function xPicks(labels, span, at) {
    const n = labels.length;
    if (at) return at.filter((i) => i >= 0 && i < n);
    if (n <= 1) return n ? [0] : [];
    const longest = Math.max(...labels.map((l) => String(l).length));
    const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18))));
    if (n <= cap) return [...Array(n).keys()];
    const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null;
    const d = mins ? mins[1] - mins[0] : 0;
    let first = null, land = null;
    for (let k = 1; k < n; k++) {
      const stepM = k * d, clock = mins && d > 0;
      if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue;
      // start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps
      let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0;
      if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0));
      const count = Math.floor((n - 1 - s) / k) + 1;
      if (count > cap) continue;
      if (first == null) first = { k, s };
      if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; }
    }
    const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 };
    const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i);
    return out;
  }
  // colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step)
  const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none';
  const rgb = (el, value) => {
    el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value;
    const c = getComputedStyle(swatchProbe).color; swatchProbe.remove();
    const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null;
    return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number);
  };
  const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; };
  // text width, measured in the chart's own face (falls back to an estimate when the chart is hidden)
  const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; };
  const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; };
  function frame(el, h) {
    el.innerHTML = '';
    const w = Math.max(240, el.clientWidth || 600);
    const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el);
    return { s, w, h };
  }
  function tooltip(el) {
    let t = el.querySelector('.chart-tip');
    if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); }
    return t;
  }
  function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) {
    const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
    for (const v of yt) {
      const yy = y(v);
      svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
      text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink });
    }
    const n = labels.length;
    for (const i of xPicks(labels, w - padL, at)) {
      const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i);
      // the first and last labels are pulled inside the plot; none runs past either edge
      let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2;
      left = Math.max(band ? 0 : padL, Math.min(w - tw, left));
      if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue;
      text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink });
    }
  }
  // Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader
  // line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps
  // it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched
  // (close markers often need the first label on the left so the next leader runs clear): full labels first, then
  // short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console.
  function lane(s, items, w, maxRows = 3) {
    const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2);
    // fixed captions first, then spans, then markers from left to right
    const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0));
    const opts = order.map((it) => {
      const out = [];
      for (const str of [it.label, it.short].filter(Boolean)) {
        const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0;
        const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]];
        for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 });
      }
      return out;
    });
    const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap)
      && !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3)
      && !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3)));
    let best = null, steps = 0;
    const solve = (i, placed, allowShort, allowDrop) => {
      if (++steps > 20000) return false;
      if (i === order.length) { best = placed.slice(); return true; }
      for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
      if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
      return false;
    };
    for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; }
    const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped);
    return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 };
  }
  function prepNotes(s, annotations = [], x, w, extra = []) {
    const items = [];
    for (const a of annotations || []) {
      if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour });
      else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour });
    }
    items.push(...extra);
    return { items, ...lane(s, items.filter((it) => it.label), w) };
  }
  // shaded spans go under the data; marker lines and the label rows over it
  function notesUnder(g, el, N, { padT, h, padB }) {
    const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07);
    for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g);
  }
  function notesOver(g, el, N, { padT, h, padB }) {
    const ink = css(el, '--axis-ink', '#6b6b6b');
    // a marker line runs to the plot's foot, or stops above the bar it points at (it.end)
    for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
    for (const p of N.placed) {
      const base = padT - 9 - p.row * ROW;
      if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
      if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g);
      text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink });
    }
    const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set();
    for (const d of N.dropped) {
      if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); }
      if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); }
    }
  }
  // does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 }
  function hits(b, lines) {
    for (const pts of lines) for (let k = 1; k < pts.length; k++) {
      const a = pts[k - 1], c = pts[k]; if (!a || !c) continue;
      if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; }
      const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0]));
      for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; }
    }
    return false;
  }
  const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3);
  // a reference line (a budget, an SLO) with its label at an end where no series passes
  function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) {
    const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
    for (const r of [].concat(refs || [])) {
      const yy = y(r.value);
      svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g);
      if (!r.label) continue;
      const tw = measure(s, r.label, 11), right = r.at !== 'left';
      const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy }));
      const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0];
      text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink });
    }
  }
  // One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart.
  function paintRect(parent, st, x, y, w, h, r = 0) {
    let fill = st.fill || 'none';
    if (st.hatch) {
      const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8);
      const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s));
      svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p);
      fill = `url(#${id})`;
    }
    const inset = st.stroke ? (st.width || 1) / 2 : 0;
    const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent);
    if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent);
    return n;
  }
  const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}';
  function legendEl(items, target) {
    if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); }
    const ul = document.createElement('ul'); ul.className = 'chart-legend';
    for (const it of items) {
      const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' });
      it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li);
    }
    const t = typeof target === 'string' ? document.querySelector(target) : target;
    if (t) t.replaceChildren(ul);
    return ul;
  }
  const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r);
  // charts are laid out with measured text: draw again once the page's fonts have arrived
  function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } }
  const heightOf = (h) => (typeof h === 'function' ? h() : h);

  function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) {
    el._fmt = format;
    const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3')));
    const draw = () => {
      const { s, w, h } = frame(el, heightOf(height));
      const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value));
      // max caps the range (a flat series with one spike): values above it are clipped and their peak labelled
      const top = max ?? Math.max(...all);
      const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4);
      const tick = tickFormat || format;
      const lastVal = series[0].values.filter((v) => v != null).at(-1);
      const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0;
      const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1);
      // the peaks clipped by max are labelled in the rows above the plot, where the line leaves it
      const peaks = [];
      series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } }));
      const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; };
      const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]]));
      let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null;
      // the today label goes inside the plot on whichever side of its line no series crosses; failing that, above
      if (today != null && todayLabel) {
        const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y);
        const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b }));
        todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null;
        if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); }
      }
      const g = svg('g', {}, s);
      axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick });
      notesUnder(g, el, N, { padT, h, padB });
      series.forEach((sr, k) => {
        const col = colourOf(sr, k);
        const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))]));
        // step: a balance that only changes on given days holds its value until the next one
        const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), '');
        if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); }
        if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g);
        else {
          // a forecast: the line after today is dashed, so a projection never reads as a fact
          const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8);
          const defs = svg('defs', {}, g);
          const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a);
          const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b);
          svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g);
          svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g);
          if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); }
        }
        if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); }
      });
      references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) });
      notesOver(g, el, N, { padT, h, padB });
      // hover: a rule, a dot per series and a bare tooltip
      const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
      const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
      const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
      const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
      hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
      const last = series[0].values.filter((v) => v != null).at(-1);
      s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
    };
    draw(); watch(el, draw); settle(draw);
    const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null;
    return { legend: lg };
  }
  function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) {
    el._fmt = format;
    const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i);
    const draw = () => {
      const { s, w, h } = frame(el, heightOf(height));
      const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format;
      const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
      const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62);
      const x = (i) => padL + band * i + band / 2;
      const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows);
      const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1));
      const g = svg('g', {}, s);
      axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick });
      notesUnder(g, el, N, { padT, h, padB });
      for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4;
      const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9');
      values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); });
      references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) });
      notesOver(g, el, N, { padT, h, padB });
      s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`);
    };
    draw(); watch(el, draw); settle(draw);
  }
  // Two directions on one scale: what went out above the zero line and what came in below it (sold and bought,
  // income and spend). The axis says which is which, with signed ticks and a caption on each side of zero.
  // mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged).
  function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) {
    if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); }
    up = up || labels.map(() => 0); down = down || labels.map(() => 0);
    const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } };
    const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side);
    const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v));
    const draw = () => {
      const { s, w, h } = frame(el, heightOf(height));
      const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down);
      const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10));
      const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
      const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62);
      const x = (i) => padL + band * i + band / 2;
      // the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left
      const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows);
      const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0]));
      const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b');
      const bwid = measure(s, below, 11) + 9;
      axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] });
      svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink });
      notesUnder(g, el, N, { padT, h, padB });
      for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4;
      labels.forEach((lab, i) => {
        const o = partial === i ? 0.5 : null;
        if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); }
        if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); }
      });
      svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g);
      references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) });
      notesOver(g, el, N, { padT, h, padB });
      s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`);
    };
    draw(); watch(el, draw); settle(draw);
    const items = [];
    if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) });
    if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) });
    if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) });
    return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
  }
  function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) {
    el.innerHTML = '';
    const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value));
    const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`;
    const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap);
    svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
    svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
    const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
    t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
    wrap.appendChild(t); el.appendChild(wrap);
  }
  // A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
  // label instead, ink for the top item, for a ranked list of money in a narrow card.
  function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) {
    el.innerHTML = '';
    const max = Math.max(...rows.map((r) => r.value)) || 1;
    const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`;
    const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1;
    rows.forEach((r, k) => {
      const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px';
      const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
      const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right';
      if (thin) {
        const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`;
        li.append(a, b, bar);
      } else {
        const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0';
        const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`;
        track.append(bar, a); li.append(track, b);
      }
      list.appendChild(li);
    });
    el.appendChild(list);
  }
  // Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is
  // already committed (bills to come), and an overrun past the track's end in the negative colour.
  // One row per category: label and figures on one line, the 8px track under them.
  function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) {
    el.innerHTML = '';
    const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums';
    const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0))));
    for (const r of rows) {
      const over = r.spent > r.planned;
      const li = document.createElement('li');
      const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
      top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
      // scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
      const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
      const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
      const pct = (v) => (v / r.planned) * 100;
      const spent = Math.min(r.spent, r.planned);
      track.innerHTML = `<span style="position:absolute;inset:0 auto 0 0;width:${pct(spent)}%;border-radius:999px;background:var(--chart-1,#2f6fdf)"></span>`
        + (r.committed ? `<span style="position:absolute;top:0;bottom:0;left:${pct(spent)}%;width:${Math.min(pct(r.committed), 100 - pct(spent))}%;background:var(--chart-1,#2f6fdf);opacity:.35"></span>` : '')
        + (over ? `<span style="position:absolute;top:-3px;bottom:-3px;left:100%;width:1px;background:var(--text,#111)"></span><span style="position:absolute;top:0;bottom:0;left:calc(100% + 2px);width:max(12px, ${pct(r.spent - r.planned)}%);border-radius:0 999px 999px 0;background:var(--negative,#c4242a)"></span>` : '');
      track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`);
      li.append(top, track); list.appendChild(li);
    }
    el.appendChild(list);
  }
  // Share against a target: one row per part, the current share as a bar and a 1px tick at the target,
  // so drift reads at a glance (never two stacked bars to compare).
  function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) {
    el.innerHTML = '';
    const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums';
    for (const r of rows) {
      const drift = r.value - r.target;
      const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
      li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
      li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
      list.appendChild(li);
    }
    el.appendChild(list);
  }
  // shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline.
  // A subline needs a 40-44px row pitch; one-line labels sit on 32px.
  function rowLabel(g, s, el, r, { y0, pitch, lw }) {
    const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
    if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); }
    else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink });
  }
  const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d));
  // day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short
  // label with today always shown and its neighbours left out, so no two headers ever touch
  function dayHeads(g, s, el, days, { x0, cw, y, today }) {
    const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
    const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1)));
    const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6;
    const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw));
    labs.forEach((t, i) => {
      if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return;
      text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted });
    });
  }
  // The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one
  // chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision).
  function kindStyle(el, k = {}) {
    const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf');
    const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {};
    const dash = k.dash ? { dash: '3 2' } : {};
    if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash };
    if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash };
    if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash };
    if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash };
    return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash };
  }
  // Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind.
  //   rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }]
  //         and { group: 'Brand' } rows for a team subheader
  //   kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } }
  //   days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index;
  //   holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons.
  // A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the
  // days before it), and is always in the bar's tooltip.
  function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) {
    const draw = () => {
      const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30;
      const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2;
      const { s, w, h } = frame(el, height);
      const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2)));
      const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw;
      const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf');
      const g = svg('g', {}, s);
      // weekends one tone step darker, faint day lines, the day header (today in ink)
      for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g);
      for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
      dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today });
      for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); }
      if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
      let y0 = head;
      for (const r of rows) {
        if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; }
        rowLabel(g, s, el, r, { y0, pitch, lw });
        const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from);
        bs.forEach((b, k) => {
          const kind = kinds[b.kind] || {}, st = kindStyle(el, kind);
          const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2;
          const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g);
          paintRect(grp, st, x0, by, x1 - x0, barHeight, 4);
          const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink;
          const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w;
          // over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words
          const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {};
          if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo });
          else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo });
          else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted });
          titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {});
          if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); }
        });
        y0 += pitch;
      }
      s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`);
    };
    draw(); watch(el, draw); settle(draw);
    const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind)));
    const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } }));
    if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) });
    return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
  }
  // Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare
  // stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip.
  //   rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }]
  //   cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash)
  function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) {
    const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3);
    const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; };
    const draw = () => {
      const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26;
      const { s, w, h } = frame(el, head + rows.length * pitch);
      const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22)));
      const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111');
      const g = svg('g', {}, s);
      dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today });
      rows.forEach((r, k) => {
        const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw });
        const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2;
        r.values.forEach((v, i) => {
          const cx = lw + i * cw + 1;
          if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; }
          const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3);
          text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink });
          titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {});
        });
      });
      s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`);
    };
    draw(); watch(el, draw); settle(draw);
    const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level)));
    const ST = styles();
    const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) }));
    return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
  }
  // When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and
  // a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written.
  //   rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }]
  //   at or compare may be null (it did not sell out); step: minutes between axis labels (60).
  function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) {
    const o = minutes(open), c = minutes(close);
    const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } };
    const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent);
    const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent);
    const draw = () => {
      const narrow = (el.clientWidth || 600) < 560, axisH = 24;
      const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32);
      const { s, w, h } = frame(el, rows.length * pitch + axisH);
      const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26)));
      const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0));
      const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0);
      const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0');
      const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16));
      const g = svg('g', {}, s), plotH = rows.length * pitch;
      // the axis: one step from the opening, the labels thinned by the same rule as the charts
      const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m);
      const picks = xPicks(marks.map(fmt.clock), x1 - x0);
      for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); }
      rows.forEach((r, k) => {
        const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2;
        if (narrow) {
          const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0;
          text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink });
          if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
          const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 ');
          if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted });
        } else rowLabel(g, s, el, r, { y0, pitch, lw });
        svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
        const cm = minutes(r.compare), am = minutes(r.at);
        if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {});
        if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {});
        if (narrow) return;
        if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); }
        else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
      });
      s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`);
    };
    draw(); watch(el, draw); settle(draw);
    const items = [];
    if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) });
    if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) });
    return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
  }
  function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); }
  window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt };
})();
assets/scenes/stage.css›
/* tasteskill / scenes / stage.css — the held stage under every scroll scene (references/scenes.md).
   Without .is-live (no script, a failed CDN, reduced motion, a phone set to 'rest') a scene is an ordinary
   section showing its finished composition. With it, the section is (hold + 1) screens tall and the
   stage sticks to the viewport for the hold. */

.scene { position: relative; }
.scene > .scene-stage { position: relative; }

html.js .scene.is-live { height: calc((var(--hold, 2) + 1) * 100svh); }
html.js .scene.is-live > .scene-stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: clip;       /* clip, not hidden: hidden would make the stage a scroll container */
  contain: layout paint;
}

/* Everything a scene moves is composited; nothing it moves is laid out again. */
.scene.is-live [data-move] { will-change: transform, opacity; }

/* Lenis */
html.is-smooth { scroll-behavior: auto; }
html.is-smooth body { overscroll-behavior: none; }

@media (prefers-reduced-motion: reduce) {
  html.js .scene.is-live { height: auto; }
  html.js .scene.is-live > .scene-stage { position: relative; height: auto; }
}
assets/scenes/stage.js›
// tasteskill / scenes / stage.js — the shared stage every scroll scene runs on (references/scenes.md).
//
// A scene is a tall section whose stage sticks to the viewport while the page scrolls past it; a GSAP
// timeline is scrubbed by that scroll. The page always scrolls natively (no wheel capture), the
// scrollbar always tells the truth, and the finished composition is what reduced motion, no script and
// a failed CDN all show.
//
// Load at the end of <body>, in this order (versions pinned):
//   <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
//   <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollTrigger.min.js"></script>
//   <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/SplitText.min.js"></script>  (only if a scene splits text)
//   <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>     (optional smoothing)
//   <script src="assets/scenes/stage.js"></script>
//   <script src="assets/scenes/<scene>.js"></script>
// and keep the one-line <script>document.documentElement.classList.add('js')</script> in <head>.
//
//   Stage.smooth()                         weighted wheel scrolling (Lenis), off for touch and reduced motion
//   Stage.scene(root, { hold, scrub, build, phone, rest })
//   Stage.split(el, 'lines' | 'words' | 'chars')   masked split text, SplitText when loaded
//   Stage.ready()                          resolves when fonts and the scenes' pictures are ready
//   Stage.init()                           starts every <section class="scene" data-scene="<id>"> on the page
//
// A page:  Stage.smooth(); Stage.init();
(() => {
  const html = document.documentElement;
  const mq = (q) => window.matchMedia(q).matches;
  const live = !!(window.gsap && window.ScrollTrigger);
  if (live) gsap.registerPlugin(ScrollTrigger);
  if (live && window.SplitText) gsap.registerPlugin(SplitText);

  const Stage = {
    live,
    get reduced() { return mq('(prefers-reduced-motion: reduce)'); },
    get phone() { return mq('(max-width: 767px)'); },
    get touch() { return mq('(pointer: coarse)'); },
    lenis: null,
    scenes: [],
  };

  // Weighted wheel scrolling. lerp 0.1-0.14 feels heavy without lagging behind the hand; touch keeps the
  // phone's own scrolling, and reduced motion keeps the browser's.
  Stage.smooth = ({ lerp = 0.12 } = {}) => {
    if (!live || !window.Lenis || Stage.reduced || Stage.touch || Stage.lenis) return Stage.lenis;
    const lenis = new Lenis({ lerp, smoothWheel: true, anchors: true, autoRaf: false });
    lenis.on('scroll', ScrollTrigger.update);
    gsap.ticker.add((t) => lenis.raf(t * 1000));
    gsap.ticker.lagSmoothing(0);
    html.classList.add('is-smooth');
    Stage.lenis = lenis;
    return lenis;
  };

  // One scroll scene.
  //   root   <section class="scene"> holding one <div class="scene-stage">
  //   hold   screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall
  //   scrub  true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight)
  //   build(tl, ctx)  add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q }
  //   phone  'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build }
  //   rest(ctx)  optional: anything extra the finished composition needs (most scenes need nothing)
  // Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too.
  Stage.scene = (root, opts = {}) => {
    if (!root) return null;
    const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts;
    const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
    const q = (sel) => [...root.querySelectorAll(sel)];
    const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); };
    if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; }
    const mm = gsap.matchMedia();
    mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => {
      const isPhone = c.conditions.phone;
      const ctx = { root, stage, phone: isPhone, q };
      const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build };
      if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); }
      root.classList.remove('is-rest');
      root.classList.add('is-live');
      root.style.setProperty('--hold', plan.hold);
      const tl = gsap.timeline({
        defaults: { ease: 'none' },
        scrollTrigger: {
          trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true,
          onUpdate: (self) => {
            root.style.setProperty('--p', self.progress.toFixed(4));
            if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats)));
          },
        },
      });
      plan.build(tl, ctx);
      // pad the timeline to exactly 1 so positions in build() read as fractions of the hold
      if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration());
      return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); };
    });
    Stage.scenes.push(mm);
    return mm;
  };

  // Masked split text. Lines rise out of their own mask; words and characters are for short headings.
  Stage.split = (el, type = 'lines') => {
    if (!el) return [];
    if (window.SplitText && live) {
      const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false });
      return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars;
    }
    // fallback: words in inline-block spans, the full text kept for screen readers
    if (!el.dataset.split) {
      el.setAttribute('aria-label', el.textContent.trim());
      el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `<span aria-hidden="true" style="display:inline-block">${w}</span>`).join(' ');
      el.dataset.split = 'words';
    }
    return [...el.children];
  };

  // Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in.
  Stage.ready = () => {
    const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean);
    return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); });
  };

  // Every <section class="scene" data-scene="<id>" data-options='{"hold":3}'> on the page, started by
  // the scene file of that id (assets/scenes/<id>.js registers Scenes['<id>']).
  Stage.init = () => {
    for (const el of document.querySelectorAll('.scene[data-scene]')) {
      if (el.__scene) continue;
      const make = window.Scenes[el.dataset.scene];
      if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; }
      let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {}
      el.__scene = make(el, o) || true;
    }
  };

  window.Scenes = window.Scenes || {};
  window.Stage = Stage;
  if (live) { window.addEventListener('load', () => Stage.ready()); }
})();
evals/evals.json›
{
  "skill_name": "design-taste-frontend",
  "how_to_run": "For each eval, give the prompt to a fresh agent that has this skill, and to a second fresh agent without it (the baseline). Render both with scripts/inspect.mjs and have a judge who does not know which is which score the screenshots with references/critique.md. Record inspector FAIL counts for both. Evals 7 to 10 are app screens: inspect them with --app (a sheet dealt by draw.mjs --app switches it on by itself) and judge them with the app-screen passes in references/critique.md.",
  "evals": [
    {
      "id": 1,
      "name": "saas-invoicing",
      "prompt": "Build a landing page for Ledgerly, an invoicing tool for freelance designers. What it does: you write an invoice in under a minute, the client pays by card or bank transfer, and it sends polite reminders by itself when an invoice is overdue. It costs 9 euros a month flat, with a 14-day free trial and no card needed. Include pricing and a short FAQ. There is no existing brand and no screenshots; the product name is all we have. Plain static HTML, CSS and JS in one folder.",
      "expected_output": "A static landing page with a clear idea drawn from invoicing itself, headlines of two lines or fewer at 1440/768/390, no eyebrow labels, no monospace, no index numbering, no thin rules, no gradients or shadows, a non-default palette and type system, a pricing section that is not three cards, and no invented customers or statistics.",
      "files": []
    },
    {
      "id": 2,
      "name": "restaurant-osteria",
      "prompt": "Make a one-page website for Osteria Nove, a 38-seat Italian restaurant in Alfama, Lisbon. Handmade pasta, a short natural wine list, dinner only, Tuesday to Saturday from 19:00. Address: Rua dos Remédios 9. Reservations by phone (+351 21 000 0009) or email ([email protected]). The page needs the menu (invent a believable short one: antipasti, pasta, dolci, with prices in euros), hours, address and how to book. We have no photographs yet. Static HTML/CSS/JS.",
      "expected_output": "A one-page restaurant site with a specific idea (for example the page as the menu itself), the menu set typographically without divider lines, practical information easy to find, bundled photographs used only as atmosphere and listed for replacement, and all house rules met.",
      "files": []
    },
    {
      "id": 3,
      "name": "portfolio-designer",
      "prompt": "I'm Mara Lindqvist, a product designer in Stockholm with eight years in fintech and health apps. I need a portfolio homepage: six selected projects (make up plausible names, clients, years and one-line descriptions), a short about paragraph, and a way to contact me ([email protected]). I don't have project images ready, so design it so it still looks finished without them, or use placeholders that I can swap. Static HTML/CSS/JS.",
      "expected_output": "A portfolio page that looks finished without project imagery (a typographic index, a name-led hero, or atmosphere photographs clearly marked for replacement), with a designed footer, a phone layout that is its own composition, and all house rules met.",
      "files": []
    },
    {
      "id": 4,
      "name": "product-ceramics",
      "prompt": "Launch page for Kiln, a ceramic pour-over coffee set (dripper, carafe and two cups) that is hand-glazed in small batches in Porto. It costs 84 euros and ships across the EU within a week. The glaze is mixed by hand so no two sets are identical. We have not shot product photos yet, so there are no images of the set. One page, with a way to buy (a button is enough). Static HTML/CSS/JS.",
      "expected_output": "A product page that does not fake product photography: it either commits to type and colour, uses one shape from the object as a motif, or uses atmosphere photographs honestly, with the price and purchase action clear, and all house rules met.",
      "files": []
    },
    {
      "id": 5,
      "name": "guesthouse-ericeira",
      "prompt": "Website for Casa Mirante, a six-room guesthouse on the cliffs above Ericeira, Portugal. Rooms from 140 euros a night with breakfast included; ten minutes' walk to the surf at Ribeira d'Ilhas; open March to November. One page: what it is, the rooms, what's nearby, and how to book (email [email protected]). We have no photographs yet. Static HTML/CSS/JS.",
      "expected_output": "A calm, photograph-led or type-led one-pager whose hero is one clear thing, with rooms and booking information easy to find, bundled coast photographs used as atmosphere and listed for replacement, and all house rules met.",
      "files": []
    },
    {
      "id": 6,
      "name": "conference-format",
      "prompt": "Landing page for Format, a two-day design conference in Rotterdam on 14 and 15 May 2027. Twelve speakers (invent plausible names and talk titles), 400 seats, tickets at 390 euros, venue: Maassilo. Sections: what it is, the line-up, the schedule in brief, tickets, and a short FAQ. No brand exists yet beyond the name. Static HTML/CSS/JS.",
      "expected_output": "An event page with a strong typographic or colour idea, the line-up and schedule set as structured text without divider lines or index numbers, a ticket section that is not three cards, and all house rules met.",
      "files": []
    },
    {
      "id": 7,
      "name": "app-budget-overview",
      "prompt": "Build the main screen of Ledgerly, a budgeting app for couples who share money: the October overview with their accounts and balances, the spending plan (planned against spent by category), bills due in the next 14 days and recent transactions. Two people use it, Mia and Tom. Desktop first, and it must work on a phone. Static HTML/CSS/JS.",
      "expected_output": "An app screen, not a landing page: a quiet shell (sidebar or rail), a clear screen title, numbers that add up and are right-aligned with tabular figures, one chart per question, colour spent on data and status only, no icons in tinted squares, no gradients or glows, no placeholder names, and a phone layout where the navigation folds away. inspect.mjs --app passes.",
      "files": []
    },
    {
      "id": 8,
      "name": "app-deploy-dashboard",
      "prompt": "Dashboard for Relay, an edge deployment platform for web teams: one project's overview with recent deployments (status, branch, commit message, author, duration, region), requests and p95 latency over the last 24 hours, the error rate, and usage against the plan's limits. Dark theme. Static HTML/CSS/JS.",
      "expected_output": "A dense, readable dark screen on a neutral (not navy) ground: one surface step or one hairline between regions, a deployments table with honest statuses and aligned numbers, charts with few gridlines and one series each, IDs in monospace at most, no rainbow series, no glow, and app inspector checks passing.",
      "files": []
    },
    {
      "id": 9,
      "name": "app-settings-billing",
      "prompt": "Workspace settings for Proofline, a design-review tool: general settings, members and roles (a members table with an invite form), billing (current plan, seats, invoices) and notifications. Static HTML/CSS/JS, desktop and phone.",
      "expected_output": "A settings screen with a label column beside its fields (or stacked fields on a phone), labels above inputs, one control height, quiet borders, honest disabled states, a members table with right-aligned figures and one status pill per row, and no decorative icons or gradients.",
      "files": []
    },
    {
      "id": 10,
      "name": "app-support-queue",
      "prompt": "The queue screen of Hearth, a support inbox for a six-person SaaS team: the list of conversations with assignee, priority, channel and time to the SLA, the open conversation with the customer's details beside it, plus filters and saved views. Static HTML/CSS/JS.",
      "expected_output": "A three-region work screen (navigation, list, conversation) that stays readable at 1440 and folds to one region at a time on a phone, with status shown once per row, times in words, no tinted icon squares or emoji, and app inspector checks passing.",
      "files": []
    }
  ]
}
library/app.json›
{
  "version": 1,
  "about": "App-screen library for tasteskill: styles (directions), shells and screen patterns, distilled from the study of 23 desktop product screens (17 light, 6 dark, 17 products) chosen by the owner. Every number is a measurement from the study or a median over it, except where a note says a value was moved for contrast or for the house palette bans. Reference products are not named; the screenshots stay private.",
  "styles": [
    {
      "id": "achromatic-ledger",
      "name": "Achromatic ledger",
      "theme": "light",
      "basis": "4 screens: a seller home, a per-agent insights page, a generated report, a settings page with usage numbers",
      "suits": [
        "finance",
        "commerce",
        "devtools",
        "support",
        "report",
        "workspace",
        "builder"
      ],
      "idea": "The chrome holds no colour at all; money and counts are set in ink, the one chart hue marks the screen's main data element, and the only other hue is a small signal where a figure changed.",
      "colourBudget": "under 1% coloured pixels; the chrome is ink, the one chart hue goes on the main data element, and the only other hue is a small change signal",
      "tokens": {
        "ground": "#f8f8f8",
        "surface": "#ffffff",
        "raised": "#eeeeee",
        "border": "#e6e6e6",
        "rule": "#f0f0f0",
        "control": "#d4d4d4",
        "text": "#0a0a0a",
        "muted": "#6b6b6b",
        "accent": "#141414",
        "positive": "#2e7d32",
        "negative": "#c4242a",
        "warning": "#92400e",
        "chart": [
          "#141414",
          "#8a8a8a",
          "#1d5fbf"
        ],
        "pills": {
          "positive": [
            "#dcfae5",
            "#04250e"
          ],
          "negative": [
            "#ffedea",
            "#831c1e"
          ],
          "warning": [
            "#fffbeb",
            "#92400e"
          ],
          "neutral": [
            "#eeeeee",
            "#3d3d3d"
          ]
        },
        "radius": {
          "card": 8,
          "control": 6
        },
        "font": {
          "family": "Geist",
          "second": "Geist Mono, for IDs and keys only",
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 24,
            "section": 16,
            "kpi": 30,
            "body": 14,
            "small": 12
          },
          "weights": [
            400,
            500,
            700
          ],
          "note": "700 is for money figures only; every other number is 400-500."
        },
        "density": "sparse",
        "rowHeight": 44,
        "sidebarWidth": 256,
        "gap": 12,
        "pad": 24,
        "controlHeight": 36
      },
      "shell": "open-plane",
      "shellAlternatives": [
        "icon-rail",
        "no-sidebar"
      ],
      "signature": "No brand colour anywhere in the chrome: the one primary action is near-black (a grey pill, #e5e5e5, is the secondary), the main data element (today's series, the booked bars, the current period) takes the one chart hue, #1d5fbf, and everything else is drawn in ink with the comparison or the part after the cursor in grey, zero balances are set in grey, and the only hue on the screen is a small triangle or a short coloured number where a figure changed.",
      "identity": [
        "A ground from the product's world in place of #f8f8f8, one level off white and low in chroma: a stone (#f4f3f1) for a bakery or a shop, a sage (#f2f5f2) for a garden or a greenhouse, a blue-grey (#f2f4f7) for a ledger or a meter. Cards stay white; a warm tint stays under the cream check (saturation under about 15%).",
        "The one chart hue (#1d5fbf, or the brand's own hue at 3:1 or more on white) on the screen's main data element: today's line, the booked bars, the current period's column, the margin that breaks. The chrome stays ink and the hue stays under 1% of the screen.",
        "Every figure in Geist Mono, as the style's decision, so money and counts read as a ledger's columns while the text stays Geist. Lint and the inspector flag mono; give the reason in the report."
      ],
      "avoid": [
        "a brand-coloured primary button",
        "tinted cards or tinted KPI tiles",
        "a coloured chart where one series in ink says it",
        "bold on anything but money",
        "a chart with no y-scale (the study's one example of this look hid magnitude behind hover and was flagged)",
        "a grey pill as the only button for the screen's main job: in testing an Approve repeated in grey on every row left the screen without a primary"
      ],
      "notes": "The studied screens of this kind used 11-12px card radii, which §9.4 counts among the rare middle values; the library sets the tight pair (8 and 6) so the style lands on the bimodal rule. After testing, the chart hue moved from an unused third series colour onto the main data element: a screen whose only hue was a delta read as a kit."
    },
    {
      "id": "framed-sheet",
      "name": "Framed sheet",
      "theme": "light",
      "basis": "6 screens: a developer platform overview, an analytics overview, an events analytics tab, a list detail, a reports dashboard, a flow builder",
      "suits": [
        "analytics",
        "devtools",
        "builder",
        "smb-admin",
        "support",
        "commerce",
        "report"
      ],
      "idea": "The app chrome is one tinted L-shaped frame; the work sits on a white sheet with a hairline edge and soft corners, so depth comes from a single tone step and no shadow.",
      "colourBudget": "under 3%; the brand shrinks to the logo and a 2px tab underline, the data carries the chroma",
      "tokens": {
        "ground": "#f9fbfc",
        "surface": "#ffffff",
        "raised": "#f4f6f8",
        "active": "#ebedee",
        "border": "#e8eaec",
        "rule": "#f0f1f2",
        "control": "#cdced1",
        "text": "#131416",
        "muted": "#5d5f60",
        "accent": "#1b6c80",
        "positive": "#1e7b4f",
        "negative": "#c4242a",
        "warning": "#92400e",
        "chart": [
          "#1b6c80",
          "#2f70bb",
          "#a86f0c"
        ],
        "pills": {
          "positive": [
            "#dcfae5",
            "#04250e"
          ],
          "negative": [
            "#ffedea",
            "#831c1e"
          ],
          "warning": [
            "#fffbeb",
            "#92400e"
          ],
          "neutral": [
            "#ebedee",
            "#353736"
          ]
        },
        "radius": {
          "card": 8,
          "control": 6,
          "sheet": 16
        },
        "sheetInset": 8,
        "font": {
          "family": "IBM Plex Sans",
          "second": "IBM Plex Mono, for IDs and keys only",
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 22,
            "section": 16,
            "kpi": 28,
            "body": 14,
            "small": 12
          },
          "weights": [
            400,
            500,
            600
          ],
          "note": "KPI values at 400; the semibold goes on the label and the active tab."
        },
        "density": "medium",
        "rowHeight": 48,
        "sidebarWidth": 228,
        "gap": 24,
        "pad": 24,
        "controlHeight": 32
      },
      "shell": "framed-sheet",
      "shellAlternatives": [],
      "signature": "One tinted frame holds the sidebar and the 48px top bar with no rule between them; the page is a white sheet inset 8px from the window's right and bottom edges, with a 1px edge, 16px corners and no shadow. The active tab is a 2px underline in ink or the accent; everything else in the chrome is grey.",
      "identity": [
        "The frame tinted from the product's colour in place of #f9fbfc: one low-chroma step of its hue (a green-grey for a climate or farm tool, a blue-grey for an observability tool, a stone for a print shop). The sheet stays white.",
        "The brand's own hue from the brief as --chart-1 in place of the dealt teal, on the main series and the one selected state, at 3:1 or more on white, never violet or indigo.",
        "IBM Plex Mono for IDs, keys and the figures in tables (one job): its figures have their own drawing, and a developer reads them as the product's."
      ],
      "avoid": [
        "a border between the sidebar and the top bar",
        "a shadow under the sheet",
        "cards whose border is heavier than the sheet's (#cdcfcf on cards read as heavy in the study)",
        "a fourth nesting level: frame, sheet and card are enough",
        "different gutters for the KPI row and the chart row (20 against 24 was flagged)"
      ],
      "notes": "The study's second data hue in this style was an orange (#e88e46) that falls inside the house terracotta range; the third chart colour here is an amber moved out of that range and darkened to 3:1 on white."
    },
    {
      "id": "accent-ladder",
      "name": "Accent ladder",
      "theme": "light",
      "basis": "3 screens: a newsletter home, a network analytics overview, a cap-table company home",
      "suits": [
        "creator",
        "smb-admin",
        "community",
        "analytics"
      ],
      "idea": "One brand hue at full strength only on small marks, and its pale tints doing all the area work: the active nav row, a plan card, badges and progress tracks.",
      "colourBudget": "3-10%, nearly all of it as pale tints of the one hue",
      "tokens": {
        "ground": "#f9fafb",
        "surface": "#ffffff",
        "raised": "#f3f4f6",
        "border": "#e5e7eb",
        "rule": "#f2f2f2",
        "control": "#d1d5db",
        "text": "#111827",
        "muted": "#5f6672",
        "accent": "#b8336a",
        "accentSoft": "#fdf2f7",
        "accentTrack": "#f8dbe8",
        "accentInk": "#6b1d3e",
        "positive": "#247b50",
        "negative": "#c4242a",
        "warning": "#92400e",
        "chart": [
          "#b8336a",
          "#6b7280",
          "#d4618f"
        ],
        "pills": {
          "positive": [
            "#dcfce7",
            "#14532d"
          ],
          "negative": [
            "#ffedea",
            "#831c1e"
          ],
          "warning": [
            "#fffbeb",
            "#92400e"
          ],
          "neutral": [
            "#f3f4f6",
            "#374151"
          ],
          "accent": [
            "#fdf2f7",
            "#6b1d3e"
          ]
        },
        "radius": {
          "card": 8,
          "control": 6
        },
        "font": {
          "family": "DM Sans",
          "second": null,
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 32,
            "section": 18,
            "kpi": 24,
            "body": 14,
            "small": 12
          },
          "weights": [
            400,
            500,
            600,
            700
          ],
          "note": "700 for the page title only."
        },
        "density": "sparse",
        "rowHeight": 45,
        "sidebarWidth": 230,
        "gap": 24,
        "pad": 24,
        "controlHeight": 32
      },
      "shell": "open-plane",
      "shellAlternatives": [
        "ruled-t-frame"
      ],
      "signature": "The full hue goes only on small marks: the one filled-button style, the data series and the active nav icon. Its 50-200 tints carry every area: the active nav row (tint fill with the dark ink of the hue), the plan or trial card, badges and progress tracks. The primary action may instead be near-black, as on the study's newsletter home.",
      "identity": [
        "The ladder built from the brand's own hue (its logo, the brief's object) in place of the dealt raspberry: the full hue on small marks, its pale tints on areas, never violet or indigo.",
        "The product's objects in the lead block: the newsletter's latest issue as its cover, the shop's products as 40px thumbnails, the course's lessons with their own art.",
        "A ground one level toward the hue's palest tint (#fbf7f8 for the raspberry) in place of #f9fafb, so the page and the tints read as one family."
      ],
      "avoid": [
        "the full hue on large areas",
        "a second accent family (four families on one screen was flagged in the study)",
        "a gradient on the trial chip or upgrade button",
        "a left accent bar on the active nav row on top of the tint (seen once in 23 screens)",
        "violet or indigo"
      ],
      "notes": "All three studied screens of this kind used violet, purple or pink as the one hue. The move does not depend on the hue, so the house's violet and indigo ban holds and the library deals a raspberry instead."
    },
    {
      "id": "editorial-greeting",
      "name": "Editorial greeting",
      "theme": "light",
      "basis": "2 screens: a team workspace home and a personal insights home",
      "suits": [
        "workspace",
        "creator"
      ],
      "idea": "A personal or team home that opens with a typeset greeting in a centred column; every number below it is set at regular weight on tonal panels.",
      "colourBudget": "under 2% for the accent; tonal panels carry the structure",
      "tokens": {
        "ground": "#ffffff",
        "surface": "#f9f9f9",
        "raised": "#ffffff",
        "border": "#eaeaea",
        "rule": "#f3f3f3",
        "control": "#d9d9d6",
        "text": "#1f201d",
        "muted": "#636361",
        "accent": "#1b6e66",
        "positive": "#2e7d32",
        "negative": "#b42318",
        "warning": "#92400e",
        "chart": [
          "#1f201d",
          "#1b6e66",
          "#8c8c88"
        ],
        "pills": {
          "positive": [
            "#dcfae5",
            "#04250e"
          ],
          "negative": [
            "#fdecea",
            "#7a1d14"
          ],
          "warning": [
            "#fffbeb",
            "#92400e"
          ],
          "neutral": [
            "#f3f3f3",
            "#3d3d3a"
          ]
        },
        "radius": {
          "card": 22,
          "control": 999
        },
        "font": {
          "family": "Instrument Sans",
          "second": "Instrument Serif, for the greeting only",
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 48,
            "section": 14,
            "kpi": 32,
            "body": 14,
            "small": 12
          },
          "weights": [
            400,
            500,
            600
          ],
          "note": "The 48px title is the serif greeting; set in the sans it is 24/400. Every figure is 400."
        },
        "density": "sparse",
        "rowHeight": 63,
        "sidebarWidth": 224,
        "gap": 16,
        "pad": 20,
        "controlHeight": 38
      },
      "shell": "open-plane",
      "shellAlternatives": [
        "framed-sheet"
      ],
      "signature": "The greeting is the h1, in a centred 1100px column on a real 12-column grid (77px columns, 16px gutters), with spans that change by row but share column lines (3/3/3/3, then 9/3, then 6/6, so every internal edge meets one above it). Every figure below is set at regular weight on tonal panels, one layer each: a panel one step off white holds its title and its content directly; a white inner card is used once, for the main chart.",
      "identity": [
        "The greeting names this team or household and today's one fact, set from the data (\"Morning, Okoro house. The car is ready by 06:10.\"), never \"Welcome back\".",
        "Each person's stable hue as a pale tint on their avatar and their own lane or column (a couple and their joint account as three lanes): identity, not data, so it stays out of the chart legends.",
        "A surface from the product's world in place of #f9f9f9, one level toward its material (a sage for a garden, a stone for a kitchen, a blue-grey for a studio), with the page white kept."
      ],
      "avoid": [
        "an emoji in the greeting",
        "a tracked uppercase date above the greeting (put the date after it or in the grey line below)",
        "a 624-864px column on a desktop screen (both were flagged as wasted width)",
        "a tray around every card (a grey panel with a white card 6px inside it, on every block, read as a bento template in testing)",
        "red as the accent: in testing red marked a record week of upgrades and read as an alarm; red is for the failing fact only"
      ],
      "notes": "One studied screen set its greeting in a serif at 48/700 and its counts in a mono; the other set the greeting at 24/400 in the sans. The red is the second screen's chart red moved two degrees of hue out of the house terracotta range. After testing, the studied red accent was replaced by a deep teal: red on good news read as an alarm."
    },
    {
      "id": "pastel-consumer",
      "name": "Pastel consumer",
      "theme": "light",
      "basis": "2 screens: a wallet home and a personal finance home",
      "suits": [
        "wallet",
        "finance"
      ],
      "idea": "Categories are coded by pale tints whose ink is the dark of the same hue, all under one large radius, and each headline sum sits inside the card that explains it.",
      "colourBudget": "5-8%, as pale tiles and data marks",
      "tokens": {
        "ground": "#f3f5f7",
        "surface": "#ffffff",
        "raised": "#f2f8fd",
        "border": "#e6e6e6",
        "rule": "#f4f4f4",
        "control": "#d6d9dd",
        "text": "#1b1d1f",
        "muted": "#5c6064",
        "accent": "#1d5fd6",
        "accentSoft": "#dcf1ff",
        "accentInk": "#0a3a80",
        "positive": "#2f7a45",
        "negative": "#c41c29",
        "warning": "#92400e",
        "chart": [
          "#1d5fd6",
          "#b45309",
          "#2a8798"
        ],
        "pills": {
          "positive": [
            "#dcfae5",
            "#04250e"
          ],
          "negative": [
            "#ffedea",
            "#831c1e"
          ],
          "warning": [
            "#fffbeb",
            "#92400e"
          ],
          "neutral": [
            "#eef1f4",
            "#3a3d40"
          ],
          "accent": [
            "#dcf1ff",
            "#0a3a80"
          ]
        },
        "radius": {
          "card": 20,
          "control": 10
        },
        "font": {
          "family": "Manrope",
          "second": null,
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 28,
            "section": 20,
            "kpi": 32,
            "body": 16,
            "small": 14
          },
          "weights": [
            400,
            500,
            600
          ],
          "note": "Money at 24-32px; card titles regular; semibold for net worth, bill amounts and actions."
        },
        "density": "medium",
        "rowHeight": 72,
        "sidebarWidth": 240,
        "gap": 24,
        "pad": 24,
        "controlHeight": 40
      },
      "shell": "open-plane",
      "shellAlternatives": [
        "icon-rail"
      ],
      "signature": "Pale category tiles with the dark of their own hue as ink (#dcf1ff with #0a3a80), one large radius (20px on cards, stepping down to 10, 8 and 5 inside), and quiet money polarity: income green with a plus sign, spending in ink, no red on ordinary outgoings. Tints go on small tiles, avatars and chips; a panel that holds a list stays white. When the product has people (two partners, a family), each gets one hue, at least 60 degrees from the next.",
      "identity": [
        "The tile hues from the account's own colours (the card's colour, each pot's colour in the app) in place of the dealt cobalt, each tile pale with the dark of its own hue as ink.",
        "The money's own objects: the card art at its real ratio, merchant marks at 24px in the rows, the goal's picture on its pot.",
        "Manrope's figures carry the balance: 30-36px at 600 with tabular figures and the pence in a smaller grey, so the number itself is the face."
      ],
      "avoid": [
        "a gradient balance card",
        "two data palettes on one page (flagged in the study)",
        "red on every outgoing amount",
        "a 624px phone-width column on a desktop screen (flagged as a stretched phone screen)",
        "a KPI strip on top: the sums belong in their cards",
        "whole panels tinted per person or category beside white cards (two surface languages on one screen)"
      ],
      "notes": "The two studied screens used a pure #0000ff and an indigo #4723f5 as their brand colour, and pastels such as #7cbecf that fall under 3:1 on white. The library uses a cobalt that reads as blue rather than indigo, and data colours darkened to at least 3:1 on the card."
    },
    {
      "id": "true-black-flat",
      "name": "True-black flat",
      "theme": "dark",
      "basis": "5 screens: an events back office, an event analytics tab, a community overview, an artist studio home, an artist insights page",
      "suits": [
        "community",
        "creator",
        "markets"
      ],
      "idea": "Night mode on true black: sidebar, top bar and page share one ground, elevation is a lightness step alone, and colour is confined to data marks.",
      "colourBudget": "at or under 3%; the accent never touches buttons, tabs or nav",
      "tokens": {
        "ground": "#000000",
        "surface": "#111111",
        "raised": "#2a2a2a",
        "border": "#1a1a1a",
        "rule": "#161616",
        "control": "#444444",
        "text": "#ffffff",
        "muted": "#b4b4b4",
        "faint": "#949494",
        "accent": "#c6f24e",
        "positive": "#c6f24e",
        "negative": "#e5484d",
        "warning": "#e5a93b",
        "chart": [
          "#c6f24e",
          "#e6e6e6",
          "#6b6b6b"
        ],
        "pills": {
          "positive": [
            "#222a10",
            "#c6f24e"
          ],
          "negative": [
            "#2c1a20",
            "#e5566f"
          ],
          "warning": [
            "#2b2414",
            "#e5a93b"
          ],
          "neutral": [
            "#222222",
            "#ffffff"
          ]
        },
        "radius": {
          "card": 20,
          "control": 999
        },
        "font": {
          "family": "Inter",
          "second": null,
          "fallback": "system-ui, sans-serif",
          "sizes": {
            "title": 20,
            "section": 16,
            "kpi": 24,
            "body": 14,
            "small": 12
          },
          "weights": [
            400,
            500,
            600
          ],
          "note": "Nothing above 600; inactive nav labels #bbb, active #fff."
        },
        "density": "medium",
        "rowHeight": 80,
        "sidebarWidth": 210,
        "gap": 14,
        "pad": 22,
        "controlHeight": 36
      },
      "shell": "ruled-t-frame",
      "shellAlternatives": [
        "no-sidebar"
      ],
      "signature": "Elevation by lightness alone (#000 ground, #111 card, #2a2a2a nested panel) with no borders or shadows on cards; the active nav item is marked only by its label going from #bbb to #fff; the primary control is made primary by inversion, a white 36px pill with black text.",
      "identity": [
        "The one data hue from the product's world in place of the dealt lime: a solar amber (#f2b33d) for an energy home, a sky blue (#4cb3f0) for water or weather, a lime only for sport. It is still the only hue on data and never touches buttons, tabs or nav. A green or yellow-green hue is also --positive; an amber that sits beside --warning leaves the warning to its pill pair.",
        "A ground with the product's temperature: the studied #000 for media and events, a warm near-black (#0e0d0c) for a home, a cool one (#0c0d0f) for a monitor, with cards one lightness step above it (#181716, #15171a). Never navy or slate.",
        "A signature element from the product's world as the lead: the energy home's power flow (sun, house, battery and grid with their live kW), an event's doors against capacity, an artist's release with its cover."
      ],
      "avoid": [
        "a navy or slate dark theme",
        "glow, neon borders or shadows",
        "a hue per chart (five hues across four charts was flagged)",
        "the accent on buttons, tabs or nav",
        "a 64px top bar left empty across most of its width (flagged twice)"
      ],
      "notes": "The studied screens of this kind used a violet-magenta (#dd6cff) and an orange (#ff5500) as their one data hue. The library deals a lime, which keeps the move and stays clear of the violet ban; because the lime is a yellow-green, it is also the positive colour (a second, mint green beside it read as two greens). The greys are #b4b4b4 for secondary text and #949494 for meta, 32 levels apart rather than the 20 of the measured #aaa and #969696, which read as one grey; #949494 still holds 4.5:1 on the #2a2a2a nested panel."
    },
    {
      "id": "data-terminal",
      "name": "Data terminal",
      "theme": "dark",
      "basis": "1 screen: a stock research view. Keep it narrow.",
      "suits": [
        "markets",
        "devtools"
      ],
      "idea": "A research or monitoring screen read in long sessions: cards darker than the ground, tabular digits that stack, signs instead of colour, and every selection marked by the same 1px accent line.",
      "colourBudget": "under 3%; the accent is never more than a 1px line",
      "tokens": {
        "ground": "#171719",
        "surface": "#131217",
        "raised": "#212023",
        "border": "#222224",
        "rule": "#1b1a1f",
        "control": "#3a3a3e",
        "text": "#ffffff",
        "muted": "#8a8c92",
        "accent": "#4a8ad6",
        "positive": "#44a282",
        "negative": "#e5566f",
        "warning": "#d9a441",
        "chart": [
          "#f2f2f3",
          "#e5566f",
          "#4a8ad6"
        ],
        "pills": {
          "positive": [
            "#1b2828",
            "#5fc39c"
          ],
          "negative": [
            "#2c1a20",
            "#e5566f"
          ],
          "warning": [
            "#2a2416",
            "#d9a441"
          ],
          "neutral": [
            "#212023",
            "#bdbdc0"
          ]
        },
        "shadow": "0 12px 28px rgba(0,0,0,.2)",
        "radius": {
          "card": 6,
          "control": 999
        },
        "font": {
          "family": "Public Sans",
          "second": null,
          "fallback": "Inter, system-ui, sans-serif",
          "sizes": {
            "title": 26,
            "section": 20,
            "kpi": 20,
            "body": 13,
            "small": 12
          },
          "weights": [
            400,
            500,
            600,
            700
          ],
          "note": "Section titles 20/400 in the muted grey; card titles 16/600; tabular figures everywhere."
        },
        "density": "dense",
        "rowHeight": 42,
        "sidebarWidth": 50,
        "gap": 24,
        "pad": 32,
        "controlHeight": 34
      },
      "shell": "icon-rail",
      "shellAlternatives": [],
      "signature": "Every selection (tab, toggle, range, nav item) is a 1px accent line exactly as wide as its label, placed on the nearest existing edge; numbers stay white or grey and carry their sign in the text ('+$24,000,000').",
      "identity": [
        "Every figure in a monospace as the style's decision (IBM Plex Mono or JetBrains Mono for each price, count and time, digits that stack), with the text in Public Sans. Lint and the inspector flag mono; give the reason in the report.",
        "The one accent line from the product's world in place of the dealt blue (the exchange's or the team's hue): still the only selection mark, never violet or indigo.",
        "A signature of the market or the system as the lead: an order book's depth as two mirrored bar lists, an incident as a minute-by-minute strip, a region map with the list beside it."
      ],
      "avoid": [
        "filled pill tabs or a coloured segmented control",
        "a chart with no scale and no dates (the studied screen had none and was flagged)",
        "tertiary text under 4.5:1 (#4c4c4f read at about 2:1 in the study)",
        "lighter cards with borders and a glow",
        "green and red on every number; the one column whose job is \"what moved today\" and a negative headline figure may carry the polarity colours"
      ],
      "notes": "The studied screen's muted grey #82848a and red #d4405d were lifted to #8a8c92 and #e5566f so text passes 4.5:1 on the card. Its card list rows sit on a 28px pitch and its table rows on 42px."
    }
  ],
  "shells": [
    {
      "id": "open-plane",
      "name": "Open plane",
      "basis": "5 of 23 screens",
      "suits": [
        "finance",
        "wallet",
        "workspace",
        "creator",
        "analytics"
      ],
      "numbers": {
        "sidebarWidth": "230-256 (230, 240, 256 in the study; 280 and 292 with a built-in icon rail)",
        "separation": "none, or one 1px hairline; the sidebar sits on the page ground or within 1-2% of it",
        "navPitch": 34,
        "navRowHeight": 32,
        "navIcon": "16px outline, about 1.5px stroke",
        "navLabel": "14px, 400, grey or ink",
        "activeItem": "neutral grey pill, radius 6-12, inset 8-12px from the sidebar edges (#e9e9e9-#ededed)",
        "topBar": "none",
        "canvas": "fluid with a 24-64px gutter, or a centred 1100px column"
      },
      "blueprint": "The labelled sidebar sits on the page ground with nothing or one hairline between it and the canvas; there is no top bar, and the page header and cards sit straight on the ground about 32-36px from the top. Workspace or account switcher at the top of the sidebar, primary nav on a 34px pitch, groups under 12-13px sentence-case grey labels with 22-56px of air above, utilities and the user row pushed to the bottom. Cards are white on the off-white ground (or the sidebar is white with a 1px border on a #f9fafb canvas). The canvas is fluid with one gutter value, or a centred column for personal homes.",
      "phone": "The sidebar folds into a 48px top bar holding the screen's name, a menu button that opens a full-screen sheet with the nav and the switcher, and at most one action. Content takes the full width with 16px margins."
    },
    {
      "id": "framed-sheet",
      "name": "Tinted frame, white sheet",
      "basis": "8 of 23 screens, in three variants",
      "suits": [
        "analytics",
        "devtools",
        "builder",
        "smb-admin",
        "support"
      ],
      "numbers": {
        "chrome": "#f6f6f6-#f9fbfc (a warm #f7f6f2 once)",
        "sheetInset": "8px from the window's right and bottom edges (12 and 16 once each)",
        "sheetRadius": "10-16, median 16",
        "sheetEdge": "1px #eaeaea-#ededed, no shadow",
        "sidebarWidth": "220-263 (228 on two screens)",
        "topBar": "48px on the chrome ground with no rule; search 548x32 centred on the window with a shortcut hint",
        "contentPadding": 24,
        "floatingVariant": "both panels inset 12px with a 12px gutter, radius 10"
      },
      "blueprint": "Sidebar and top bar share one tinted ground with no rule between them, forming an L; the page lives on a white sheet. Variant one: the sheet butts against the sidebar and the top bar and is inset 8px from the right and bottom edges, 16px radius, 1px edge. Variant two: a flush L-frame with no inset, the frame marked by one rule. Variant three: sidebar and content are two floating panels inset 12px from every edge with a 12px gutter. The top bar, when present, is 48px and holds a centred search, the account button and a help or AI icon. Inside the sheet the content has 24px padding and starts with the breadcrumb or title row.",
      "phone": "The frame disappears: the sheet becomes the screen, with a 48px top bar (menu button, title, one action) on the chrome tint. The search moves into the menu sheet or behind an icon."
    },
    {
      "id": "ruled-t-frame",
      "name": "Ruled T-frame",
      "basis": "4 of 23 screens (3 dark, 1 light)",
      "suits": [
        "community",
        "creator",
        "smb-admin"
      ],
      "numbers": {
        "sidebarWidth": "210-240",
        "topBar": "64px with a 1px bottom rule",
        "rules": "1px; #1a1a1a on #000, #f2f2f2 on white",
        "navPitch": "34 (dark) or 40 (light)",
        "switcher": "at the top bar's right end, behind a full-height 1px divider",
        "contentGutter": "29px from the sidebar rule and the window edge"
      },
      "blueprint": "A 210-240px sidebar and a 64px top bar share one ground, divided only by 1px rules. The workspace or community switcher sits at the right end of the top bar behind a full-height divider; the sidebar holds the wordmark and the nav on a 34px pitch with sentence-case group labels. On dark, the active item is its label going from #bbb to #fff. The top bar must carry something real (search, the back link and object name, the switcher): two studied screens left about 1070px of it blank and were flagged.",
      "phone": "The top bar stays at 48-56px with the switcher reduced to its mark; the sidebar becomes a full-screen menu sheet or a bottom bar of four or five destinations."
    },
    {
      "id": "icon-rail",
      "name": "Icon rail",
      "basis": "3 of 23 screens",
      "suits": [
        "markets",
        "devtools",
        "finance",
        "wallet"
      ],
      "numbers": {
        "railWidth": "50-60, median 55",
        "icon": "15-18px outline",
        "pitch": "34-49",
        "active": "icon colour alone, or a full-bleed 50px band at a 2% tint, or a 1px accent bar on the rail's border",
        "groups": "short 20px hairlines, no labels",
        "utilities": "pushed to the bottom after a gap of about 200px"
      },
      "blueprint": "An unlabelled rail of 50-60px carries the brand mark at the top and 15-18px outline icons on a 34-49px pitch; groups are split by 20px hairlines, utilities sit at the bottom. The rail sits on the page ground with a 1px edge, or is white with a faint 4px shadow on a grey ground. It saves about 180px of width against a labelled sidebar, so it suits products with few destinations and screens that need the width. Icons need tooltips and an accessible name.",
      "phone": "The rail becomes a bottom bar with the same four or five icons, each with a short label under it."
    },
    {
      "id": "no-sidebar",
      "name": "No sidebar",
      "basis": "3 of 23 screens",
      "suits": [
        "report",
        "creator",
        "community"
      ],
      "numbers": {
        "topBar": "46-48px on the page ground, no border",
        "column": "centred, 1088-1208px",
        "gutters": "152-212px at a 1512px window",
        "logo": "on the column's left edge",
        "links": "14-15px semibold grey"
      },
      "blueprint": "A 46-48px top bar on the page ground over a centred 1088-1208px column; the logo sits exactly on the column's left edge and the bar's right cluster ends near its right edge, so header and content share one column. Three to five text links, a 36px search, the account. Use it for generated reports, viewers and products with few destinations; with a sticky bar on the same ground, give it a 1px rule when content scrolls under it (the studied bar without one sliced content off).",
      "phone": "The bar keeps the logo, a search icon and a menu button; the links move into the menu sheet. The column becomes the full width with 16px margins."
    }
  ],
  "patterns": [
    {
      "id": "kpi-strip-chart-rail",
      "name": "KPI strip, main chart and a side rail",
      "screen": "overview",
      "kinds": [
        "creator",
        "smb-admin",
        "community",
        "analytics"
      ],
      "basis": "a newsletter home; a community overview",
      "blueprint": "Title row: a 24px noun or the product's name at 600-700, an optional 14-16px grey line under it, and the one primary action on the right, bottom-aligned with that line. 16-24px below, the scope row: section tabs and the date range in one row of 32-36px controls, or a 48px segmented track with the range select inside its right end. Then a grid of 1fr and a fixed 384px rail with a 24px gap. Main column: one KPI container split by 1px dividers into 3-5 equal cells (14px grey label, 24px value at 400-600, a 12px caption naming the baseline and window), then the main chart card, which is the largest thing on the screen, then the next card, all 16px apart. Rail: 16px headings outside their cards, 8px above each and 16px after the previous card; a recent-items list of 3-5 rows on a 45px pitch with 1px rules, closed by a 30px outlined 'View all' button instead of pagination. The rail starts level with the first card, not with the title.",
      "phone": "The rail moves below the main column; the KPI container becomes a 2-column grid; tabs scroll inside their own row."
    },
    {
      "id": "kpi-tabs-chart",
      "name": "KPIs as the chart's tabs",
      "screen": "overview",
      "kinds": [
        "finance",
        "commerce"
      ],
      "basis": "a seller home",
      "blueprint": "One card per subject, 8-16px apart: the main chart card runs full width, and any card holding a table of five columns or fewer, or a short list, pairs with its neighbour in two columns, the page header indented by the card padding so the title, the card titles, the numbers and the plot share one left edge. Balances that do not change during the day go first, in one container split by 1px dividers into equal columns; a zero balance is grey and its caption becomes the next step. In the main card, 2-4 KPIs sit in a row and act as the chart's series tabs: the selected one carries a 2px rule and an ink label, the others keep a grey label and their value in ink at a lighter weight (a grey value reads as disabled); when the tabs are the per-entity breakdown the brief asks for (takings by shop), nothing is greyed and the rule alone marks the selection. Choosing one swaps the series. Under them, one line chart as wide as the card's content (about 1136x220 in the study): 3-4 solid 1px gridlines, 4-6 round y ticks at 12px, a 2-3px line in ink with straight segments and no fill, an 8px hover dot with a 1px dashed guide and the line after the cursor in pale grey. Close with a 2-3 row comparison table on a 36px pitch, value and change columns right-aligned under a 14px grey header such as 'vs prior Monday'.",
      "phone": "KPI tabs become a 2-up grid above the chart, with the selected cell marked by a fill (never an underline floating inside the grid) or a row of tabs that scrolls sideways; the chart is full width, 160-200px tall, with 4 x labels; the comparison table keeps its two right-aligned columns."
    },
    {
      "id": "chart-pairs",
      "name": "Per-period and running-total pairs",
      "screen": "analytics",
      "kinds": [
        "community",
        "smb-admin",
        "analytics"
      ],
      "basis": "a network analytics overview",
      "blueprint": "Title at 24-32px with one sentence under it saying what the filters do, then one filter row of 36-38px controls (one filled or near-black, the rest borderless or text, a reset link last) and 16-24px to the KPIs. KPI row: 3 equal cards in the content column (3 x 372 + 2 x 17 = 1152 in the study) or one divided container; a 16px label over the value with the change written as a phrase on the value's baseline ('+8 in the last 90 days'), grey when it is zero. Then one row per metric: the per-period column chart on the left and its cumulative line on the right, at equal width (2 x 568 + 16 = 1152) and with the same x ticks; square bars, a 2px straight line, integer ticks from zero, no legend because each chart has one series. One gap value horizontally and vertically.",
      "phone": "Pairs stack, the cumulative chart directly under its per-period chart; KPI cards become a single column of label-value rows."
    },
    {
      "id": "insights-share-tables",
      "name": "Insights with share tables",
      "screen": "analytics",
      "kinds": [
        "analytics",
        "devtools",
        "builder"
      ],
      "basis": "a per-agent insights page",
      "blueprint": "No cards: sections are carried by whitespace and a 16/500 heading with one 14px grey sentence under it saying what the section shows. A row of up to 6 KPIs on a fixed pitch (12px grey label over a 30px tabular value, no boxes, no dividers), then one full-width time chart (about 218px plot, 5 hairline gridlines, 11-12px grey ticks, a dot legend under the title, a 'Compare to previous period' text button on the right). Then a 2 x 2 grid of small tables with a 24px gutter whose rows align across the two columns. Each table: a header of 12px grey labels with no fill and no rule, 44px rows with 2px gaps whose rounded fill (#f5f7f9, radius 8) is the data bar, its width the row's share divided by the largest share; Share in 12px grey and Count in 14px ink, both right-aligned. An empty table keeps its header and shows one dashed 1px box about 38px tall with one sentence ('No Slack channel usage in this range.').",
      "phone": "KPIs become a 2 x 3 grid; the chart is full width; the share tables stack in one column and keep their row bars."
    },
    {
      "id": "table-filter-bar",
      "name": "Table with tabs and a filter bar",
      "screen": "list",
      "kinds": [
        "community",
        "devtools",
        "commerce",
        "support",
        "smb-admin"
      ],
      "basis": "an events back office, a tracks studio, a sessions overview",
      "blueprint": "Title row: an 18-24px title on the left, at most an outlined secondary and the one primary (36px) on the right. Underline tabs for the list's states on a full-width 1px rule with a 2px indicator as wide as the label; 22-32px below, the toolbar: a search of about 252-316 x 32-36 on the left, the count ('7 events') and the sort on the right. The table sits on the panel or page ground with no card around it: a header of 12-13px grey sentence-case labels with no fill and no rule, rows on a 48-56px pitch for one line or 72-92px with a thumbnail (media height plus 24-32px), 1px rules 3-7% darker than the ground or no rules at all, the identity column given the most width, numbers right-aligned in tabular figures with the unit muted ('5 /10'), one status mark per row and one kebab at the far right. Drop a column that is empty in every row (a blank co-host column left a 300px dead band). On an overview, cap the rows and link to the full list; on the full list, state the total and load more at the end.",
      "phone": "Rows become two-line items: identity and the key number on line 1, the secondary fields in 13px grey on line 2, the kebab at the right. Tabs scroll in their own row; the search becomes a full-width field."
    },
    {
      "id": "count-cards-filter",
      "name": "Count cards that filter",
      "screen": "list",
      "kinds": [
        "community",
        "commerce",
        "support"
      ],
      "basis": "an events back office",
      "blueprint": "Above a list, 3-5 equal count cards (61px tall, 12px padding, a 13px grey label over a 16/600 value, 8px gap) are the type filter: the selected card gets a 1.5px accent border and a pale accent fill, and the list below shows that type only. Zero counts print as '0' in the same style; no deltas, no icons. This is one place where a row of identical small cards is right: the cards are peers and each one does work.",
      "phone": "The count cards become a horizontally scrolling row of chips with the count inside each, or a select above the list."
    },
    {
      "id": "settings-two-column",
      "name": "Settings in two columns",
      "screen": "settings",
      "kinds": [
        "devtools",
        "smb-admin",
        "support",
        "workspace",
        "commerce",
        "analytics",
        "finance",
        "wallet",
        "creator",
        "community",
        "builder",
        "markets",
        "report"
      ],
      "basis": "one settings page; stacking from a narrow rail card. The basis is thin.",
      "blueprint": "A centred column of about 1152px. Each section is one row: a label column of about 450px (16/500 title, 14px grey description; the title's cap top 6-8px below the panel top, level with the panel's first line) beside a panel of about 700px with a #fafafa fill, 16px radius, 16px padding, no border and no shadow. Sections are split by a full-width 1px rule with 32px above and below. Inside a panel: field labels 14/500 above 40px white inputs (1px #e8e8e8, radius 10), 8px from label to field, 16px between fields, 24px from the description to the first label, 16px to the Save button; switch rows with a 14/500 title, a 20px status badge 8px after it, a 14px grey description and a 28x16 toggle 16px from the right edge, centred on the title line. A plan-gated feature shows a 48px tinted strip with a 32px button directly above its panel, and the panel stays readable. When a panel runs much taller than its label, put the section's help or a summary of the current values in the label column rather than 250px of nothing.",
      "phone": "Each section stacks: title and description, then the panel at full width. Inside, label, 8-10px, field, 16-26px to the next pair; toggles stay on the right of their title."
    },
    {
      "id": "settings-with-usage",
      "name": "Settings with usage on top",
      "screen": "settings",
      "kinds": [
        "devtools",
        "smb-admin",
        "support"
      ],
      "basis": "one settings page",
      "blueprint": "Use this only when usage is the question of the view (a plan, billing or quota view), and show only numbers no section below repeats, at a size below the h1. When a settings screen also reports usage, put 3-4 usage numbers in one row on top (a 14px grey label, a 30px regular value, a 12px change pinned to the card bottom with its window in words, a grey dash where there is no comparison) and link out to the analytics screen ('View more') instead of drawing a chart. Below, the two-column settings rows. One dark primary per screen. Keep upsell to one surface: the studied settings page carried three nags (a top banner, a setup button and a gated strip) and was flagged for it.",
      "phone": "The usage row becomes a 2-column grid of label-value pairs; settings rows stack."
    },
    {
      "id": "wallet-balance-actions-assets",
      "name": "Balance, actions and assets",
      "screen": "wallet",
      "kinds": [
        "wallet",
        "finance"
      ],
      "basis": "a wallet home",
      "blueprint": "One column whose every element snaps to its two edges. A balance card (1px border, 16px radius, about 134px tall) holding one 32px figure and one line under it in which only the change is coloured ('-$0.09 in the last 30 days'); the title says what the figure is, so it needs no label. 32px below, 4 equal action tiles in a pale tint with dark same-hue icon and 12-14px label (4 x 144 + 3 x 16 = 624 in the study; scale the column and keep the arithmetic). 32-40px below, text tabs for the asset kinds on a 1px rule with a 2px ink indicator as wide as the word, a network or account select on the right of the same row. Then the asset table: 72px rows (48px round icon plus 24px), no dividers, two-line cells pairing a 16px value with a 14px grey unit amount, numeric columns right-aligned to shared edges, change as a small solid triangle and a coloured percentage, zero change grey, a missing share a grey em dash. On a desktop do not leave the column at phone width: the study's 624px column was flagged as a stretched phone screen; widen it to 864-1100 or give it a second column.",
      "phone": "This pattern is phone-first: the same column at full width with 16px margins, the 4 tiles in one row, the table as two-line rows with the value right-aligned."
    },
    {
      "id": "finance-home-pinned-rail",
      "name": "Finance home with a pinned accounts column",
      "screen": "overview",
      "kinds": [
        "finance",
        "wallet"
      ],
      "basis": "a personal finance home",
      "blueprint": "No page header and no KPI strip: cards start 36px from the top and each headline sum sits in the card that explains it. On the left, a 306px accounts card pinned at the viewport height minus the 36px margins opens with net worth (16/600 label left, value right) and holds the account tree with no dividers: groups 16px ink, categories 14px ink, leaves 14px grey indented 8px, values right-aligned, a 30px pitch and about 45px between groups; an empty group is a bordered box with an '+ Add account' link instead of $0.00. On the right, a 2-column grid with 24px gaps of cards that answer this month's questions, each with its own period caption: the plan (spanning both columns; a ring with an empty hole, a dot legend, the available figure beside it with a per-day line), recent spending as 61px two-line rows (payee over category, amount over date, right-aligned) and upcoming bills. Income is green with a plus sign; spending stays in ink.",
      "phone": "Net worth becomes the first card; the account tree collapses to its groups with totals; the month cards stack."
    },
    {
      "id": "builder-canvas",
      "name": "Builder canvas",
      "screen": "builder",
      "kinds": [
        "builder"
      ],
      "basis": "a flow builder in analytics mode",
      "blueprint": "No global navigation: a 44px top bar on the window ground with no rule (an exit arrow, the object's name at 16/600 with a status pill, and on the right 16px icon buttons on a 40px pitch, a secondary and a near-black primary, split by 1px dividers). Below, two panels on the ground with 8px outer margins and a 12px gap, both with a 1px border, 16px radius and no shadow: a 232px palette (16px padding, groups under 14px labels, items on a 40px pitch with a small type tile, a label and a drag grip) and the canvas, a flat well one step darker than the ground (#f4f6f8 on #f9fbfc) with no dot grid. Nodes share one width (338px in the study), 12px radius, a 1px hairline, padding 16 on top and 12 at the sides, a type tile and a 16/600 title, and their metrics in a well as key-value rows on a 32px pitch with right-aligned tabular or mono values ('0% (0)'). Nodes stack on one spine 64px apart with a 38px round insert button centred in each gap; connectors are 1px orthogonal lines with 6px rounded elbows. Overlays (date range, zoom, minimap) are small white controls pinned 16px inside the canvas corners. Canvas content is one size larger than the chrome (16 against 14). Node-type tiles may be tinted, one pastel per type, the same in the palette and on the canvas: pass --allow icon-chips to the inspector and say why.",
      "phone": "Editing a graph on a phone is rarely the job: show the flow read-only as a vertical list of nodes with their metrics, and the palette as a bottom sheet if editing is kept."
    },
    {
      "id": "inbox-triage",
      "name": "Inbox triage",
      "screen": "inbox",
      "kinds": [
        "support",
        "workspace"
      ],
      "basis": "assembled from the study's list, feed and rail measurements; no inbox screen was in the study",
      "blueprint": "Three regions on one ground: the navigation (a 230px sidebar or a 55px rail), the list, and the open item with its details. The list column has a fixed width (the study's fixed columns are 306 and 384px) and its own toolbar: saved views as underline tabs or a select, a 32px search, the count. Rows are two-line, 64-80px: line 1 is who (14/600) with the time in words right-aligned in grey ('9 min', 'Yesterday'); line 2 is the subject or last message in 13-14px grey, truncated to one line with an ellipsis. Unread rows set the name at 600 and add a 6px dot or a 16px count circle; read rows keep the name and subject in ink at 400 (grey is for meta only, never for a row's identity: faded urgent rows were the top complaint in testing). One status mark per row (priority or SLA, as a pale pill or words, not both). The selected row takes the --active fill of the nav pill (optionally a 2px accent bar inside its left edge), so it is never confused with a hover or a focus ring; never a saturated fill. The row's meta sits in fixed right-aligned slots (assignee 20px, channel 14px, priority 14px) so it lines up as columns; priority is printed only above normal. The open item fills the remaining width; its details (customer, plan, history) sit in a fixed column of 306-384px as key-value rows on a 28-32px pitch.",
      "phone": "One region at a time: the list fills the screen; choosing a row pushes the conversation; the details open from a button in its header; back returns to the list at the same scroll position."
    },
    {
      "id": "workspace-home-greeting",
      "name": "Workspace home with a greeting",
      "screen": "home",
      "kinds": [
        "workspace",
        "creator"
      ],
      "basis": "a team workspace home; a personal insights home",
      "blueprint": "A centred column on a real 12-column grid (77px columns, 16px gutters, 1100px), about 72px from the top. The greeting is the h1 (48/700 in a serif, or 24/400 in the sans), with the date after it or in a grey line under it, never as a tracked eyebrow, and no emoji. 50px below, 4 count tiles spanning 3 columns each (128px tall, 16px padding): label, value at regular weight in tabular or mono figures, a plain caption ('Ready when you are'), no deltas. Then spans that change by row but share column lines (9 + 3, then 6 + 6, so each internal edge meets one above it) for the main objects: panels with 20px padding, a 14/600 title, a count chip and a 'View all' link, holding row cards 63px tall with 8px gaps, one step lighter than the panel with a border one step darker. Pale tints may code the panels, each in the hue of the tile that counts it. Only a floating composer, if there is one, carries a shadow, and it must not cover the last rows.",
      "phone": "Greeting at 24-28px; tiles in a 2 x 2 grid; panels stack in reading order."
    },
    {
      "id": "object-detail-header-grid",
      "name": "Object header and a six-column grid",
      "screen": "detail",
      "kinds": [
        "community",
        "commerce",
        "analytics"
      ],
      "basis": "an event's analytics tab",
      "blueprint": "The page header is the object itself: one card (88px, 14px padding) holding a 50x62 cover, the 18/600 name with 28px icon buttons beside it (open, copy, share), a 13px grey meta line (date, time, place) and outlined 36px actions on the right. Under it, underline tabs on a full-width 1px rule, then a 36px row with a segmented time range on the left and a refresh button on the right. The body is one 6-column grid with one gutter (14px) in both directions whose spans change by row while every edge lands on the same lines: 3 KPI tiles of 2 columns, the main chart in 4 columns beside a side chart in 2, two progress cards of 3, a full-width trend of 6. Give the widest card to the screen's question, and do not give 824px to one ring and one legend row (flagged).",
      "phone": "The header card keeps cover, name and meta, with actions in a kebab; every grid item takes the full width in the same order."
    },
    {
      "id": "generated-report",
      "name": "Generated report",
      "screen": "report",
      "kinds": [
        "report",
        "analytics"
      ],
      "basis": "a report generated from a CSV",
      "blueprint": "No sidebar; the only chrome is a 47-48px viewer bar with a 1px rule. A centred 1088px column 33px below it: a 24/600 title, a 12px grey line 4px under it naming the source and the load time, one outlined 30px action on the right ('Refresh view'). If the data is a snapshot, one quiet notice (#fffbeb fill, 1px #fde68a, 12px #92400e text, 34px tall). Then 4 stat cards with a 12px gap (4 x 263 + 3 x 12 = 1088), a 12px grey label over an 18/600 tabular value; put the date range in the subtitle, not in a lone fifth card. Two charts side by side at the same 12px gap so their edges meet the stat columns: a time chart on a real time axis with flat labels, and a histogram with equal bins and square bottoms. Then the records table in a card: a 256x30 search in its header, a 14/600 grey header row over a 1px #e5e7eb rule, 37px rows over lighter #f3f4f6 rules, numbers right-aligned, a sort glyph on the active column only.",
      "phone": "Stats in a 2 x 2 grid; charts stacked; the table as two-line rows, or scrolling inside its own frame with the first column fixed."
    },
    {
      "id": "tabbed-object-detail",
      "name": "Tabbed object detail in a sheet",
      "screen": "detail",
      "kinds": [
        "analytics",
        "smb-admin",
        "builder",
        "support"
      ],
      "basis": "a list's engagement tab; a reports dashboard",
      "blueprint": "Inside the sheet: a breadcrumb row at 16/600 (the parent in grey, the current page as the h1, a 24px grey type chip after it) with its actions on the right (a text button and an outlined 32px dropdown). Under it, the object's tabs at 14px with a 2px ink underline that runs 8px past the label, on a 1px rule that spans the sheet. 30px below, the filter row: a label above a 300x32 select on the left; the freshness stamp ('Last updated July 21, 2026') and a 40px refresh button on the right, centred on the select's line. Then 3 KPI cards (value 36/400, its label at 16/600 under it, the period in words) and 2 chart cards, all on one 24px gutter so the rows share column lines. Do not stretch a short card to its neighbour's height (it left 250px of dead white) and do not list legend series that have no data.",
      "phone": "Breadcrumb shortens to a back link and the h1; tabs scroll; KPI cards stack as label-value rows; charts full width."
    },
    {
      "id": "report-card-legend-table",
      "name": "Report card with a legend table",
      "screen": "analytics",
      "kinds": [
        "analytics",
        "smb-admin"
      ],
      "basis": "a campaign overview",
      "blueprint": "A report card (20/600 title, a kebab top right, a 32px select for the scope) whose body splits into a 390px left block and the chart. Left: the hero number at 24/400 with its label under it and one change pill with its window ('vs. previous period'), then the legend as a mini table of 2-4 metric rows on a 57px pitch: dot, name at 15/600, a benchmark pill, the value right-aligned, the change right-aligned. Right: the line chart with 4 gridlines at round values, a 2px baseline with ticks, y labels on the right and axis text no larger than the data (12-14px; 16px ticks were flagged as louder than the numbers). Under it, a detail table whose rate cells stack the count over a 12px grey percentage on one right edge. Use the same colour for a series in the legend dot and in the line.",
      "phone": "The legend table goes above the chart; the detail table becomes two-line rows."
    },
    {
      "id": "research-lists-table",
      "name": "Research lists and a long table",
      "screen": "analytics",
      "kinds": [
        "markets",
        "devtools"
      ],
      "basis": "a stock research view",
      "blueprint": "Header: a 26/700 title over a 20px grey date, quiet pill buttons on the right; a 1px rule about 44px below, with the tabs just under it and the active tab marked by a 1px accent line as wide as its label lying on that rule. 64px below, a 2-column row of cards at about 43/57 with a 24px gap: a ranked list on a 28px pitch (16px logo, bold ticker, grey name truncated, a right-aligned grey percentage and a white value, signs in the text) and a chart card with its legend in the header (5px dots, 13px grey labels), a text range selector under the baseline (1M 3M 6M YTD All, the active one marked by the same 1px accent segment) and 'Updated daily' in 11-12px grey. Then a 20/400 grey section title and a full-width table: a 38px header of 11-12px grey labels, 42px rows over 1px rules, ISO dates and every figure right-aligned in tabular numerals, white only for the identity and the total. Give the chart a y-scale and dates (the studied chart had neither and was flagged).",
      "phone": "Cards stack; the range selector stays as text under the chart; the table becomes two-line rows with the total on the right."
    },
    {
      "id": "platform-overview-tray",
      "name": "Platform overview with a usage tray",
      "screen": "overview",
      "kinds": [
        "devtools",
        "analytics"
      ],
      "basis": "a developer platform project overview",
      "blueprint": "Sections stacked in one column about 48px apart, each with an 18/500 title on the left and a quiet action on the right ('View all', an outlined Refresh, a date-range button), and freshness in 14px grey after the title with a small status dot. First, a getting-started strip: one container split by 1px dividers into 4 equal cells (16/500 title, a 14px description, a grey arrow top right), shown until setup is done. Then the recent items table, capped at 4-5 rows on a 49px pitch: IDs in mono truncated to 8 characters plus an ellipsis with a copy icon, relative expiry in grey, rules that bleed 8px past the text column. Then usage: a #fafafa tray with 6px padding holding a KPI strip of 5 equal cells (a 14/500 label over a 24/400 value with a 14px unit suffix, '5 mins') and, 6px below, a chart card whose metric is chosen by the KPI cells themselves (the selected cell marked by a 2px rule or the active fill), never a second control repeating the KPI labels; the chart takes two-thirds of the width when its line is flat for most of the window, with the next answer beside it. Durations and timestamps in tabular figures. A missing value is a grey dash at label size, not '--' at value size.",
      "phone": "The getting-started strip becomes a 2 x 2 grid or a list; the table becomes two-line rows; the KPI strip a 2-column grid."
    },
    {
      "id": "bar-list-ranking",
      "name": "Bar list and ranking",
      "screen": "analytics",
      "kinds": [
        "analytics",
        "smb-admin"
      ],
      "basis": "an AI-search visibility overview",
      "blueprint": "A 24/700 title with one factual stat chip beside the actions on the right; underline tabs on a 1px rule; a filter row of compound outlined groups that read as a sentence ('Date | Jun 14 - Jul 13 | Daily', 'Region | United States'), 28px tall and 8px apart. KPIs: as many equal columns as there are KPIs (four across, not four in a three-column grid that orphans one), tabular or mono values with fixed decimals. Then two equal panels, each with a 73px header (14/600 title, a 13px grey description, a dropdown that switches the panel's metric) over a full-bleed 1px rule: a sorted bar list (rows on a 64px pitch, a label line, then a 10px pill bar in one colour with no track, the value 5px after the bar's end, the longest bar about 88% of the width) and a ranking table (53px rows, rules inset to the padding, a rank, a 16px logo, a soft chip on the user's own row, values right-aligned). Do not fix a panel's height so its last row is cut off (flagged).",
      "phone": "KPIs in a 2 x 2 grid; panels stack; the bar list keeps its bars at full width."
    },
    {
      "id": "deploy-health-lead",
      "name": "Current deploy and health lead",
      "screen": "overview",
      "kinds": [
        "devtools"
      ],
      "basis": "house pattern from testing: built after a flat 24-hour chart filled a whole first screen and lost to a richer layout",
      "blueprint": "Title row: the project's name as the h1 with a status pill (Healthy, Degraded) and a breadcrumb of team and product above it; repository, branch and a freshness stamp in a 14px grey line under it; the one primary action (Redeploy, or View logs when the latest deploy failed) on the right. Then a full-width card for what is live now: the production deployment's commit message at 16/600, its branch, short SHA in mono, author and age on the left, and three or four facts in divided cells on the right (domain, live since, build time, regions). Then the health strip: three or four KPIs in one divided container, each with its change in words and in the polarity colour when it moved the wrong way. Then a two-column row: the 24-hour chart at two-thirds (deploy markers as 1px ticks with their short SHA, the budget or SLO as a dashed reference line) and, at one-third, the block that explains the worst number (the spike and what reverted it, the status codes behind the error rate). Then the deployments table capped at 6 rows, a failed row carrying its failing step and a View logs action, a building row its elapsed time.",
      "phone": "The live deployment card becomes a stack of label-value rows; the KPIs a 2x2 grid; the chart full width with its markers; the explanation under it; deployments as two-line rows with region and duration on line 2."
    },
    {
      "id": "power-flow",
      "name": "Power flow summary",
      "signature": true,
      "cues": [
        "energy",
        "solar",
        "battery",
        "kwh",
        "tariff",
        "heat pump",
        "electric car",
        "ev charger",
        "home charger"
      ],
      "basis": "house pattern from round-4 testing: a home-energy overview without it read as the stock dark dashboard; real energy apps lead with this flow",
      "blueprint": "A strip under the title row that answers \"where is the power going now?\": four nodes in one row (Sun, House, Battery, Grid), each a 14px label over its live figure at 20-24px with the unit as a grey suffix (2.4 kW), the battery's charge under its figure (88%). Between neighbours, a 2px connector only where power flows now, with its direction and amount in words above it (\"to battery 1.7 kW\"); an idle link is a 1px dashed rule in the grid colour. Solar and export carry the one data hue, import from the grid is grey, the house is ink. The flows balance and are built from one live-state object that also feeds the top bar and the charts: sun + grid import + battery out = house + battery in + export. A freshness stamp at the strip's right (\"Live · 20:34\").",
      "phone": "The four nodes become a 2x2 grid in the same order (Sun, House / Battery, Grid) with the flow sentence under it (\"Sun 2.4 kW: house 0.7, battery 1.7\"), no connectors."
    },
    {
      "id": "leave-timeline",
      "name": "Team timeline with kind encoding",
      "signature": true,
      "cues": [
        "leave",
        "holiday",
        "time off",
        "absence",
        "pto",
        "vacation",
        "rota",
        "roster",
        "shift",
        "booking"
      ],
      "basis": "house pattern from round-4 testing: a leave calendar that drew every booking in the same pale grey read as a loading state",
      "blueprint": "People down the left on a 160-220px column, grouped under 12px team subheaders; days across with weekends one tone step darker, faint 1px day lines, and today as a 1px line in the one data hue with its date in ink in the header. Each kind of booking has its own mark, drawn with `Charts.timeline(el, rows, kinds)`: holiday a pale fill, off sick a 1px diagonal hatch, long leave a darker grey step, working away an outline, a pending request a dashed outline. Full colour only for what needs a decision: a request that breaks cover is outlined and labelled in the negative colour. Bars 20-24px tall at radius 4; the label inside the bar, the kind's code when the bar is too short, then the label after the bar's end, never over the days before it. One-line names on a 32px pitch; a name with a subline (team, office) on 40-44px. A public holiday is a small dot under its date whose tooltip names the office. The legend is the one the helper returns, so each swatch is its mark scaled down and only kinds on screen are listed.",
      "phone": "One week at a time, with the week switch in the same control row as any office filter; names shorten to first name and initial; the legend sits in a 2-column grid."
    },
    {
      "id": "cover-matrix",
      "name": "Cover against the minimum",
      "signature": true,
      "cues": [
        "team cover",
        "cover by",
        "staffing",
        "rota",
        "roster",
        "shift",
        "coverage",
        "on call",
        "leave"
      ],
      "basis": "house pattern from round-4 testing: a grid of 120 absolute headcounts made the reader subtract each team's minimum in their head",
      "blueprint": "A team-by-day matrix of slack against each team's minimum (+2, +1, 0, −1) rather than headcounts, drawn with `Charts.matrix(el, rows, { cols, comfort })`: the team and its rule (\"needs 7 of 10\") in a 160-220px label column on a 40-44px pitch, one cell per working day with the signed slack in 12px tabular figures. Cells with room to spare stay plain; one hue ramps toward the negative colour as the slack runs out (tight a pale tint, at the minimum a stronger tint, short the full negative with white figures), and the headcount sits in the tooltip (\"6 in of 9, needs 7\"). Today's column has its date in ink. The days that go short are the finding in the title row, beside the request that causes them.",
      "phone": "The lowest slack per team for each week (four columns), with the days behind a tap; or one team at a time."
    },
    {
      "id": "sell-out-strip",
      "name": "Sell-out time strip",
      "signature": true,
      "cues": [
        "bakery",
        "cafe",
        "café",
        "kitchen",
        "restaurant",
        "sell-out",
        "sell out",
        "sold out",
        "deli",
        "menu",
        "bake"
      ],
      "basis": "house pattern from round-4 testing: six 4px black bars for sell-out times were the loudest marks on a bakery screen and drew no comparison",
      "blueprint": "One row per line that sold out, earliest first: the line and its shop on the left; a 2px grey track on a shared opening-to-closing axis with an ink dot at today's sell-out time and a hollow tick at the comparison day's (last Sunday), drawn with `Charts.timeStrip(el, rows, { open, close, compareLabel })`; the time on the right in ink with the gap under it in 12px grey, its baseline named (\"3 h 40 m before close\", \"45 m earlier than last Sunday\"). The lines that did not sell out are counted in one closing sentence, not drawn. Axis labels on one hourly step from opening; the legend (Today, Last Sunday) is the one the helper returns.",
      "phone": "The rows keep the full width with axis labels every two hours; the gap moves under the line's name."
    }
  ],
  "kinds": {
    "finance": {
      "note": "money homes, budgeting, accounting, seller balances",
      "styles": [
        "achromatic-ledger",
        "pastel-consumer"
      ],
      "screens": [
        "overview",
        "list",
        "settings"
      ]
    },
    "wallet": {
      "note": "consumer wallets, crypto, personal finance on the go",
      "styles": [
        "pastel-consumer",
        "achromatic-ledger"
      ],
      "screens": [
        "wallet",
        "list",
        "settings"
      ]
    },
    "commerce": {
      "note": "seller and shop back offices: orders, payouts, products",
      "styles": [
        "achromatic-ledger",
        "framed-sheet"
      ],
      "screens": [
        "overview",
        "list",
        "detail"
      ]
    },
    "analytics": {
      "note": "B2B analytics, marketing and growth reporting",
      "styles": [
        "framed-sheet",
        "achromatic-ledger",
        "accent-ladder"
      ],
      "screens": [
        "analytics",
        "list",
        "detail"
      ]
    },
    "devtools": {
      "note": "developer platforms: deploys, sessions, API keys, usage",
      "styles": [
        "framed-sheet",
        "data-terminal",
        "achromatic-ledger"
      ],
      "screens": [
        "overview",
        "list",
        "settings"
      ]
    },
    "builder": {
      "note": "flow, form, page and automation builders",
      "styles": [
        "framed-sheet",
        "achromatic-ledger"
      ],
      "screens": [
        "builder",
        "list",
        "settings"
      ]
    },
    "creator": {
      "note": "newsletters, creator studios, audience insights",
      "styles": [
        "accent-ladder",
        "editorial-greeting",
        "true-black-flat"
      ],
      "screens": [
        "overview",
        "analytics",
        "list"
      ]
    },
    "smb-admin": {
      "note": "small-business and SaaS admin with plans, onboarding and growth nudges",
      "styles": [
        "accent-ladder",
        "framed-sheet"
      ],
      "screens": [
        "overview",
        "list",
        "settings"
      ]
    },
    "workspace": {
      "note": "team hubs and personal homes",
      "styles": [
        "editorial-greeting",
        "achromatic-ledger"
      ],
      "screens": [
        "home",
        "inbox",
        "settings"
      ]
    },
    "community": {
      "note": "community, events and media back offices",
      "styles": [
        "true-black-flat",
        "accent-ladder"
      ],
      "screens": [
        "overview",
        "list",
        "detail"
      ]
    },
    "support": {
      "note": "support inboxes, queues, helpdesks",
      "styles": [
        "achromatic-ledger",
        "framed-sheet"
      ],
      "screens": [
        "inbox",
        "list",
        "settings"
      ]
    },
    "markets": {
      "note": "trading, research and monitoring read in long sessions",
      "styles": [
        "data-terminal",
        "true-black-flat"
      ],
      "screens": [
        "analytics",
        "list",
        "detail"
      ]
    },
    "report": {
      "note": "generated or exported reports with no app around them",
      "styles": [
        "achromatic-ledger",
        "framed-sheet"
      ],
      "screens": [
        "report"
      ]
    }
  }
}
library/directions.json›
[
 {
  "id": "name-as-monument",
  "name": "The name as a monument",
  "sectors": [
   "agency",
   "studio-portfolio",
   "personal-portfolio",
   "hospitality",
   "restaurant",
   "music",
   "culture",
   "fashion",
   "event",
   "architecture",
   "real-estate",
   "physical-product",
   "consumer-app",
   "other"
  ],
  "tones": [
   "bold",
   "editorial",
   "luxury"
  ],
  "needs": [],
  "idea": "The name is the headline. Set it so large that it becomes architecture: as wide as the viewport, cropped by an edge, or stacked to fill the screen. Nothing else on the first screen competes; the description of what this is drops to a single small sentence. The idea comes from treating the name's letters as material. Ask what the subject can do to its own name: a photograph of the work sits inside a counter or replaces a letter, the name repeats down the page like a reel, the letters are split to opposite corners and the page happens between them. In the study this was the single most common device among sites scoring 7.5 or more.",
  "composition": "First screen: the wordmark spans 88–100% of the viewport width (font-size 16–24vw, set with a clamp so it never wraps), anchored either to the bottom edge with its descenders nearly touching it, or dead centre with everything else pushed to the corners. One line of supporting text at 16–20px sits in a corner on a shared margin; navigation is three or four words in the opposite corner. After the hero the page is deliberately small and calm: a narrow text column or a simple two-column grid, so the contrast with the monument holds. The name returns once more at full width as the footer, in the inverse colours. Margins are tight (20–32px) so the type reaches the edges. Two type sizes do nearly all the work: the monument and a small text size, with one mid size for section headings.",
  "typography": "One family. The display cut must survive enormous sizes: tight tracking (-0.04 to -0.07em), leading 0.8–0.9, optical kerning on. Section headings are modest (28–40px) on purpose.",
  "typeClasses": [
   "grotesk-tight",
   "condensed-heavy",
   "extended-heavy",
   "heavy-black",
   "serif-display"
  ],
  "colour": "Two values carry the page (ground and ink). The accent, if used at all, appears on one thing only: the single action or the inset image frame. The footer inverts ground and ink.",
  "paletteTags": [
   "light-neutral",
   "dark-neutral",
   "two-tone",
   "mono-accent"
  ],
  "imagery": "Few images, used as inserts in the typography rather than as illustrations beside it: one photograph inside or behind the name (a 4:5 or 1:1 frame replacing a letter, or tucked between two words), then a short run of large full-width or half-width photographs further down. Square corners. No collage of small thumbnails.",
  "sectionTags": [
   "type-led",
   "quiet",
   "poster"
  ],
  "motion": "scaling-wordmark",
  "richness": "Scale. The size of the name against the smallness of everything else is the entire visual event, so protect both ends: do not shrink the name to make room, and do not let body text creep above 20px.",
  "pitfalls": "A long name set small is just a logo. If the name has more than about 12 characters, stack it on two lines or crop it with the viewport edge; never drop below 12vw. Do not add a second big headline under the name. Do not decorate the letters.",
  "seenIn": [
   "aww-nothin",
   "opl-kereliott",
   "aww-paul-kalkbrenner",
   "opl-nia-creative",
   "aww-house-of-honey-1",
   "aww-produx-design"
  ],
  "allow": []
 },
 {
  "id": "sentence-as-layout",
  "name": "The sentence is the layout",
  "sectors": [
   "agency",
   "saas",
   "ai-product",
   "dev-tool",
   "nonprofit",
   "culture",
   "education",
   "physical-product",
   "consumer-app",
   "finance",
   "health",
   "event",
   "other"
  ],
  "tones": [
   "bold",
   "editorial"
  ],
  "needs": [],
  "idea": "One short sentence, set at poster scale, is the composition. The trick that makes it an idea rather than a big headline: something from the subject sits inside the sentence where a word would be. A strip of photograph fills the gap between two words; a small live fragment of the product replaces the noun; the object the sentence names is pictured at the size of a letter. Write the sentence first, find the word that can be shown instead of said, and put the showing in its place. The reader completes the sentence with their eyes.",
  "composition": "The sentence is four to seven words and runs as one or two full-width lines at 9–13vw. Inline with the type, on the baseline, sits one media slot: a rounded-rectangle or square frame exactly one line-height tall and two to three ems wide, holding a photograph or a looping crop. Above and below the sentence the screen is empty except for a small navigation line and one supporting sentence set small, both on the same left margin. Later sections repeat the grammar at smaller scale: each is a short statement on the left six columns with its proof (image, figure, list) on the right, so the page reads as a series of sentences with evidence. Generous vertical space (160px or more) between sections.",
  "typography": "Display weight with real presence (medium to bold), tracking -0.03 to -0.05em, leading 0.95–1.0 so two lines sit close. The inline frame aligns to cap height and baseline exactly; set it with em units so it scales with the type.",
  "typeClasses": [
   "grotesk-tight",
   "serif-display",
   "condensed-heavy",
   "serif-light-large"
  ],
  "colour": "A calm ground with ink at full contrast. The only colour comes from the photograph inside the sentence; if the palette has an accent, use it once, on the action.",
  "paletteTags": [
   "two-tone",
   "light-neutral",
   "single-field",
   "mono-accent"
  ],
  "imagery": "One strong, simple photograph that reads at small size (a face, an object, a texture, a horizon), cropped to a wide strip inside the sentence. The same image may open to full width in the next section, so the small slot feels like a window onto something larger.",
  "sectionTags": [
   "type-led",
   "poster",
   "quiet"
  ],
  "motion": "masked-headline",
  "richness": "The collision of big type and one live image inside it. Because there is only one image on the first screen, choose it with care and give it perfect alignment to the letters.",
  "pitfalls": "The sentence must be short enough for two lines on a phone, where the inline frame drops to its own line between the two halves. Do not style one word differently to fake the device. Do not use more than one slot per sentence.",
  "seenIn": [
   "aww-colonia-zacamil",
   "aww-bleibtgleich26",
   "opl-spatial",
   "aww-coffeetech-r",
   "opl-midnight-2"
  ],
  "allow": []
 },
 {
  "id": "pinned-poster",
  "name": "The pinned poster",
  "sectors": [
   "culture",
   "music",
   "event",
   "fashion",
   "personal-portfolio",
   "studio-portfolio",
   "physical-product",
   "ecommerce",
   "hospitality",
   "consumer-app"
  ],
  "tones": [
   "bold",
   "editorial"
  ],
  "needs": [],
  "idea": "Something stays put while everything else changes. A title holds its place across the viewport and the images beneath it swap; the name is split to two opposite corners and the page passes between the halves; a single frame stays centred while its contents change. Every scroll step is a new cover of the same magazine. The idea suits subjects that are a series (a book of portraits, a line-up, a collection, a set of projects): the fixed element says what the series is, the changing element shows its members.",
  "composition": "A sticky layer holds the fixed element for the height of two to three viewports: usually the title at 10–14vw across the middle or top of the screen, or two words pinned to the top-left and bottom-right corners. Under it, a stack of full-height panels scrolls by, each a single large photograph (portrait 4:5, centred, 40–50% of the viewport wide) with one line of caption at the same small size everywhere. The title sits above the images in the stacking order and uses `mix-blend-mode: difference` or a solid colour that reads on every panel. When the pinned run ends, the page releases into ordinary flowing sections with a narrow measure. Build with `position: sticky`; never hijack the scroll.",
  "typography": "The pinned title is the only large type on the page. Captions and body share one small size (15–17px). Tight tracking and solid weight so the title reads over photographs.",
  "typeClasses": [
   "grotesk-tight",
   "condensed-heavy",
   "heavy-black",
   "extended-heavy"
  ],
  "colour": "One strong title colour against a pale or dark ground. Each panel may take its own flat ground colour sampled from its photograph.",
  "paletteTags": [
   "two-tone",
   "photographic-neutral",
   "single-field"
  ],
  "imagery": "A series of photographs with the same crop ratio and the same framing (all portraits, all objects, all rooms), so that swapping them reads as one image changing. Five to eight in the pinned run.",
  "sectionTags": [
   "photo-led",
   "poster",
   "type-led"
  ],
  "motion": "pinned-story",
  "richness": "Repetition with change. The fixed title makes each new photograph feel like an event, so the photographs must be good and consistent.",
  "pitfalls": "If the title is not legible over every panel, the idea fails: test each one. Keep the pinned run under six viewports or it feels like a trap. On a phone the title shrinks to 16–18vw and stays pinned at the top; panels become full-width. A pin longer than three screens, or one where the screens under it are nearly empty, read in blind tests as a stuck page. Every screen under the pin needs its own content, and nothing may pass over the pinned words.",
  "seenIn": [
   "opl-city-of-angels",
   "aww-normal-is-boring",
   "aww-glitch-grit",
   "aww-revelatio-studio"
  ],
  "allow": [],
  "needsPictures": true
 },
 {
  "id": "colour-chapters",
  "name": "Colour chapters",
  "sectors": [
   "agency",
   "music",
   "event",
   "education",
   "nonprofit",
   "saas",
   "consumer-app",
   "culture",
   "physical-product",
   "health",
   "finance",
   "ai-product"
  ],
  "tones": [
   "bold",
   "playful"
  ],
  "needs": [],
  "idea": "Every section is a chapter with its own flat colour, floor to ceiling. The page reads like a set of posters hung in a row, held together by one typeface and one grid. The colours are not decoration: they come from the subject (the brand's packaging, the artist's record sleeves, the product's own colour ways, the categories of the content) and each chapter's colour means that chapter. Decide the sequence like a designer orders a book: a strong opener, a quiet second, a surprise in the middle, a dark or very light closer.",
  "composition": "Sections are full-bleed fields, each at least 90svh tall, butted directly against each other with no gap and no divider. Inside every field the same 12-column grid and the same margins apply, and each field holds one thing: a huge heading (8–12vw) with a small paragraph, or one photograph with a caption, or one list. Text colour is chosen per field for strong contrast (ink or paper, never grey). Headings start on the same left edge in every chapter, so the eye feels the system as colours change. The navigation and the footer take the colours of the first and last chapter.",
  "typography": "One typeface in two weights. Headings very large and tight; body 18–20px. Because colour is loud, the type stays plain: no italics, no second face.",
  "typeClasses": [
   "grotesk-tight",
   "heavy-black",
   "geometric",
   "rounded-friendly"
  ],
  "colour": "Four to six flat fields used at full strength, one per section, each with its assigned text colour. No tints, no gradients between them: the hard edge where two fields meet is the look.",
  "paletteTags": [
   "flat-loud",
   "pastel-fields"
  ],
  "imagery": "Sparing. One photograph or object per chapter at most, either cut out and sitting directly on the colour, or a plain rectangle with generous colour around it. Photographs should be simple enough not to fight the field.",
  "sectionTags": [
   "colour-field",
   "type-led",
   "poster"
  ],
  "motion": "colour-on-scroll",
  "richness": "Colour at scale. A whole screen of one saturated colour is rich on its own, so a chapter needs few elements; it still needs its content at full size, because a coloured screen with one small line on it reads as empty.",
  "pitfalls": "Check contrast on every field with the palette's assigned ink. Do not put cards on the fields. Do not use more than six colours. Two neighbouring fields should differ clearly in lightness as well as hue.",
  "seenIn": [
   "aww-warm-fuzzy",
   "aww-paul-kalkbrenner",
   "opl-anidachi",
   "aww-aardvark-book-club",
   "aww-ai-in-design-report-2026"
  ],
  "allow": []
 },
 {
  "id": "place-carries-it",
  "name": "The place carries it",
  "sectors": [
   "hospitality",
   "restaurant",
   "real-estate",
   "architecture",
   "event",
   "health",
   "culture",
   "nonprofit"
  ],
  "tones": [
   "quiet",
   "luxury",
   "warm"
  ],
  "needs": [],
  "idea": "For a place, the photograph is the argument. One full-bleed image of where you would be standing fills the first screen, and the design steps back: the name sits along the bottom edge like a film title, or small in a corner, and nothing else interrupts the view. The page that follows keeps that restraint: few words, large pictures, long pauses. The designer's work here is editing: choosing the one frame that makes someone want to be there, and then not covering it.",
  "composition": "First screen: a single photograph at 100vw by 100svh, focal point kept clear of text. The name is set either very large (14–20vw) hugging the bottom edge with 16–24px of margin, or small (20–28px) top-left with a one-line description bottom-left. Navigation is two to four words in the top corners in the same small size. If text sits on the image, a soft neutral scrim under the text area only. Below, sections alternate between a short centred or left-set paragraph in a narrow column (max 52ch) with wide empty margins, and photographs shown large: one full-bleed, then a pair at different sizes, then one portrait offset to the right. Practical information (hours, address, rates, booking) is gathered once near the end in a plain two-column block.",
  "typography": "A face with some grace for the name; small, calm, well-spaced text everywhere else. Sentence case. Body 17–19px with leading around 1.55.",
  "typeClasses": [
   "serif-display",
   "serif-light-large",
   "condensed-heavy",
   "grotesk-light"
  ],
  "colour": "Neutral paper or deep neutral ground chosen to flatter the photographs; text ink drawn from the darkest tone in the imagery. Colour lives in the pictures.",
  "paletteTags": [
   "photographic-neutral",
   "dark-neutral",
   "earth"
  ],
  "imagery": "The dealt set is the palette. Choose four to six photographs with the same light. The hero image must have a calm area for the name. Full-bleed or large with square corners; never thumbnails, never a carousel of ten.",
  "photoTreatment": "Leave photographs ungraded. If text overlays one, darken only the area behind the text with a neutral scrim.",
  "sectionTags": [
   "photo-led",
   "quiet"
  ],
  "motion": "slow-crossfade",
  "richness": "The photographs, at full size, uninterrupted. Empty paper around short text makes the next image land harder.",
  "pitfalls": "A weak hero photograph cannot be fixed by typography; pick again. Do not stack a headline, a paragraph and two buttons on the image. Do not use a booking bar that covers the bottom of the view; one small action is enough.",
  "seenIn": [
   "aww-white-desert",
   "opl-eagles-nest",
   "aww-tengile-malamala-collection",
   "aww-izanami",
   "aww-seasats"
  ],
  "allow": [],
  "needsPictures": true
 },
 {
  "id": "page-is-the-object",
  "name": "The page is the object",
  "sectors": [
   "restaurant",
   "event",
   "hospitality",
   "culture",
   "music",
   "physical-product",
   "ecommerce",
   "nonprofit",
   "education",
   "personal-portfolio",
   "consumer-app",
   "other"
  ],
  "tones": [
   "warm",
   "playful",
   "editorial"
  ],
  "needs": [],
  "idea": "Find the printed or physical artefact that already exists in the subject's world and build the page as that thing. A wedding site is the invitation card. A restaurant is its menu. A conference is the ticket; a record is its sleeve; a shop is the receipt or the swing tag; an archive is a stack of tabbed folders; a course is the syllabus sheet. The artefact supplies everything a template would otherwise invent: its proportions, its typographic manners, its way of grouping information. Visitors recognise the object instantly, which is why these pages feel specific.",
  "composition": "The first screen shows the artefact as a single filled panel (paper colour on a contrasting ground) centred or offset, with believable proportions: a card at 5:7, a ticket at about 2.2:1 with a notch cut by `clip-path`, a menu as a tall sheet. All primary content lives on it, set the way that object is set: centred and symmetrical for an invitation, in tidy aligned columns for a menu or ticket. The ground behind is a flat colour or one full-bleed photograph. As the page continues, each section is another piece of the same stationery: a second card, a tear-off stub, a folded panel, always filled shapes with real edges (straight, notched, scalloped, perforated by a dotted `border` of round dots at 4px or more), never outlined boxes. The footer is the back of the object.",
  "typography": "Take the manners of the artefact: a bookish serif with small caps avoided, or a blunt condensed face for tickets and posters. Sizes stay within what print would use relative to the panel; the name is large on the panel, details are small and exact.",
  "typeClasses": [
   "serif-text",
   "serif-display",
   "slab",
   "condensed-heavy",
   "grotesk-tight"
  ],
  "colour": "Paper and ink plus the ground the paper sits on. Two or three flat colours in total, like a two-colour print job.",
  "paletteTags": [
   "two-tone",
   "earth",
   "light-neutral"
  ],
  "imagery": "Optional. One photograph behind the object, or small photographs placed on it like tipped-in plates. The object itself is the image.",
  "sectionTags": [
   "object-led",
   "type-led",
   "colour-field"
  ],
  "motion": "curtain-footer",
  "richness": "Recognition. The shaped edges, the print-like alignment and the limited inks make a familiar object out of plain HTML.",
  "pitfalls": "Choose an artefact that truly belongs to the subject, or it becomes costume. Shape the edges with fills and `clip-path`; an outlined rectangle with a thin border is not an object. Keep text sizes readable on a phone, where the object becomes full width with its shaped edge kept at top and bottom.",
  "seenIn": [
   "opl-sarah-matt-wedding",
   "aww-mosbys-files",
   "opl-the-deep-end",
   "opl-weeksync",
   "opl-le-petit-bleu",
   "opl-archiv"
  ],
  "allow": []
 },
 {
  "id": "one-motif",
  "name": "One motif",
  "sectors": [
   "saas",
   "finance",
   "consumer-app",
   "ai-product",
   "dev-tool",
   "health",
   "physical-product",
   "restaurant",
   "education",
   "nonprofit",
   "other"
  ],
  "tones": [
   "playful",
   "technical",
   "warm",
   "bold"
  ],
  "needs": [],
  "idea": "Take one shape from the subject and let it run the whole system. A drilling tool's octagonal cross-section becomes the buttons, the image masks and the loader. A two-account money app becomes two circles that recur as icon, inline dot and section colour. A café's striped awning becomes every band on the page. Look at the product, the logo, the object or the word in the headline and find the simplest geometry in it (a circle, an arc, a chamfer, a stripe, a notch, a ring). Then use only that shape, at three or four very different scales, everywhere a designer would normally reach for a generic rounded rectangle.",
  "composition": "Hero: the motif appears once at enormous scale as a flat filled shape, cropped by the viewport (a circle 120vh across entering from the right, a chamfered block filling the lower half), with a short headline at 6–8vw and one supporting sentence set on the remaining ground, left-aligned on a shared margin. Through the page the motif returns as: the mask for every photograph (via `clip-path` or `border-radius` made from the same geometry), the shape of the primary button, the bullet beside list items at text size, and one full-width band that separates the two halves of the page. Sections use simple two-column or single-column layouts so the shape is the only thing with character.",
  "typography": "A clean geometric or grotesk face that echoes the shape (round bowls for circles, squared terminals for chamfers). Medium weight; headings 6–8vw in the hero and 3–4vw after.",
  "typeClasses": [
   "geometric",
   "grotesk-tight",
   "rounded-friendly"
  ],
  "colour": "The motif carries the accent colour at large scale, flat. Everything else is ground and ink. A second motif colour is allowed only if the subject has a real pair (two accounts, two modes).",
  "paletteTags": [
   "mono-accent",
   "two-tone",
   "pastel-fields"
  ],
  "imagery": "Photographs appear inside the motif's shape. If there is product interface to show, it sits on a flat colour stage shaped by the motif. Draw the shape with CSS or a simple inline SVG path; do not draw illustrations.",
  "sectionTags": [
   "colour-field",
   "object-led",
   "type-led"
  ],
  "motion": "shutter-image",
  "richness": "One shape at many sizes. The giant cropped shape gives the hero mass and colour without a single image.",
  "pitfalls": "One motif means one. A circle here, a hexagon there and a blob behind is the generated look this replaces. The motif must come from the subject and you must be able to say from where in one clause.",
  "seenIn": [
   "aww-sstr-friction-reduction",
   "opl-tara",
   "opl-boaxt",
   "opl-midnight-2",
   "opl-superlocal-2026",
   "opl-billow"
  ],
  "allow": []
 },
 {
  "id": "product-performs",
  "name": "The product performs",
  "sectors": [
   "saas",
   "dev-tool",
   "ai-product",
   "consumer-app",
   "finance"
  ],
  "tones": [
   "technical",
   "quiet"
  ],
  "needs": [],
  "idea": "Software is most convincing when it is doing its job in front of you. Instead of describing features, put small, true, working fragments of the product on the page: the actual control, with real content, responding to the pointer. A colour picker's loupe reads the page's own background and prints its value. A list tool lets you tick an item right there. A type-scale plugin sets the page's own headings. Find the one interaction that is the product and make the hero that interaction; then show each further capability as another fragment at real size. No dashboard collage, no browser frame, no screenshots shrunk until unreadable.",
  "composition": "Hero: a short plain headline (4–6vw, left-aligned, upper-left third) and one sentence; the rest of the first screen is the main fragment at actual interface size, sitting directly on the page or on one flat stage colour, large enough to read every label. The fragment is built in HTML and works. Below, capabilities are a single-column sequence, each a row of two parts on a 5/7 split: a two-line statement on one side and the relevant fragment on the other, with the fragments varying in size and shape so the column never becomes a zigzag of equal blocks. Fragments are cropped pieces of interface (a menu, a row, a control, a panel edge), not whole windows. One section pins a fragment while three short statements change it.",
  "typography": "The marketing type matches the product's interface type so the fragments and the page feel like one thing. Headline modest; interface text at its true size (13–15px) inside fragments.",
  "typeClasses": [
   "grotesk-tight",
   "grotesk-light",
   "geometric"
  ],
  "colour": "Neutral ground. The product's own interface colours provide the colour. At most one flat stage colour behind fragments.",
  "paletteTags": [
   "light-neutral",
   "dark-neutral",
   "mono-accent"
  ],
  "imagery": "The interface fragments are the main imagery: build them carefully with real labels, real numbers and sensible states (one hovered, one selected). Around them, two or three real pictures of the customer's world (found with scripts/assets.mjs), never stock of people at laptops.",
  "sectionTags": [
   "ui-led",
   "quiet"
  ],
  "motion": "pinned-story",
  "richness": "Fidelity. A fragment with believable content and a real hover state is more interesting than any illustration. Spend the effort there.",
  "pitfalls": "A fake interface made of grey bars and circles is the worst outcome; if you cannot build a believable fragment, change direction. Mark each fragment's root element with the attribute data-ui: inside it the product's own conventions apply (1px control borders, small radii, a shadow on its open menu) and the inspector leaves them alone; outside it the house rules hold as usual. data-ui is for cropped fragments only, never for a page wrapper. Do not put the fragment in a browser or laptop frame.",
  "seenIn": [
   "opl-dabbit",
   "opl-rows",
   "opl-typewave",
   "opl-shelf",
   "opl-uni",
   "opl-kontu"
  ],
  "allow": []
 },
 {
  "id": "quiet-instrument",
  "name": "The quiet instrument",
  "sectors": [
   "saas",
   "dev-tool",
   "ai-product",
   "personal-portfolio",
   "consumer-app",
   "finance",
   "other"
  ],
  "tones": [
   "quiet",
   "technical"
  ],
  "needs": [],
  "idea": "Radical understatement. Where every competitor shouts, this page almost whispers: a headline barely larger than body text, a narrow column, small exact demonstrations, and a great deal of untouched space. The restraint is the claim. It says the thing is simple, finished and sure of itself. This only works when it is carried through completely; one loud element breaks it. In the study, a tool for simple lists sold itself with a 27px headline and 12–14px copy and was one of the most confident pages in the set.",
  "composition": "A single column 440–560px wide, placed left of centre (starting at about 22% of the viewport width) or dead centre, running the whole page. The headline is the one large thing on the first screen: two lines at display size (5–7vw), medium weight, starting on the column's left edge and allowed to run wider than the column. The supporting paragraph follows at body size (17–19px) in the full ink. One small primary button and one text link. Below, each point is a short paragraph followed by a compact demonstration or image no wider than the column. Sections are separated only by space (96–140px). Nothing spans the full width until the footer, which is a single quiet line. The first screen is mostly empty and that emptiness is composed: the column's top edge sits at about 30% of the viewport height.",
  "typography": "One family, two weights. A display size for the headline (5–7vw), one heading size for section titles (about 32–40px), body at 17–19px, a small size for captions (14–15px). Leading 1.5 for text. Letter-spacing near zero. The typeface must be handsome at small sizes.",
  "typeClasses": [
   "grotesk-light",
   "serif-text",
   "geometric"
  ],
  "colour": "Paper and two inks (full and soft). One accent used on exactly one element per screen.",
  "paletteTags": [
   "light-neutral",
   "mono-accent"
  ],
  "imagery": "Precise rather than loud: cropped interface details at true size, and real photographs of the customer's world held to the column width, each doing one job. One short looping clip can sit at column width too. Never a large stock hero image.",
  "sectionTags": [
   "quiet",
   "ui-led",
   "type-led"
  ],
  "motion": "stillness",
  "richness": "Precision. With so little on the page, alignment, spacing and the exact size relationships are the design. Every element shares the column's left edge.",
  "pitfalls": "This is the one direction where a small headline is right; the inspector will not object, but do not mix it with a large display line. Emptiness must look decided: keep the column edges exact and the vertical rhythm even. Not suitable when the subject needs emotion or photography to sell. Quiet is not thin: the column must still deliver every piece of content the brief asks for, in full, at 16px or more in the full ink. In blind tests the quiet version lost every time it was quiet everywhere: a small headline beside an empty panel read as unfinished. Quiet means few things, not small things: the headline and the demonstration are large, and every screen holds content.",
  "seenIn": [
   "opl-rows",
   "opl-compile",
   "opl-plastic",
   "opl-bueno-fyi",
   "aww-gil-huybrecht"
  ],
  "allow": []
 },
 {
  "id": "hero-object",
  "name": "The object, large",
  "sectors": [
   "physical-product",
   "ecommerce",
   "consumer-app",
   "fashion",
   "finance",
   "health",
   "restaurant",
   "music"
  ],
  "tones": [
   "bold",
   "luxury",
   "quiet"
  ],
  "needs": [
   "product-photo"
  ],
  "idea": "Show the thing, bigger than seems reasonable. One photographed object fills the first screen, scaled until the viewport crops it, with the text small and tucked beside or behind it. The crop is the idea: a record whose edge leaves the top-right corner, a bottle whose shoulder runs off the bottom, a chair seen so close you read its grain. Being that close reads as confidence in the object. Everything else on the page serves it: details as tight close-ups, facts as short lines, one way to buy.",
  "composition": "First screen: the object occupies 55–75% of the viewport and breaks at least one edge. It sits on a flat field (ground colour or one bold colour), never in a box. The headline is short and either very large and partly hidden behind the object (10–13vw, the object overlapping two or three letters) or small (28–36px) in the opposite corner with a one-line description. No navigation bar beyond a wordmark and one action. Following sections: a row of three or four close-up details at different crop ratios (not equal tiles), a single wide photograph of the object in use, a plain list of facts set large (materials, size, price) aligned to one edge, then the purchase action. The object returns once at the end from a different angle.",
  "typography": "Either very large and structural or very small and precise; pick one for the headline and hold it. Facts are set in the text face at 22–28px, not in a table.",
  "typeClasses": [
   "grotesk-light",
   "grotesk-tight",
   "extended-heavy",
   "serif-light-large"
  ],
  "colour": "A flat field chosen to make the object's own colour sing (its complement, or a tone darker than its shadow side). One field for the whole page, or a second for the facts section.",
  "paletteTags": [
   "single-field",
   "light-neutral",
   "pastel-fields",
   "dark-neutral"
  ],
  "imagery": "Use the product's own photographs if they exist. Otherwise use a still-life from the dealt set as a stand-in and say so. Prefer images with a plain background; use `mix-blend-mode: multiply` on light fields so a white studio background disappears into the page colour.",
  "photoTreatment": "Scale the main image well past its container (`width: 120–160%`) and let `overflow: hidden` on the section do the cropping.",
  "sectionTags": [
   "object-led",
   "photo-led",
   "colour-field"
  ],
  "motion": "shutter-image",
  "richness": "The object's surface at close range, and the tension of the crop.",
  "pitfalls": "A small centred product shot with space all round is a catalogue thumbnail, not this direction. If the image has a busy background it cannot sit on a field; crop tighter or change image. Keep text off the object's focal area.",
  "seenIn": [
   "opl-when-you-wish-upon-a-starboy",
   "opl-plastic",
   "opl-archiv",
   "aww-moto-finance",
   "aww-the-watch",
   "opl-aevion"
  ],
  "allow": []
 },
 {
  "id": "specimen-catalogue",
  "name": "The specimen catalogue",
  "sectors": [
   "ecommerce",
   "physical-product",
   "restaurant",
   "fashion",
   "culture",
   "education",
   "health",
   "other"
  ],
  "tones": [
   "playful",
   "warm",
   "editorial"
  ],
  "needs": [
   "product-photo"
  ],
  "idea": "Present what is on offer the way a field guide presents birds: each item alone on a flat colour, same scale, same angle, named plainly. A bakery's pastries become specimens and the specimens become the menu. A furniture maker's chairs each get their own colour tile. The format flatters ordinary objects because it treats each as worth looking at, and it replaces both the feature grid and the product-card grid with something that has a point of view.",
  "composition": "Hero: a left-aligned statement of what this is (5–7vw, upper-left) beside or above a tight cluster of four to seven specimen tiles of different sizes arranged as a loose mosaic that runs off the right edge. Each tile is a flat colour square or 4:5 rectangle with one photograph centred on it, no border, 8–12px gaps. The main section is the catalogue itself: a grid of tiles in which each item has its name and one fact set beneath the tile in the text face, aligned left to the tile edge. Tile colours rotate through three or four fields from the palette. One tile in every six or seven is double size. Supporting sections (about, how to order) are plain text on the ground colour, kept short.",
  "typography": "Friendly and plain. Item names at 18–22px in medium weight, facts beneath in the soft ink. One big statement in the hero; no other display type.",
  "typeClasses": [
   "grotesk-tight",
   "serif-text",
   "slab",
   "rounded-friendly"
  ],
  "colour": "The tiles are the colour: three or four flat fields from the palette, repeated in a steady rotation. Ground stays neutral.",
  "paletteTags": [
   "pastel-fields",
   "flat-loud",
   "light-neutral"
  ],
  "imagery": "Photographs of single items on plain backgrounds, all cropped to sit at the same apparent size within their tile. On light tiles, `mix-blend-mode: multiply` removes white studio backgrounds. If the subject's own item photographs are missing, use the dealt set and say so.",
  "sectionTags": [
   "object-led",
   "colour-field",
   "photo-led"
  ],
  "motion": "drag-row",
  "richness": "Rhythm of colour tiles and the pleasure of comparing like with like.",
  "pitfalls": "It needs at least six items that can be shown as objects; with three it is a feature row. Keep labels outside the tiles. No shadows under the objects, no hover lift.",
  "seenIn": [
   "aww-noho",
   "opl-madie",
   "opl-humanoid-index",
   "opl-smallbits",
   "aww-butter"
  ],
  "allow": []
 },
 {
  "id": "borrowed-category",
  "name": "Dressed as something else",
  "sectors": [
   "finance",
   "saas",
   "consumer-app",
   "physical-product",
   "health",
   "dev-tool",
   "ai-product",
   "education",
   "nonprofit",
   "real-estate",
   "other"
  ],
  "tones": [
   "bold",
   "playful",
   "luxury"
  ],
  "needs": [],
  "idea": "Art-direct the subject as if it belonged to a different, more vivid category. A money app is sold like a streetwear drop. An industrial roaster is presented like a design object. Drilling hardware gets the calm of a watch brand. A parcel-locker service becomes a fairground of stickers. Choose the borrowed category by asking what feeling the audience is missing from this kind of product (swagger, calm, glamour, play) and which category owns that feeling. Then take its manners completely: its type, its pacing, its way of photographing things. The contrast between dull subject and vivid dress is what people remember.",
  "composition": "Depends on the borrowed category, so decide it explicitly and write it in your notes. Fashion drop: one shouted condensed headline across the full width, a single cropped image under it, then product facts as a blunt list. Watch or perfume: a centred object on a dark field, a small serif name, very few words, long vertical pauses. Poster or zine: flat colour blocks, heavy type, tilted cut-out images. Museum: one item per screen on a plinth-like band of colour with a short label beside it. Whichever you choose, the page structure stays simple (four to six sections) and every section uses the borrowed manners, including pricing and the footer.",
  "typography": "The borrowed category's type, fully committed: condensed and loud, or refined and small. No halfway.",
  "typeClasses": [
   "condensed-heavy",
   "serif-display",
   "extended-heavy",
   "heavy-black"
  ],
  "colour": "The borrowed category's palette, which will be unlike the subject's sector norm. That mismatch is intended.",
  "paletteTags": [
   "flat-loud",
   "two-tone",
   "dark-neutral",
   "single-field"
  ],
  "imagery": "Photograph the subject the way the borrowed category would be photographed. With the dealt set, choose images for attitude, not literal relevance, and crop them hard.",
  "sectionTags": [
   "poster",
   "type-led",
   "photo-led",
   "colour-field"
  ],
  "motion": "edge-marquee",
  "richness": "Attitude. The page has a recognisable voice from another world, applied to a subject nobody expected it for.",
  "pitfalls": "Name the borrowed category in one word before you start, or the result is merely loud. The content must still be clear: what it is, what it costs, how to get it. Avoid borrowing from 'tech startup', which is the default you are escaping.",
  "seenIn": [
   "opl-uglycash",
   "aww-coffeetech-r",
   "aww-sstr-friction-reduction",
   "opl-locker-land",
   "aww-moto-finance",
   "aww-vero-new-york"
  ],
  "allow": []
 },
 {
  "id": "contact-sheet",
  "name": "The contact sheet",
  "sectors": [
   "personal-portfolio",
   "studio-portfolio",
   "agency",
   "fashion",
   "culture",
   "architecture",
   "music",
   "ecommerce"
  ],
  "tones": [
   "quiet",
   "technical",
   "editorial"
  ],
  "needs": [],
  "idea": "Show everything at once, small, in order. The work is laid out like a photographer's contact sheet or an archive drawer: dozens of small frames in strict rows, with all text set at one small size. Nothing is featured, which is the point: the volume and consistency of the body of work is the statement, and the visitor does the choosing. It suits people and studios whose strength is range and output.",
  "composition": "The name, or one sentence about the work, sits at display size (6–9vw) across the top of the first screen, and the sheet begins directly under it, still inside the first screen. The first screen opens with three or four short blocks of small text across the top on a 12-column grid (who, what, where to reach them), all at 14–16px, and immediately below, the sheet begins: rows of thumbnails at a fixed height (90–140px) with their natural widths, 6–10px gaps, each project forming a run of four to eight frames preceded by its title in the same small text. The sheet fills the whole page width edge to edge with 16–20px margins. Hovering a run dims the others and shows that project's one-line description in a fixed position. A single enlarged frame may interrupt the sheet once per screen at four times the size.",
  "typography": "One face. The name at display size on the first screen; everything else at one text size and one small size; hierarchy below the name comes from position and from a single brighter ink for titles.",
  "typeClasses": [
   "grotesk-light",
   "grotesk-tight"
  ],
  "colour": "Black or white ground, so the thumbnails are the only colour.",
  "paletteTags": [
   "dark-neutral",
   "light-neutral"
  ],
  "imagery": "Many images: at least 24 frames to make a sheet. They can be project photographs from the dealt set cropped to mixed ratios, or page captures of the work. Square corners, no captions on the frames.",
  "sectionTags": [
   "dense",
   "photo-led",
   "quiet"
  ],
  "motion": "index-preview",
  "richness": "Density and order. The mass of small images forms a texture; the strict rows keep it calm.",
  "pitfalls": "With fewer than about 20 images it looks unfinished; choose another direction. Do not add a big headline above the sheet. On a phone the frames become two per row at a fixed height and each run scrolls sideways. Rows that run past the right edge read as clipped in screenshots and to most visitors: wrap the frames onto the next line, or end each row inside the grid.",
  "seenIn": [
   "aww-gil-huybrecht",
   "opl-smallbits",
   "opl-gunes-atakan-peksen",
   "opl-proooof",
   "aww-oimachi"
  ],
  "allow": [],
  "needsPictures": true
 },
 {
  "id": "editorial-report",
  "name": "The printed annual",
  "sectors": [
   "culture",
   "nonprofit",
   "education",
   "finance",
   "saas",
   "ai-product",
   "health",
   "agency",
   "other"
  ],
  "tones": [
   "editorial",
   "bold"
  ],
  "needs": [],
  "idea": "Lay the page out like a well-designed printed report or magazine feature: a masthead image, a tight oversized title, text held to a strict column line, and colour used in solid blocks the way a two-colour print job would. It gives weight and authority to subjects that are really about content: findings, a manifesto, a programme, a serious product. The reader should feel that someone edited this.",
  "composition": "A fixed vertical axis at about 42% of the viewport width organises everything: headings begin on it, body columns hang from it, and the 0–42% zone holds small side notes, figures or stays empty. Masthead: a wide image band (photographic mosaic or a single crop) across the top 45% of the first screen; below it the title at 7–9vw starting on the axis, with a two-sentence standfirst at 20–24px. Sections alternate between text pages (title on the axis, body in one or two columns of 34–40ch to its right) and full-bleed colour pages carrying one large figure, quotation or image mosaic. Mosaics are gutterless: photographs and flat colour tiles butted together in a 4- or 6-cell grid. Page numbers, running heads and other print furniture are left out.",
  "typography": "A tight heavy grotesk or a sharp serif for titles; an excellent text face for reading at 18–20px with leading near 1.5. Headings in sentence case, tracked tight.",
  "typeClasses": [
   "grotesk-tight",
   "serif-display",
   "serif-text"
  ],
  "colour": "Paper, ink and two or three solid block colours. A colour page is a whole section in one flat colour.",
  "paletteTags": [
   "flat-loud",
   "two-tone",
   "mono-accent"
  ],
  "imagery": "Photographs used as blocks in mosaics alongside flat colour tiles; mixed subjects are fine if cropped tight and converted to one treatment.",
  "photoTreatment": "Give all photographs in a mosaic one treatment: greyscale via `filter: grayscale(1) contrast(1.05)`, or all full colour. Do not mix.",
  "sectionTags": [
   "type-led",
   "colour-field",
   "dense",
   "poster"
  ],
  "motion": "masked-headline",
  "richness": "The column line, the mosaics and whole pages of colour give structure and variety without ornaments.",
  "pitfalls": "The axis must be exact and reused everywhere or it reads as random indentation. Keep paragraphs to a real reading measure. On a phone the axis collapses to the left margin and side notes move inline above their paragraph.",
  "seenIn": [
   "aww-ai-in-design-report-2026",
   "opl-applied-imagination",
   "opl-the-queue",
   "opl-artem-taradash",
   "opl-belgrade-architecture"
  ],
  "allow": []
 },
 {
  "id": "loud-flat-poster",
  "name": "Loud and flat",
  "sectors": [
   "agency",
   "consumer-app",
   "music",
   "event",
   "education",
   "nonprofit",
   "physical-product",
   "saas",
   "culture"
  ],
  "tones": [
   "bold",
   "playful"
  ],
  "needs": [],
  "idea": "A page with the directness of a street poster: flat saturated colour, the heaviest type you can find, short blunt statements, and photographs cut out and stuck on. It is for subjects with a voice. Write the page as things a person would actually say out loud, and let the design say them at full volume. The craft is in the control beneath the noise: a strict grid, two or three colours, and exact alignment keep it from becoming a mess.",
  "composition": "Hero: a statement of three to six words in ultra-heavy type at 11–15vw, filling the width, lines stacked tight (leading 0.85), flush left with 20–28px margins, on a flat saturated ground. One cut-out or hard-cropped photograph overlaps the type from the right or bottom, tilted 3–6 degrees. A single chunky button in the contrasting flat colour. Sections are solid panels in alternating palette colours: one is a row of short statements each on its own panel of unequal width; one is a single photograph bleeding off two edges with a caption block set over a solid colour bar; one is a large plain list. Panels meet edge to edge. Corners are square or use one large radius (28–40px) consistently.",
  "typography": "The heaviest weight available, set tight, mostly lowercase or mostly uppercase (choose one). Body text is a plain grotesk at 18–20px.",
  "typeClasses": [
   "heavy-black",
   "condensed-heavy",
   "extended-heavy",
   "rounded-friendly"
  ],
  "colour": "Three or four flat colours at full strength including black or white. Each panel is one colour; text on it is one other.",
  "paletteTags": [
   "flat-loud"
  ],
  "imagery": "Photographs with attitude, cropped hard or cut to a simple shape with `clip-path` (circle, arch, blunt polygon), slightly rotated, overlapping the type. Two or three on the page.",
  "sectionTags": [
   "poster",
   "colour-field",
   "type-led"
  ],
  "motion": "edge-marquee",
  "richness": "Colour mass, type mass, and overlap.",
  "pitfalls": "Headlines still obey the two-line rule: at 13vw that means very few words, which is correct for this voice. No outlines, no offset shadows, no sticker confetti. Rotation is for two or three images only, never for text blocks.",
  "seenIn": [
   "aww-warm-fuzzy",
   "aww-aardvark-book-club",
   "opl-uglycash",
   "opl-depo-budget-app",
   "aww-trevor-noah"
  ],
  "allow": []
 },
 {
  "id": "split-stage",
  "name": "The split stage",
  "sectors": [
   "restaurant",
   "hospitality",
   "personal-portfolio",
   "physical-product",
   "fashion",
   "real-estate",
   "health",
   "culture",
   "ecommerce",
   "saas",
   "consumer-app"
  ],
  "tones": [
   "warm",
   "quiet",
   "editorial"
  ],
  "needs": [],
  "idea": "Half the screen never moves. One side is a fixed photograph (or object, or product view) that stays in place for the whole visit; the other side is a calm scrolling column with everything you need to read. The fixed half keeps the feeling present while the reader gets on with the facts, like sitting in the room while reading the menu. As sections pass, the fixed image may quietly change to the one that matches.",
  "composition": "Desktop: a two-column page, 50/50 or 45/55. One column is `position: sticky; top: 0; height: 100svh` and holds a single photograph filling it edge to edge, or the object centred on a flat field. The other column scrolls and holds all content in a single narrow measure (max 46ch) with comfortable padding (48–72px): name and one-line description first, then sections separated by generous space, then contact and footer. Headings in the scrolling column are modest (32–44px). The column grounds differ: photograph on one side, flat paper colour on the other, meeting at a clean vertical edge. On a phone the fixed half becomes a 60svh image at the top that stays sticky behind the first section, then releases.",
  "typography": "A text-first setting: a readable serif or a calm sans at 17–19px, with one display size for the name. Left-aligned throughout.",
  "typeClasses": [
   "serif-text",
   "serif-display",
   "grotesk-light",
   "slab"
  ],
  "colour": "The paper colour of the reading column is chosen against the photograph: a tone taken from its shadows or its lightest area. One ink, optionally one accent ink for links and the action.",
  "paletteTags": [
   "two-tone",
   "earth",
   "light-neutral",
   "photographic-neutral"
  ],
  "imagery": "One excellent portrait-orientation photograph for the fixed half, plus three or four more that crossfade in as their sections arrive. Small inline photographs in the reading column are optional and should be few.",
  "sectionTags": [
   "quiet",
   "photo-led",
   "type-led"
  ],
  "motion": "slow-crossfade",
  "richness": "A full-height photograph always in view, against a quiet, well-set column of text.",
  "pitfalls": "The reading column must be genuinely well typeset, since it is half the page. Do not put cards or grids in it. Check that the fixed image crops well at short viewport heights. In blind tests the commonest complaint was monotony: several screens of the same split with only the text changing. Change what the reading half holds from section to section (a list, a large figure, a table, a full-colour panel, a drawing) and let at least one section break the split and run full width.",
  "seenIn": [
   "opl-le-petit-bleu",
   "opl-rafael-derolez-2026",
   "opl-flowjam-2",
   "opl-devonshire"
  ],
  "allow": []
 },
 {
  "id": "dense-tiles",
  "name": "The well-kept board",
  "sectors": [
   "agency",
   "studio-portfolio",
   "saas",
   "dev-tool",
   "ai-product",
   "culture",
   "music",
   "other"
  ],
  "tones": [
   "technical",
   "bold"
  ],
  "needs": [],
  "idea": "The whole page is a board of tiles fitted edge to edge with narrow gaps, like a tidy desk seen from above. Each tile is one thing: the name, a statement, a photograph, a number, a link, a short list. There is no hero and no sequence of bands; the page is one continuous composition that the eye roams. It suits subjects with many small true things to show (a studio with work, writing, clients and news; a product with several distinct capabilities) and it replaces the generic bento by having real content in every tile and a strict system for sizes.",
  "composition": "A 12-column grid with 8px gaps and 8–12px outer margins, filled completely, no empty cells. Tiles use only four sizes (for example 6×4, 6×2, 3×2, 3×4 in column × row units of about 90px). Every tile is a flat filled rectangle: ground colour one step from the page, or a palette colour, or a photograph covering it. One radius everywhere (0, or 6–10px). The first screen contains: a name tile (the name at display size, the largest type on the page) with the wordmark set large, a statement tile with the description at 28–40px, two or three image tiles and one action tile in the accent colour. Text inside tiles is top-left aligned with 20–24px padding and never centred. Below the first screen the board continues with the same sizes; the last row is the footer made of tiles.",
  "typography": "One grotesk in two weights. Inside tiles: a large size for single statements, a small size for lists. No uppercase labels in tile corners.",
  "typeClasses": [
   "grotesk-tight",
   "geometric"
  ],
  "colour": "Page ground, tile ground one step lighter or darker, and one or two palette colours on selected tiles. Photographs supply the rest.",
  "paletteTags": [
   "dark-neutral",
   "light-neutral",
   "mono-accent"
  ],
  "imagery": "Photographs fill whole tiles with `object-fit: cover`; choose images that crop well to both wide and tall tiles.",
  "sectionTags": [
   "dense",
   "colour-field",
   "photo-led"
  ],
  "motion": "stillness",
  "richness": "The fit. A full board with consistent gaps and varied tile contents is satisfying in the way a packed suitcase is.",
  "pitfalls": "Tiles are filled, never outlined, and never carry shadows. Do not put an icon and a two-line feature blurb in every tile; vary what tiles hold. Decide the phone layout as its own board: two columns, tiles re-ordered so the name and statement lead.",
  "seenIn": [
   "aww-oimachi",
   "aww-aspen-search",
   "aww-realevate",
   "opl-justin-lerner"
  ],
  "allow": []
 },
 {
  "id": "uneven-photographs",
  "name": "Photographs at uneven sizes",
  "sectors": [
   "hospitality",
   "real-estate",
   "architecture",
   "fashion",
   "restaurant",
   "culture",
   "health",
   "nonprofit",
   "studio-portfolio",
   "physical-product"
  ],
  "tones": [
   "quiet",
   "luxury",
   "warm",
   "editorial"
  ],
  "needs": [],
  "idea": "A calm page where the pictures are placed like prints on a gallery wall: each at its own size and position, with text set small and light between them. Nothing lines up in the obvious way, yet everything sits on a grid you can feel. The unevenness is what makes it read as art-directed by a person: a small portrait far left, a wide landscape running off the right edge, a square that overlaps the next section. It lets photographs lead without ever becoming a slideshow.",
  "composition": "A 12-column grid with wide margins (5–7vw). The hero is a single line of large light type (6–9vw) across the top third, and below it one photograph placed off-centre (columns 6–12, 16:10), its top edge overlapping the baseline zone of the headline by a few pixels of deliberate tension. Each following section places two or three photographs with different spans and vertical offsets: for example a 3:4 portrait in columns 2–5 starting 120px lower than a 16:10 landscape in columns 7–12; then a single small 1:1 in columns 9–11 beside a paragraph in columns 2–6. Paragraphs are short (max 40ch) and sit in the gaps. No two consecutive sections use the same arrangement. Vertical spacing is large and irregular on purpose (120–260px).",
  "typography": "A light or regular weight at large size for the few headings; small, quiet text. Serif or a slender sans. Everything left-aligned to grid lines.",
  "typeClasses": [
   "serif-light-large",
   "serif-text",
   "grotesk-light"
  ],
  "colour": "A pale neutral ground with one deep ink taken from the photographs. No accent beyond a quiet link colour.",
  "paletteTags": [
   "light-neutral",
   "earth",
   "photographic-neutral"
  ],
  "imagery": "Six to nine photographs from one set, in a deliberate mix of ratios (3:4, 4:5, 1:1, 16:10, 21:9). Square corners. Captions, if any, are one short line under an image in the soft ink.",
  "sectionTags": [
   "photo-led",
   "quiet"
  ],
  "motion": "shutter-image",
  "richness": "Placement. The differing sizes and offsets create movement on a still page.",
  "pitfalls": "Uneven is not random: every image edge lands on a column line, and offsets come from a small set of values. On a phone, keep the unevenness with alternating widths (100%, 72% left, 60% right) rather than stacking everything full width.",
  "seenIn": [
   "aww-tengile-malamala-collection",
   "aww-sobha-privy-collection",
   "aww-kononenko-architectural-bureau",
   "opl-zara-webb"
  ],
  "allow": [],
  "needsPictures": true
 },
 {
  "id": "dark-room",
  "name": "The dark room",
  "sectors": [
   "hospitality",
   "restaurant",
   "music",
   "fashion",
   "physical-product",
   "finance",
   "architecture",
   "studio-portfolio",
   "ai-product",
   "personal-portfolio"
  ],
  "tones": [
   "quiet",
   "luxury"
  ],
  "needs": [],
  "idea": "One dark field from top to bottom, and in it, lit things. Photographs and objects appear like exhibits in a dim gallery: each with space around it, each the brightest thing on its screen. Type is light, small and sparse. The darkness is not a theme switch; it is a room, and the page is a slow walk through it. It suits subjects sold on atmosphere and material: an evening restaurant, a metal card, a record, a house at dusk.",
  "composition": "A single near-black or deep-toned ground runs the full page with no section bands. First screen: either the name at full width in a light weight along the bottom edge with one dim photograph above it, or one object or photograph centred at 40–50% of the viewport with a line of text beneath at 16–18px. Each later screen holds one or two lit items at a generous size (the main one 40–60% of the screen width) with its text set beside it at full reading size; the darkness frames the items, it does not replace content. Images vary in size but all share the same darkness at their edges so they sit in the ground without frames. Practical details come last in a simple two-column text block. The only solid light element on the page is the single primary action.",
  "typography": "Light or regular weights; they glow on dark. Avoid heavy bold, which looks cheap on black. Headings 4–7vw at most; text 16–18px with leading 1.6 and a slightly lifted ink (not pure white).",
  "typeClasses": [
   "grotesk-light",
   "serif-light-large",
   "serif-display",
   "extended-heavy"
  ],
  "colour": "A dark ground with a tint (blue-black, green-black, brown-black), a soft light ink, a dimmer ink for secondary text, and one warm or bright accent used once per screen at most.",
  "paletteTags": [
   "dark-neutral"
  ],
  "imagery": "Low-key photographs: dark backgrounds, one light source, subjects emerging from shadow. Their dark edges should blend into the page ground.",
  "photoTreatment": "If a photograph's background is lighter than the page, darken the image slightly (`filter: brightness(0.85) contrast(1.05)`) rather than adding a frame.",
  "sectionTags": [
   "photo-led",
   "quiet",
   "object-led"
  ],
  "motion": "slow-crossfade",
  "richness": "Light against dark, and the slow pacing.",
  "pitfalls": "Grey text on black fails contrast quickly: check it. No glow effects, no neon accent, no coloured light blobs. A dark page with a bright busy photograph is not this direction; choose low-key images. Dark does not mean empty: every screen still carries its content at a readable size, and running text is a light ink at 7:1 or more, not a dim grey.",
  "seenIn": [
   "opl-kereliott",
   "aww-moto-finance",
   "aww-izanami",
   "aww-cipher",
   "opl-deeo-studio-2"
  ],
  "allow": [],
  "photoTone": "dark"
 },
 {
  "id": "type-only-statement",
  "name": "Type and nothing else",
  "sectors": [
   "saas",
   "dev-tool",
   "ai-product",
   "finance",
   "agency",
   "nonprofit",
   "education",
   "personal-portfolio",
   "event",
   "other"
  ],
  "tones": [
   "bold",
   "quiet",
   "editorial",
   "technical"
  ],
  "needs": [],
  "idea": "The first screen is made only of words, colour and space, good enough that nobody misses a picture there. Pictures arrive later, large and few, as relief from the type. This is the honest answer when a subject has nothing worth photographing: instead of inventing illustrations, set what you have to say as well as it can be set. The typography carries a point of view through scale (one enormous statement, everything else small), through measure (a narrow, bookish column or a full-width line), and through one committed colour.",
  "composition": "First screen: the statement, two lines at most, set at 9–14vw and occupying the full width with tight leading, positioned in the upper two-thirds. One supporting sentence at 20–24px sits bottom-left on the same margin and the single action sits bottom-right on the same baseline. No navigation bar; two or three links in a top corner. Sections that follow are typographic set pieces, each different: a list of five to eight capabilities set at 4–6vw one per line; a paragraph at 28–36px in a 28ch column pushed to the right half; a pair of large numerals with a sentence each; a single quotation filling a colour field. Each section uses a different horizontal position (left, right, full) so the page has movement without imagery.",
  "typography": "This direction lives or dies on the face and its settings. Display at tight tracking and leading; text sizes few and far apart (for example 15, 22, 34 and the display size). Use real typographic detail: proper quotes, balanced line breaks, hung punctuation where it helps.",
  "typeClasses": [
   "grotesk-tight",
   "serif-display",
   "heavy-black",
   "condensed-heavy",
   "serif-light-large"
  ],
  "colour": "Either one saturated ground for the whole page with a single ink, or paper and ink with one section inverted and one in the accent as a full field. Colour does the job images would have done.",
  "paletteTags": [
   "single-field",
   "two-tone",
   "flat-loud",
   "light-neutral",
   "dark-neutral"
  ],
  "imagery": "The first screen has no picture. Further down, real pictures of the subject's world (found with scripts/assets.mjs) arrive large and few: a full-bleed photograph between two type sections, a looping clip in one band, an archival image beside a list. Do not add icons, shapes, patterns or abstract graphics to compensate for anything.",
  "sectionTags": [
   "type-led",
   "poster",
   "colour-field"
  ],
  "motion": "masked-headline",
  "richness": "Scale contrast and colour mass. A capability list set at 5vw is an image in its own right.",
  "pitfalls": "Without pictures, weak copy has nowhere to hide: write real sentences. Vary the set pieces; four sections of big-heading-plus-paragraph is a text document, not a design. Keep all headings within two lines.",
  "seenIn": [
   "aww-bleibtgleich26",
   "opl-spatial",
   "aww-coffeetech-r",
   "opl-statements",
   "aww-nothin"
  ],
  "allow": []
 }
]
library/heroes.json›
[
 {
  "id": "name-under-photograph",
  "name": "Name under a photograph",
  "archetype": "giant-wordmark",
  "directions": [
   "name-as-monument",
   "place-carries-it",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A full-bleed photograph fills the top 58% of the 1440×900 viewport, touching the top, left and right edges, `object-fit: cover`, with its focal point in the upper two-thirds. The lower 42% is a flat ground colour that butts against the photograph with no gap. The name runs across this band on one line, spanning all 12 columns between 24px margins, at clamp(120px, 19vw, 300px), tracking -0.05em, line-height 0.85, its baseline 28px above the bottom edge so descenders nearly touch it. Fit the size to the length of the name so it never wraps. In the strip of ground between photograph and name, 24px under the photograph's edge, sits one row at 17px: the supporting sentence on columns 1–5 at the left margin, and the single action as a small solid rectangular button flush to the right margin. Navigation is four text links at 15px laid over the photograph's top-right corner, 24px in, with a neutral scrim behind that row only if the image is light there. No logo is repeated: the name is the logo. Nothing else is on the screen.",
  "mobile": "The photograph takes the top 50svh at full width, re-cropped around its focal point. The name sits under it at 23vw on one line; a name longer than seven characters breaks onto two lines at 21vw, line-height 0.85, and fills the band down to a 16px bottom margin. The supporting sentence at 16px and the action button stack above the name on the left margin. The links collapse to one 'Menu' word over the photograph, top-right.",
  "headline": "The name itself on one line, up to about 12 characters at 19vw (drop towards 13vw for longer names), and at most two lines on a phone.",
  "motion": "scaling-wordmark",
  "seenIn": [
   "aww-house-of-honey-1",
   "opl-kereliott",
   "aww-lxl-creative"
  ]
 },
 {
  "id": "stacked-name-two-inks",
  "name": "Stacked name, two inks",
  "archetype": "type-poster",
  "directions": [
   "name-as-monument",
   "colour-chapters",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "One saturated flat field covers the whole 1440×900 viewport and there is no image. The name is broken into two lines and set flush left from a 40px margin at clamp(110px, 15vw, 230px), heavy weight, tracking -0.04em, line-height 0.82 so the lines almost touch. The block starts 14% down and fills the top 58% of the screen; the longer line reaches at least column 11. Line one is set in the light ink and line two in the dark ink, so the break reads as a change of voice: split a two-word name between the lines, or break one long name with a hyphen at a syllable. The top edge carries only small text at 15px, 24px in: a monogram top-left, and two or three text links plus the single action as an underlined link top-right. The supporting sentence, 18px in the light ink and max 30ch, is right-aligned in the bottom-right corner on columns 9–12, its last line 40px above the bottom edge. The bottom-left corner stays empty. The next section begins as a different flat field exactly at the fold.",
  "mobile": "The field fills 100svh. The name keeps its two lines and two inks at 21vw, flush left at a 16px margin, starting 18% down; a long name hyphenates rather than dropping below 18vw. The supporting sentence moves to the bottom-left at 16px across the full width, with the action as an underlined link beneath it, 24px above the bottom edge. The top-right links reduce to one word.",
  "headline": "The name on exactly two lines, one per ink, each line up to about 8 characters at 15vw on desktop and 21vw on a phone.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-marina-rachello",
   "opl-anidachi"
  ]
 },
 {
  "id": "photograph-for-a-letter",
  "name": "Photograph for a letter",
  "archetype": "giant-wordmark",
  "directions": [
   "name-as-monument",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "A flat white or pale ground, about 85% empty. The name sits on one line at the exact vertical centre, bold, at clamp(96px, 10.5vw, 168px), tracking -0.04em, spanning 78–88% of the width with equal side margins. One photograph is set into the line as if it were a glyph. Either it fills the word space between first and last name (a landscape frame one x-height tall and about 1.6em wide, resting on the baseline), or it replaces one round letter of a short name (a square frame exactly one cap-height tall). Build the line as an inline-flex row with `align-items: baseline` and size the frame in em so it scales with the type; square corners, `object-fit: cover`. Navigation is small and pushed to the edges: a 15px wordmark or monogram top-left at a 24px margin and up to five text links at 14px spread evenly across the right half of the top edge. Along the bottom edge, on the same margin: one supporting sentence at 16px starting at column 6, and the single action as an underlined text link flush right.",
  "mobile": "The name stacks on two centred lines at 19vw with line-height 0.9. The photograph leaves the line and becomes a 3:2 frame at full width minus 16px margins between the two lines, so the screen reads word, picture, word. A single short name keeps the square frame inline at cap height instead. The supporting sentence and action sit under the block at 16px; the links collapse to one word top-right.",
  "headline": "The name on one line with a photograph in its word space or in place of one letter, up to about 16 characters at 10.5vw, splitting into two lines on a phone.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-paul-kalkbrenner",
   "opl-nia-creative"
  ]
 },
 {
  "id": "name-split-to-corners",
  "name": "Name split to corners",
  "archetype": "pinned-stage",
  "directions": [
   "pinned-poster",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "The name is cut into two halves that stay pinned to opposite corners while the page passes between them. A layer with `position: sticky; top: 0; height: 100svh` and `pointer-events: none` holds both: the first word top-left, 28px from the top and left edges; the second word bottom-right, 28px from the bottom and right edges. Both are set at clamp(72px, 9vw, 140px), line-height 0.85, in white with `mix-blend-mode: difference` so they read on every panel beneath. On the first screen the stage between them is a paper-white panel on columns 1–9 and one full-height photograph on columns 10–12 touching the top, right and bottom edges. The supporting sentence, 20px and max 34ch, starts at column 3 at 44% of the height; the single action is a text link with an arrow directly under it. Three text links are stacked at 15px along the right edge of the white panel at mid-height. The rest of the panel is empty. Later panels, full-height photographs and text panels of different widths, scroll under the pinned halves for three to five viewports; then the layer releases into ordinary sections.",
  "mobile": "Both halves stay pinned: the first word top-left and the second bottom-right, each at 15vw with 16px margins. Between them every panel is a full-width photograph 56svh tall followed by its sentence at 17px. On the first screen the photograph crosses the middle band with the supporting sentence and the action link beneath it. The stacked links collapse to one word at the top-right corner.",
  "headline": "The name in two halves (two words, or one word cut at a syllable), each up to about 9 characters at 9vw, one per corner, with no other headline.",
  "motion": "pinned-story",
  "seenIn": [
   "aww-normal-is-boring",
   "aww-the-state-of-the-gallery",
   "aww-milledollars"
  ]
 },
 {
  "id": "pinned-title-over-portraits",
  "name": "Pinned title over portraits",
  "archetype": "pinned-stage",
  "directions": [
   "pinned-poster",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "A pale flat ground. The title runs on one line of extra-bold uppercase at clamp(90px, 10vw, 160px), centred, spanning about 82% of the width, its cap line 120px from the top. It lives in a sticky layer (`position: sticky; top: 0; height: 100svh`) stacked above the images and set in one saturated ink that reads on both ground and photograph. Beneath it a single portrait photograph, 2:3, sits centred on columns 5–8 (about 37% of the width) and runs from 90px below the top edge down to the bottom edge, so the title crosses its upper quarter and the middle letters sit on the image. The wordmark sits top-left at 24px in 15px bold; one 'Menu' word sits top-right. Bottom-left on columns 1–3: the supporting sentence as a narrow column at 16px bold, max 26ch, its last line 32px above the bottom edge. Bottom-right: the single action as a solid rectangular button. As the page scrolls, further portraits of identical ratio and position replace the first, five to eight in all, each bringing its own flat ground colour sampled from the photograph, before the layer releases.",
  "mobile": "The title stays pinned at the top at 17vw on one line, or two lines at 15vw if longer than ten characters. Each portrait becomes full width minus 16px margins at 2:3 and starts behind the lower half of the title. The supporting sentence follows the first portrait at 16px. The action button is fixed to the bottom-right corner, 16px in. The wordmark shrinks to a monogram top-left.",
  "headline": "The title of the series, or the name: one uppercase line of up to about 14 characters at 10vw, two short lines on a phone.",
  "motion": "pinned-story",
  "seenIn": [
   "opl-city-of-angels",
   "aww-glitch-grit"
  ]
 },
 {
  "id": "sentence-with-photo-strip",
  "name": "Sentence with a photo strip",
  "archetype": "inline-media-sentence",
  "directions": [
   "sentence-as-layout",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A warm flat ground with no navigation bar. One sentence of four or five words runs as a single line of condensed capitals at clamp(120px, 13vw, 200px), sitting on the vertical centre and reaching edge to edge inside 28px margins. Where one word of the sentence would be, a strip of photograph takes its place: a frame exactly one cap-height tall (set in em) and 45–50% of the viewport wide, ratio near 5:1, corners radiused 14px, `object-fit: cover`. Build the line as a flex row with `align-items: baseline` and give the strip `flex: 1` so words and picture always fill the width. The photograph shows the thing the sentence names: a wide view of the place, taken from above or along a horizon, so it survives the letterbox crop. The top third of the screen is empty apart from a 15px wordmark top-left and two text links top-right. About 150px under the sentence the single action sits centred as a small solid button. One supporting sentence at 22px, max 44ch, is centred 48px above the bottom edge.",
  "mobile": "The sentence breaks into two lines at 21vw with the strip between them: the first words, then the photograph as a 2:1 frame at full width minus 16px margins with the same 14px corners, then the remaining words. The block is centred vertically. The action button sits 32px under the last line and the supporting sentence under that at 16px. The top links collapse to one word.",
  "headline": "A sentence of four or five short words, about 14 characters of type in total, whose missing word is the photograph.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-colonia-zacamil"
  ]
 },
 {
  "id": "sentence-split-by-frame",
  "name": "Sentence split by a frame",
  "archetype": "inline-media-sentence",
  "directions": [
   "sentence-as-layout",
   "pinned-poster",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "A white or pale ground. One short sentence is broken in two and pushed to the edges: the first half flush to the 32px left margin, the second half flush to the right margin, both on the vertical centre line at clamp(40px, 3.6vw, 60px), medium weight. Between them, dead centre on columns 5–8, sits one media frame: 35% of the viewport wide, 16:9, corners 4px, holding a photograph or a working fragment of the product. The frame belongs to a vertical reel: the bottom 60px of the previous frame shows at the top edge of the viewport and the top 60px of the next at the bottom edge, same width, 24px gaps, so the centre column reads as a strip passing through the sentence. The stage is sticky; the reel advances three or four steps while the two halves of the sentence hold their place. Wordmark top-left; three text links and the single action as a small solid button top-right, all at 15px. Bottom-left on columns 1–4: one supporting sentence at 18px, its last line 32px above the bottom edge. The other corners stay empty.",
  "mobile": "The halves stack: the first half of the sentence at 9vw flush left under the wordmark, then the frame at full width minus 16px margins and 4:3, then the second half flush right at 9vw. The neighbouring frames show as 28px slivers above and below. The supporting sentence and the action button follow at 16px. The links collapse to one word.",
  "headline": "One sentence of four to six words split into two halves of up to about 14 characters each; the frame between them shows what it is about.",
  "motion": "pinned-story",
  "seenIn": [
   "aww-made-with-gsap-1",
   "aww-artem-shcherbakov"
  ]
 },
 {
  "id": "headline-across-two-fields",
  "name": "Headline across two fields",
  "archetype": "colour-field",
  "directions": [
   "colour-chapters",
   "sentence-as-layout",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "The viewport is two flat fields meeting at exactly half height: paper above, one saturated colour below, with a hard edge and nothing drawn on it. A two-line headline in black-weight capitals at clamp(96px, 9.5vw, 150px), line-height 0.86, is centred across the seam and spans about 70% of the width, so line one stands on the upper field and line two is cut through its upper third by the seam. The type changes ink where it crosses: above the seam it takes the accent ink, below it a deep ink two steps darker than the lower field, as if submerged. Build it as two stacked copies of the same headline, one per ink, the first clipped with `clip-path: inset(0 0 50% 0)` and the second with `inset(50% 0 0 0)`. Wordmark top-left at 28px; up to four text links and the single action, a solid rectangular button, top-right. One supporting sentence at 20px in the paper colour is centred on the lower field, 56px above the bottom edge. The lower field continues for 30svh past the fold before the next colour begins.",
  "mobile": "The same two fields with the seam at 46svh. The headline stays on two lines at 15vw, centred, still cut by the seam and still changing ink. Wordmark top-left and the action button top-right; the links drop into one menu word beside it. The supporting sentence sits on the lower field at 16px, centred, max 28ch, with 40px beneath it.",
  "headline": "A statement of two to four words on two lines, up to about 10 characters per line, whose meaning the rising field acts out.",
  "motion": "colour-on-scroll",
  "seenIn": [
   "opl-juggle"
  ]
 },
 {
  "id": "swatch-column-beside-view",
  "name": "Swatch column beside the view",
  "archetype": "split",
  "directions": [
   "colour-chapters",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A 10px frame of paper colour surrounds the screen. Columns 1–10 are one photograph panel, full height inside the frame, corners radiused 12px, `object-fit: cover`: a wide, calm view with a low horizon and a great deal of sky. The headline is centred in the panel's upper third on two lines of light-weight serif at clamp(72px, 8.6vw, 132px), white, with a neutral scrim behind that band only if the sky is pale. The wordmark sits top-left inside the panel, 28px in. The single action, a white rectangular button, sits bottom-centre, 36px above the panel's lower edge; one supporting sentence at 18px sits bottom-left on columns 1–4, level with it. Columns 11–12 are both the navigation and a preview of the page: a full-height stack of five flat colour blocks of equal height, butted together with no gaps, each holding one text link at 16px in its top-left corner with 20px padding. The five colours are sampled from the photograph, ordered light to dark, and each is the ground colour of the section its link scrolls to. There is no other navigation.",
  "mobile": "The photograph panel takes the top 64svh inside an 8px frame, with the headline at 12vw on two lines in its upper third and the action button at its bottom-centre. The swatch column becomes five full-width bars, each 52px tall with its link at 15px, stacked directly under the panel; the first three show above the fold. The supporting sentence moves into the first section.",
  "headline": "A two-line statement of three to five words, up to about 14 characters per line at 8.6vw, set over the sky.",
  "motion": "colour-on-scroll",
  "seenIn": [
   "aww-seasats"
  ]
 },
 {
  "id": "film-title-on-bottom-edge",
  "name": "Film title, bottom edge",
  "archetype": "bottom-anchored-type",
  "directions": [
   "place-carries-it",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One photograph fills the viewport at 100vw by 100svh, `object-fit: cover`, with its subject in the upper two-thirds and a calmer, darker zone along the bottom. The name is one condensed uppercase word, or a short name in a high-contrast serif, set at clamp(150px, 17.5vw, 290px) and sized to span the full width between 32px margins. It is centred and anchored so its baseline sits 34px above the bottom edge, covering roughly the lowest quarter of the frame. A soft neutral dark scrim lies under that bottom band only, about 40% of the height; the rest of the image is untouched. Directly above the name's left end, on the same margin, sits the supporting sentence at 16px on two short lines. Directly above its right end, right-aligned, sits the single action as a small paper-coloured rectangular button. The top edge carries a small emblem or wordmark at the left margin and up to five text links at 15px grouped at the right, all in white. The central half of the screen belongs to the view.",
  "mobile": "The photograph is re-cropped to portrait with `object-position` on its subject and fills 100svh. The name stays on the bottom edge at 22vw on one line, or two lines at 20vw beyond eight characters, 20px above the edge. The supporting sentence sits above the name's left end at 15px. The action button moves to the top-right corner and the links collapse to a menu word beside the emblem.",
  "headline": "The name of the place: one word of up to about 12 characters at 17.5vw, or two words stacked on a phone.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-white-desert",
   "opl-eagles-nest"
  ]
 },
 {
  "id": "small-name-open-sky",
  "name": "Small name, open sky",
  "archetype": "fullbleed-media",
  "directions": [
   "place-carries-it",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "A single photograph fills the viewport edge to edge with no frame: choose one whose upper 45% is sky, wall or water with nothing in it and whose subject sits low and off-centre. All type is white and stays out of that empty zone. Top-left at a 24px margin, the name is stacked on two or three lines at 14px, like a letterhead. Top-right, three or four text links run in one line at 14px separated by commas, the current one marked by a 2px underline. Bottom-left, 32px from the left and bottom edges, the name repeats as the only larger type: two tight lines at clamp(44px, 4.2vw, 64px), line-height 1.0, regular weight, on columns 1–5. On the same baseline at the bottom-right margin sits the single action as an underlined text link with an arrow, and above it one supporting sentence at 15px, right-aligned, max 32ch. If a lower corner is too bright for white type, darken that corner alone with a neutral scrim. The page below opens on plain paper with one narrow paragraph and wide empty margins.",
  "mobile": "The photograph is cropped to portrait around its subject and fills 100svh, its upper half still empty. The small stacked name stays top-left at 13px and the links collapse to one word top-right. The larger name sits bottom-left at 9.5vw on two lines, 20px in. The supporting sentence and the underlined action stack beneath it at 15px, left-aligned, 24px above the bottom edge.",
  "headline": "The name set small on two lines of up to about 16 characters each at 4.2vw; the photograph does the work of a headline.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-kononenko-architectural-bureau",
   "aww-julien-calot",
   "aww-depo-luxe"
  ]
 },
 {
  "id": "invitation-card-cropped",
  "name": "Invitation card, cropped",
  "archetype": "artefact",
  "directions": [
   "page-is-the-object"
  ],
  "needs": [],
  "blueprint": "A flat field of one soft colour fills the viewport. Centred on it is a single portrait card in paper colour, 5:7, on columns 4–9, its top edge 190px down and its lower third cut off by the bottom of the viewport, so the page visibly continues as the rest of the card. The card is a filled shape with scalloped inner corners, made with `clip-path` or with four corner circles in the field colour, and no outline. Inside, with 56px padding, everything is centred and symmetrical like letterpress stationery. The two names, or the title, are set in a large italic serif at clamp(56px, 5.6vw, 88px) on two lines, staggered so line one sits left of centre and line two right. Between and beneath them, date, time and place appear as short lines at 15px in sentence case, separated by space and by one solid 10px diamond. The top row sits on the field above the card at 28px margins: a small monogram top-left, three text links centred, and the single action, a solid rectangular button in the ink colour, top-right.",
  "mobile": "The card becomes full width minus 12px margins, starts 96px down and is still cropped by the bottom edge, with its scalloped top corners kept. The names stay on two lines at 12vw, centred without the stagger, and the details follow at 15px. The monogram sits top-left and the action button top-right on the field; the three links move onto the card's lower part, below the fold.",
  "headline": "The two names or the title on two lines, up to about 12 characters per line, set as they would be printed on the card.",
  "motion": "curtain-footer",
  "seenIn": [
   "opl-sarah-matt-wedding"
  ]
 },
 {
  "id": "card-over-dim-room",
  "name": "Card over a dim room",
  "archetype": "artefact",
  "directions": [
   "page-is-the-object",
   "pinned-poster",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A full-bleed, low-key photograph of the room fills the viewport, darkened with `filter: brightness(0.7)` so it reads as atmosphere. Centred over it is one square-cornered panel in a flat warm tone taken from the photograph: columns 4–10, about 55% of the width and 62% of the height, a filled programme card with 32px padding. Along its top edge the three words of the title are spread to the left, centre and right in bold condensed capitals at clamp(28px, 2.5vw, 38px); that line is the headline. Under it sit two left-aligned blocks in the same face and size, line-height 1.0: the facts (date, time, place) as three short lines, then one supporting sentence held to 30ch. Along the card's bottom edge: a credit at left in 14px, the single action as a solid rectangular button in the centre, one text link at right. There is no navigation outside the card. The card is `position: sticky`: as the page scrolls it shrinks with `transform: scale` toward the bottom-left corner and stays there as a ticket-sized stub while photographs of differing sizes pass on a flat ground.",
  "mobile": "The photograph fills 100svh and the card sits centred at full width minus 16px margins, 70svh tall. The title words stack as a two-line heading at 9vw at the card's top-left. The facts and the supporting sentence follow at 5.2vw in the condensed face. The action button spans the card's bottom edge. On scroll the card shrinks to a 120px stub pinned bottom-left.",
  "headline": "The event or venue name, three words at most and up to about 8 characters each, spread across the card's top edge; two lines on a phone.",
  "motion": "pinned-story",
  "seenIn": [
   "opl-the-deep-end"
  ]
 },
 {
  "id": "tabbed-folder-stack",
  "name": "Tabbed folder stack",
  "archetype": "artefact",
  "directions": [
   "page-is-the-object",
   "one-motif",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A dark flat ground. A slim top row at 24px holds the wordmark dead centre and one text link far right at 15px. The headline is one centred line of heavy condensed capitals at clamp(96px, 10vw, 160px), spanning about 70% of the width, its cap line 110px from the top. Under it one supporting sentence in a serif at 20px, centred, max 46ch. From 51% of the height down the screen is a stack of five file folders: flat colour bands 86% of the viewport wide, centred, each offset 64px below the one behind so only a strip of the back four shows. Every folder has one trapezoid tab rising 40px from its top edge, cut with `clip-path: polygon()`, at its own horizontal position and in its own colour, holding one section name at 18px. The front folder is open and runs off the bottom edge; it carries the single action as a solid button at its top-left with 32px padding. Each folder links to its section and lends that section its ground colour. The tab shape returns as the button's top corners.",
  "mobile": "The headline sits 96px down at 17vw on one line, or two at 15vw, centred, with the supporting sentence at 16px beneath. The folder stack starts at 48svh at full width: bands offset 52px, tabs 32px tall with names at 15px, staggered left, centre and right. The front folder runs off the bottom with the action button at full width inside it.",
  "headline": "The name or a two-word statement on one line of up to about 12 characters at 10vw; two lines allowed on a phone.",
  "motion": "index-preview",
  "seenIn": [
   "aww-mosbys-files",
   "opl-medium-rare"
  ]
 },
 {
  "id": "striped-card-beside-room",
  "name": "Striped card beside the room",
  "archetype": "split",
  "directions": [
   "one-motif",
   "page-is-the-object",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "The viewport is split 50/50 with a 12px gap and 12px outer margins. The right half is one full-height photograph of the room, corners radiused 16px, `position: sticky` so it stays in view while the left half scrolls. The left half is a printed card: a flat paper-colour panel with the same 16px radius, and the subject's stripe (from its awning, label or ribbon) as the only ornament. Across the card's top runs a band 56px tall made of alternating bars of the single ink colour and paper, each bar 28px wide, built as a flex row of filled blocks. The same stripe returns as an 8px band above the footer and inside the button on hover. Under the band, centred: the wordmark about 300px wide, then a two-line headline in a light serif at clamp(40px, 3.6vw, 56px) in the ink colour, one supporting sentence at 17px held to 36ch, and the single action as a solid rectangular button. Three text links sit in one centred row at 15px, 28px above the card's bottom edge. One ink on paper; the photograph supplies every other colour.",
  "mobile": "The photograph comes first at full width inside 12px margins, 48svh tall, with the 16px radius. The card follows and overlaps the photograph's bottom edge by 24px using a negative margin. Its striped band is 40px tall; below it the wordmark at 56% width, the headline at 8vw on two lines, the sentence and the button, all centred. The links wrap into the card's foot.",
  "headline": "A warm two-line welcome of three to six words, up to about 18 characters per line at 3.6vw.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-le-petit-bleu",
   "opl-zara-webb"
  ]
 },
 {
  "id": "giant-cropped-shape",
  "name": "Giant cropped shape",
  "archetype": "colour-field",
  "directions": [
   "one-motif",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A plain ground with one flat shape at enormous scale. The shape is the simplest geometry found in the subject (a circle, a ring segment, a chamfered block, an arch), drawn in CSS or as a single inline SVG path and filled with the accent colour. It is about 120vh across and enters from the right, centred near 78% of the width and 55% of the height, so the top, right and bottom edges of the viewport crop it and its left boundary reaches column 7. If the subject has a true pair (two accounts, two modes), a second shape in a second flat colour overlaps the first by a quarter with `mix-blend-mode: multiply`. The headline sits on the bare ground on columns 1–6, vertically centred, flush left at 8% from the left edge: two lines at clamp(64px, 6.4vw, 100px), heavy weight, tracking -0.03em. Beneath it comes one supporting sentence at 19px, then the single action as a solid button cut to the same shape. A bold wordmark sits top-left at 32px; up to three text links sit top-right in the ground colour.",
  "mobile": "The shape moves to the bottom: 130vw across, centred horizontally, its top at 56svh, cropped by the left, right and bottom edges. The headline sits above it at 11.5vw on two lines at a 16px margin, starting 15% down, with the sentence at 16px and the motif-shaped button beneath. Wordmark top-left; the links collapse to one word top-right.",
  "headline": "Two lines of two or three words each, up to about 12 characters per line at 6.4vw.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-tara",
   "opl-superlocal-2026",
   "aww-sstr-friction-reduction"
  ]
 },
 {
  "id": "fragment-on-shaped-stage",
  "name": "Fragment on a shaped stage",
  "archetype": "split",
  "directions": [
   "product-performs",
   "one-motif",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A neutral ground. The headline occupies the upper-left third: two lines at clamp(52px, 5.2vw, 80px), medium weight, flush left on columns 1–6 at a 40px margin, starting 150px down. One supporting sentence at 18px, max 40ch, follows, then the single action as a small rectangular button. The rest of the screen belongs to one flat stage in a saturated colour: it spans columns 5–12, begins 42% down, and bleeds off the right and bottom edges. Its top-left corner is cut with the product's own shape using `clip-path` (a notch, a chamfer or a tab two columns wide), the only decoration on the page. On the stage, 56px in from its left and top edges, sits the main fragment of the interface at true size with labels at 13–15px, built in HTML and working: one panel or control about 620px wide, cropped by the bottom edge, never a whole window and never inside a browser or laptop frame. Mark its root with data-ui. Navigation is a plain row at 28px from the top: wordmark left and four text links right at 15px. Nothing overlaps the headline.",
  "mobile": "The headline sits under the wordmark at 10vw on two lines, followed by the sentence at 16px and the button. The stage comes next at full width, bleeding off both sides and the bottom, its corner cut kept at 72px wide. The fragment sits 16px inside it at true size; if it is wider than the screen the right edge crops it rather than scaling it down.",
  "headline": "A plain statement of what the product does in three to six words, two lines of up to about 16 characters each at 5.2vw.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-typewave",
   "opl-vessa",
   "opl-uni"
  ]
 },
 {
  "id": "control-reads-the-page",
  "name": "Control that reads the page",
  "archetype": "split",
  "directions": [
   "product-performs",
   "quiet-instrument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A near-black or deep neutral ground split into two halves by tone alone: the right half is one step lighter than the left, and they meet at a clean vertical edge at 50%. Left half: a small logo mark top-left at a 40px margin, then two-thirds of empty ground, then everything else anchored bottom-left, 40px from the edges: a one-line headline at clamp(26px, 2.1vw, 32px) in medium weight, one supporting sentence at 17px in the softer ink held to 44ch, and the single action as a light rectangular button. Right half: one fragment of the product, centred, about 450px square, built in HTML and working. It is the product's defining control (a loupe, a picker, a dial, a field) and it acts on the page itself: it reads the real background colour, the real headline size or the pointer position and prints that value in its own readout at 13px. Mark its root with data-ui so the product's own radii and borders apply inside it. There is no navigation bar; one text link sits top-right at 15px.",
  "mobile": "The halves stack as two tones. The fragment comes first: 78vw square, centred in the lighter block, which is 58svh tall, still live and reading the page under the visitor's finger. The darker block below holds the headline at 6.4vw on up to two lines, the sentence at 16px and the button, left-aligned at 20px. The logo mark sits top-left over the first block.",
  "headline": "One plain line at 26–32px naming what the tool is, up to about 32 characters, wrapping to two lines on a phone.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-dabbit"
  ]
 },
 {
  "id": "controls-on-the-sentence",
  "name": "Controls on the sentence",
  "archetype": "type-poster",
  "directions": [
   "product-performs",
   "sentence-as-layout",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "A dark flat ground with no logo bar: a 14px wordmark top-left and the single action as an underlined text link top-right, both 28px in. The headline is a two-line sentence, centred, at clamp(96px, 9vw, 150px), line-height 1.0, occupying the middle 64% of the width and sitting slightly above the midline. It is set in a variable font and it is the demonstration. Three small control chips from the product sit on top of the sentence, each about 300 by 80px with the product's true radius, a text label and a numeric readout: one above the first line near its left end, one overlapping the start of the second line, and a two-state switch at the lower right. They are tilted 2 to 4 degrees and filled solid, one step lighter than the ground. Each one works: dragging the first changes the headline's weight through `font-variation-settings`, the second changes its letter-spacing, the third toggles uppercase. Mark each chip's root with data-ui. One supporting sentence at 17px, max 40ch, is centred 72px under the headline. The bottom third of the screen stays empty.",
  "mobile": "The sentence stays on two lines at 13.5vw, centred at 38% of the height. The three chips shrink to 200 by 60px and move off the letters: one above the sentence and two side by side beneath it, untilted and still live. The supporting sentence follows at 16px. The wordmark stays top-left and the action link top-right.",
  "headline": "A two-line sentence of three to five words, up to about 11 characters per line at 9vw, that still reads when its weight and spacing change.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-dialkit"
  ]
 },
 {
  "id": "column-on-one-line",
  "name": "Column on one line",
  "archetype": "quiet-column",
  "directions": [
   "quiet-instrument",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "A warm off-white page that is more than 80% empty. Everything hangs on one vertical alignment 27% in from the left edge, the start of column 4; nothing is centred and nothing sits to its left. A small logo mark sits on that alignment 56px from the top. About 220px lower the column begins, only 380px wide: a two-line headline at 27px in medium weight, line-height 1.25; one supporting sentence at 15px in the softer ink, line-height 1.5; then, 28px below, the single action as a near-black rectangular button with 4px corners and one text link with an arrow beside it. A sign-in text link sits alone in the top-right corner at 14px; there is no navigation bar. At the bottom edge the first demonstration peeks in on the same alignment: a flat panel one step darker than the page, 368px wide, its top 90px visible, holding two real rows of the product's interface at true size with a visible pointer. Mark the panel's root with data-ui. The right half of the screen is blank on purpose, and nothing spans the full width until the footer.",
  "mobile": "The alignment becomes the 24px left margin. The logo mark sits 32px from the top and the column starts at 30% of the height: headline at 24px on two lines, the sentence at 15px, then button and text link side by side. The demonstration panel spans the width between the margins and shows its top 120px above the fold. The sign-in link stays top-right.",
  "headline": "A dry two-line statement at 27px, up to about 30 characters per line; it never grows larger at any width.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-rows",
   "opl-lovably"
  ]
 },
 {
  "id": "single-object-on-plinth",
  "name": "Single object on a plinth",
  "archetype": "object-centre",
  "directions": [
   "quiet-instrument",
   "hero-object",
   "specimen-catalogue"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "An off-white screen that is about 80% empty and does not need to scroll. One photographed object stands dead centre: a studio shot on a plain background, shown 440px tall (about half the viewport height) whatever its width, with `mix-blend-mode: multiply` so its backdrop disappears into the page. It rests on a plinth, a rounded square 520px across filled one step lighter than the page and centred behind it, with no border. The wordmark sits top-left at a 56px margin in a heavy serif or grotesk, about 400px wide (28% of the viewport) and 80px tall; it is the only large type. Opposite it, top-right on the same margin and aligned to the wordmark's top, sits the one supporting sentence at 15px, right-aligned, max 36ch. Centred 40px under the plinth is the single action: a compact email field or button 240px wide with 8px corners. Further objects replace the first in the same position by crossfade, each scaled to the same visual mass. There is no navigation. Nothing else touches the edges, and the object never grows beyond half the viewport height.",
  "mobile": "The wordmark sits top-left at 44vw wide with 20px margins and the supporting sentence drops beneath it at 14px. The plinth is 78vw square, centred at 54% of the height, with the object 44svh tall on it. The action sits 32px under the plinth, 40px in from each side. The screen stays mostly empty and the objects swap in place.",
  "headline": "None beyond the name: the wordmark top-left, up to about 10 characters, is the only large type and the object is the statement.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-archiv",
   "opl-humanoid-index"
  ]
 },
 {
  "id": "object-crops-three-edges",
  "name": "Object cropped by three edges",
  "archetype": "object-centre",
  "directions": [
   "hero-object",
   "loud-flat-poster"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "A flat off-white or single bold colour field with no navigation bar: only a small wordmark top-right at 24px and the single action fixed bottom-right as a solid rectangular button. The left 60% of the screen is one object photographed on a plain background and scaled far past its container. Set the image to about 150% of the hero's height, rotate it 10 to 20 degrees and position it so the top, left and bottom edges of the viewport all crop it and only its right-hand third is fully inside; use `overflow: hidden` on the section and `mix-blend-mode: multiply` if the field is light. The headline fills the right half: two stacked lines of very bold lowercase at clamp(120px, 13vw, 210px), right-aligned 16px from the right edge, tracking -0.05em, line-height 0.86, vertically centred. It sits behind the object in the stacking order, so the object's right-hand contour tucks over the first one or two letters of each line. One supporting sentence at 18px, max 28ch, is right-aligned under the headline on columns 9–12. Nothing else is on the screen: no second image, no list.",
  "mobile": "The object moves to the top: the image is 150vw wide, rotated, cropped by the top, left and right edges, and occupies the upper 52svh. The headline sits below it at 19vw on two lines, right-aligned at a 12px margin, its first line overlapped by the object's lower contour. The supporting sentence follows at 16px, right-aligned. The action button stays fixed bottom-right.",
  "headline": "Two lines of one or two short words each, up to about 8 characters per line at 13vw, partly tucked behind the object.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-when-you-wish-upon-a-starboy"
  ]
 },
 {
  "id": "object-splits-the-headline",
  "name": "Object splits the headline",
  "archetype": "object-centre",
  "directions": [
   "hero-object",
   "sentence-as-layout",
   "dark-room"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "One deep, flat ground colour. The object, a low-key studio photograph whose background matches the ground, stands at dead centre, scaled to 66% of the viewport height and tilted about 8 degrees, with its base cropped by the bottom edge. The headline is split around it: line one is centred across the object's upper fifth and line two across its lower fifth, both at clamp(64px, 5.8vw, 92px), regular weight, spanning columns 2–11, so the object fills the gap where a paragraph would normally sit. The type lies above the object in the stacking order in the light ink, so every letter stays readable where it crosses. At the object's mid-height two small text blocks at 14px face each other: the supporting sentence far left on columns 1–2, and two short facts (size, price) far right on columns 11–12, right-aligned, each 32px from its edge. Wordmark top-left; three text links top-centre; the single action top-right as a small light rectangular tab, the only light solid on the screen. The lower corners stay dark and empty.",
  "mobile": "The object is 54svh tall, centred, its base cropped by the bottom edge. Line one of the headline crosses its top at 11vw and line two its lower third at 11vw, both centred inside 16px margins. The side text blocks merge into one 15px sentence under the navigation. Wordmark top-left and the action tab top-right; the links collapse to one word.",
  "headline": "A two-line statement of four to six words, up to about 16 characters per line at 5.8vw, with the object standing between the lines.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-zeroz-brand-site",
   "aww-moto-finance"
  ]
 },
 {
  "id": "object-on-the-seam",
  "name": "Object on the seam",
  "archetype": "object-centre",
  "directions": [
   "hero-object",
   "quiet-instrument"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "A white or very pale page organised by one change of tone. A square-cornered panel one step darker than the page (a flat pale blue-grey or warm grey) fills the top 44% of the viewport inside 12px margins; the page colour runs below it. The object, photographed on a plain background and blended with `mix-blend-mode: multiply`, straddles the seam between the two tones: it is about 62% of the viewport wide, starts at column 6 and runs off the right edge, with its upper half over the panel and its lower half over the page. Type is small and exact. The headline is two lines at 24px in medium weight, far left on columns 1–3 at a 32px margin, its first baseline resting exactly on the seam; the second line is set in the softer ink. One supporting sentence at 15px, max 34ch, sits 120px lower on the same margin with the single action as a small rectangular button under it. A plain top row inside the panel at 14px: wordmark left, three text links centred, one text link right.",
  "mobile": "The tonal panel takes the top 46svh. The object is 120vw wide, centred on the seam and cropped by the right edge. The headline sits just under the seam at 22px on two lines at a 20px margin, with the sentence at 15px beneath and then the button. The wordmark sits top-left inside the panel; the links collapse to one word top-right.",
  "headline": "Two short sentences at 24px, one per line and up to about 28 characters each, the second in the softer ink.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-aevion"
  ]
 },
 {
  "id": "statement-beside-specimen-mosaic",
  "name": "Statement beside specimen mosaic",
  "archetype": "mosaic",
  "directions": [
   "specimen-catalogue",
   "split-stage",
   "uneven-photographs"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "The viewport is split 50/50 by tone alone: the left half is the page ground, the right half a field one step darker, meeting at a clean vertical edge with no line. Left half: the wordmark at 22px sits top-left on a 32px margin; under it the statement at clamp(44px, 4.4vw, 68px), bold, tracking -0.04em, leading 1.0, top-left aligned and filling the upper 40% of the half. The lower part of this side stays empty except for one supporting sentence at 18px pinned bottom-left and the single action as a solid rectangle beside it. Right half: a loose mosaic of seven specimen tiles on a 4-column, 3-row grid with 14px gaps. Each tile is a flat palette colour at 3:4 holding one cut-out item, centred, object-fit: contain, mix-blend-mode: multiply. Five cells are left empty so the tiles stagger; one tile spans two columns and two rows; the rightmost column is cropped by the viewport edge. Two text links sit top-right over the darker field. The right half may be position: sticky while the left scrolls.",
  "mobile": "The split becomes a stack. The statement comes first at 10.5vw on two lines inside 20px margins, the supporting sentence under it at 16px, then the action. Below, the darker field runs full width and the mosaic becomes two columns of tiles, the right column started 48px lower than the left; five tiles show and the last row is cropped by the bottom of the screen. Navigation is the wordmark and one link.",
  "headline": "A 3-5 word statement of what is on offer, at most 28 characters so it holds two lines at 10.5vw on a phone.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-noho"
  ]
 },
 {
  "id": "specimen-wall-docked-panel",
  "name": "Specimen wall, docked panel",
  "archetype": "tiles",
  "directions": [
   "specimen-catalogue",
   "dense-tiles",
   "contact-sheet"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "There is no header and no display headline: the whole viewport is a wall of specimens. Square tiles of about 112px (7.8vw) with 8px gaps fill the screen edge to edge in eight rows; alternate rows shift half a tile sideways so the wall reads as brickwork, and tiles at every edge are cropped by the viewport (overflow: hidden on the section). Each tile is a flat fill, rotating through three palette colours, holding one cut-out item at 60% of the tile, centred, with mix-blend-mode: multiply on light fills. A solid panel in the ink colour, 800px wide (columns 4-9) and about 170px tall with square corners, is docked at the bottom centre, 24px above the bottom edge, covering the tiles behind it. Inside, on 28px padding: the sentence that says what this is at 24px semibold on the left across two lines, the single action as a filled rectangle in the paper colour on the right, and beneath them a row holding the wordmark and four text links at 15px. Nothing else overlays the wall.",
  "mobile": "Tiles become 22vw squares with 6px gaps, four and a half per row, still offset as brickwork and cropped at both sides. The panel turns into a full-width block fixed to the bottom of the first screen, about a third of its height: the sentence at 6vw on two lines, the action full width beneath it, the wordmark and links in one wrapped row at 14px. The wall fills everything above.",
  "headline": "No display line: one plain sentence of up to 56 characters inside the panel names what the items are.",
  "motion": "index-preview",
  "seenIn": [
   "opl-smallbits",
   "opl-beige-force"
  ]
 },
 {
  "id": "single-specimen-name-list",
  "name": "Single specimen with name list",
  "archetype": "object-centre",
  "directions": [
   "specimen-catalogue",
   "borrowed-category",
   "hero-object"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "Borrowed from a museum plinth. A calm light ground with roughly 70% left empty. The wordmark is set large for a logo, about 28% of the viewport wide (clamp(64px, 6vw, 92px), heavy serif or grotesk), top-left on a 56px margin; one sentence of description at 15px sits top-right on the same margin, its first line aligned to the top of the wordmark. Dead centre stands one specimen: a flat tile in a palette colour, 400px wide and 460px tall (columns 5-8) with a 16px radius, holding a single cut-out item at about 80% of the tile height, object-fit: contain. Left of the tile, in columns 2-4, a vertical list of item names is right-aligned towards it: the selected name in full ink at 20px, the names above and below stepping down in size and opacity (18, 16, 14px; 60, 35, 15%). Choosing a name swaps the item and the tile colour. Below the tile, centred: the item's one-line fact at 14px, then the single action, a filled rectangle 240px wide. Three text links sit bottom-left at 14px.",
  "mobile": "The wordmark sits top-left at 11vw with the description sentence beneath it at 15px. The tile takes the full width inside 20px margins at 4:5, starting at 30% of the height. The name list becomes one horizontal row under the tile that scrolls sideways with scroll-snap, the selected name centred in full ink at 18px. The action sits full width at the bottom of the screen; the links move to the footer.",
  "headline": "The name itself as an oversized wordmark, up to 10 characters on one line; there is no separate statement.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-archiv",
   "opl-humanoid-index"
  ]
 },
 {
  "id": "shouted-headline-rising-photo",
  "name": "Shouted headline, rising photograph",
  "archetype": "type-poster",
  "directions": [
   "borrowed-category",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Borrowed from a fashion drop. A near-white or single flat colour field. Navigation is one compact line at the top on 24px margins: condensed wordmark left, three text links centred, the single action right. Directly beneath, a two-line uppercase headline in a bold condensed face at clamp(88px, 11.4vw, 176px), centred, leading 0.88, tracking -0.02em, about 60% of the viewport wide; it occupies the band from 12% to 43% of the height. Immediately under the second line, pulled up with a negative margin of -1.2vw so its top edge tucks behind the letters' baseline, a single portrait photograph rises: 27% of the viewport wide (columns 5-8), 3:4, square corners, object-fit: cover, cropped by the bottom edge so only its upper two thirds show. The photograph is the only colour on the screen. The flanks either side of it stay empty apart from one supporting sentence at 17px bottom-left in a 280px column, and the price or key fact at the same size bottom-right, both on a baseline 32px above the bottom edge.",
  "mobile": "The headline sets at 17vw on two lines, flush to 16px margins and filling the width, in the top third of the screen. The photograph widens to 64% of the width, centred, 3:4, starting directly under the headline and running off the bottom. The supporting sentence and the key fact move below the fold. Navigation collapses to the wordmark and the action.",
  "headline": "A blunt 2-4 word statement in condensed capitals, at most 22 characters, one or two words per line.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-uglycash"
  ]
 },
 {
  "id": "counter-object-wide-wordmark",
  "name": "Counter object, wide wordmark",
  "archetype": "object-centre",
  "directions": [
   "borrowed-category",
   "dark-room",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "Borrowed from a watch or perfume counter. One flat charcoal field. Centred slightly above the midline, a single low-key photograph of the object about 31% of the viewport wide (columns 5-8), 4:5, its own dark background matched to the field with filter: brightness(0.9) so it sits without a frame. Under it the name runs on one line as an ultra-wide wordmark spanning 72% of the width yet only about 45px tall: an extended face with letter-spacing near 0.6em, or a regular face stretched with transform: scaleX(2.4). Then one centred serif sentence at clamp(24px, 2.6vw, 38px). Then a single centred row at 15px with 48px between its three parts: one specification, the price inside the one solid light rectangle that is also the action, a second specification. The wordmark repeats small top-left and one text link sits top-right, both on 32px margins. The bottom fifth and both outer quarters of the screen stay empty and dark.",
  "mobile": "The photograph grows to 70% of the width at 4:5, its top at 16% of the height. The wide wordmark spans the full width inside 20px margins at about 28px tall. The serif sentence sets at 6vw on two lines. The row stacks into three centred lines with the action in the middle at full width. The lowest sixth of the screen stays dark and empty.",
  "headline": "The name as a very wide, very low wordmark on one line (up to 12 characters); the serif sentence beneath is the supporting line, up to 40 characters.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-transport",
   "aww-moto-finance"
  ]
 },
 {
  "id": "text-band-then-sheet",
  "name": "Text band, then sheet",
  "archetype": "index",
  "directions": [
   "contact-sheet",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A black or white field on a seven-column grid with 14px margins and 14px gaps. There is no display type: everything is set at one size, 16px, in one weight. The top band is plain text hanging from a line 20px below the top edge: the name in column one; one sentence on availability in column two; a list of five or six services from column three; a longer list of clients or awards from column five; and two links, profile and the single contact action, at the right end of column seven. Lists run one item per line. Below the lists about 300px of ground is left empty. At mid-height the sheet begins: one thumbnail per column, each the full column width (about 190px) at its natural ratio, top-aligned, with the project title in the text size above the first frame of each run of two to four frames. Rows continue below the fold with 40px between them. Frames have square corners and sit directly on the ground with no border and no caption on the image.",
  "mobile": "The grid becomes two columns with 12px gaps. The name and the availability sentence take the top; services and clients sit side by side under them as two short lists at 15px. An 80px gap follows, then the sheet: two frames per row at natural ratios, each project title above its first frame. The contact action is a text link fixed bottom-right.",
  "headline": "None at display size: the name is set at 16px with the rest of the text, and one sentence of up to 60 characters says what the person does.",
  "motion": "index-preview",
  "seenIn": [
   "aww-gil-huybrecht"
  ]
 },
 {
  "id": "sheet-with-text-cell",
  "name": "Sheet with a text cell",
  "archetype": "mosaic",
  "directions": [
   "contact-sheet",
   "dense-tiles"
  ],
  "needs": [],
  "blueprint": "The whole viewport is a sheet of photographs: six columns with 12px gaps and 12px outer margins, each column a vertical stack of frames at natural ratios with square corners. Each column starts at a different vertical offset (0, -90, -40, -130, -20, -70px) so rows never line up and the top and bottom frames are cropped by the viewport. Every frame has a flat strip 28px tall fused to its bottom edge in a solid palette colour, carrying one line of 13px text (a title or a place); strip colours rotate through four values. There is no header. In columns 3-4, starting at 28% of the height, one cell of the sheet is not a photograph but a filled panel in the tone opposite to the ground, 410px tall, sitting on the grid like any other frame with the same gaps on all sides. Inside it, top-left aligned on 24px padding: the wordmark at 28px, one sentence at 20px on up to three lines, the single action as a filled rectangle, and three text links on one line at the panel's foot.",
  "mobile": "The text panel spans the full width as the first block, about 40% of the screen height, with the wordmark at 7vw, the sentence at 17px and the action full width. Under it the sheet resumes in two columns with 8px gaps, the right column started 60px higher than the left, colour strips kept on every frame. The links sit in one row at the panel's foot.",
  "headline": "The wordmark at 28px plus one sentence of up to 70 characters inside the panel; nothing is set at display size.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-proooof"
  ]
 },
 {
  "id": "lit-strip-four-corners",
  "name": "Lit strip, four corners",
  "archetype": "index",
  "directions": [
   "dark-room",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "One near-black field with a single lit band. Four small pieces of text at 14px, medium weight, sit in the corners on 72px side margins and 44px top and bottom margins: the name top-left, one link top-right, one sentence saying what this is bottom-left (max 36ch), the single action as a text link bottom-right. Across the vertical middle runs one horizontal strip of photographs, 34vh tall (about 300px), each at its natural width with 10px gaps and square corners: eleven or more frames in a flex row wider than the viewport, so the first and last visible frames are cropped by the left and right edges. The frame nearest the centre is at full brightness; the others sit at filter: brightness(0.55) and brighten on hover or as they reach the centre. Under the centred frame, aligned to its left edge, its title is set in the same 14px text. The top quarter and the bottom quarter of the screen stay empty and dark. The strip scrolls sideways with overflow-x: auto and scroll-snap; never hijack vertical scroll.",
  "mobile": "The strip stays a single sideways-scrolling row but grows to 46vh tall, so one frame and the cropped edges of its neighbours fill the width. The name and the link sit at the top on 20px margins; the sentence and the action stack bottom-left under the strip at 14px. Dark space remains above and below the strip, about 12% of the height each.",
  "headline": "None at display size: the name at 14px in a corner and one sentence of up to 60 characters.",
  "motion": "drag-row",
  "seenIn": [
   "aww-jesper-landberg-4",
   "aww-boc-studio",
   "aww-leo-parpeix-portfolio-2026"
  ]
 },
 {
  "id": "mosaic-masthead-title-on-axis",
  "name": "Mosaic masthead, title on axis",
  "archetype": "mosaic",
  "directions": [
   "editorial-report",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The top 52% of the viewport is a full-width masthead band built as a gutterless mosaic: a CSS grid of 6 columns and 2 rows in which four cells are photographs (object-fit: cover, all greyscale via filter: grayscale(1) contrast(1.05)) and the rest are flat tiles in two or three palette colours. One photograph spans two columns and one colour tile spans both rows; cells butt together with no gap. The header lies flush on the band's upper edge: the wordmark in a solid ink block at far left, a flat accent-coloured tab holding three text links that runs from the block to 42% of the width, and the single action as a text link at the far right. Below the band, on the paper ground: a standfirst of one sentence at 20px on the 16px left margin, max 30ch, and, starting exactly at 42% of the width, the title on two lines at clamp(64px, 8.3vw, 128px), medium weight, tracking -0.06em, leading 0.9, running to the right margin. The title's first baseline sits about 110px under the band.",
  "mobile": "The band becomes a mosaic of 3 columns and 2 rows taking the top 38% of the screen: two photographs and four colour tiles. The header is a single row above it with the wordmark block and a menu word. The axis collapses to the 16px left margin: the title at 13vw on two lines, then the standfirst at 17px beneath it. The action sits as a text link at the foot of the screen.",
  "headline": "The title of the report, programme or product, 2-5 words, at most 26 characters across two lines.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-ai-in-design-report-2026",
   "opl-wild-as"
  ]
 },
 {
  "id": "narrow-talk-wide-headline",
  "name": "Narrow talk, wide headline",
  "archetype": "type-poster",
  "directions": [
   "editorial-report",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "An asymmetric sheet on one flat paper-grey ground, divided at 34% of the width by alignment only. Left column (columns 1-4, 32px margin): the wordmark top-left; then, starting at 30% of the height, one supporting sentence at 22px with leading 1.35, max 24ch; under it the single action, a solid ink rectangle with square corners. The column's lower third is empty. Right column (columns 5-12): four text links at 15px on its top edge, the first aligned exactly to the 34% line. Beneath them the headline in a very heavy grotesk at clamp(88px, 9.8vw, 150px), two lines, line-height 0.82, tracking -0.04em, flush left on the 34% line and filling the column width. A square-cornered photograph the full width of the right column starts at 68% of the viewport height and is cropped by the bottom edge. A solid accent block 260px wide, carrying one real figure at 40px and one line of text, overlaps the photograph's top-left corner by half its own height using a negative top margin.",
  "mobile": "One column on 20px margins. The wordmark and a menu word share the top row. The headline comes first at 15vw on two lines, heavy, line-height 0.85. The supporting sentence follows at 18px, then the action at full width. The photograph runs full bleed below at 4:3, with the accent block overlapping its top-left corner at 60% of the screen width.",
  "headline": "A 2-4 word statement in the heaviest weight, at most 20 characters, one or two words per line.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-monolog"
  ]
 },
 {
  "id": "waterline-headline",
  "name": "Waterline headline",
  "archetype": "colour-field",
  "directions": [
   "loud-flat-poster",
   "type-only-statement",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "Two flat fields fill the viewport and meet at exactly half height: the upper one paper or white, the lower one a saturated palette colour. No image. The headline is two lines of capitals in a black-weight grotesk at clamp(72px, 9vw, 140px), centred, leading 0.86, about 65% of the viewport wide, positioned so the seam cuts through the middle of the second line. Above the seam the letters take the accent ink; below it they switch to a deep ink, as if under water. Build it with two stacked copies of the headline in the same position (the lower copy aria-hidden), each clipped to its own field with clip-path: inset(), or with mix-blend-mode: difference on a single copy if the colours allow. The wordmark sits top-left and the single action, a solid rectangle in the accent ink, top-right, both on 28px margins; there are no other links. Near the bottom, 40px above the edge, one supporting sentence at 20px is centred in the paper colour on the saturated field. Everything else is empty colour.",
  "mobile": "The same two fields, meeting at 46% of the height. The headline sets at 15.5vw on two lines, filling the width inside 16px margins, the seam still cutting the second line. The wordmark sits top-left and the action top-right as a small solid rectangle. The supporting sentence sits bottom-left at 17px on the saturated field, on up to three lines.",
  "headline": "A blunt statement of 2-4 words in capitals, at most 20 characters, split so each line is one or two words.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-juggle"
  ]
 },
 {
  "id": "flush-left-shout-seam-photo",
  "name": "Flush-left shout, seam photograph",
  "archetype": "type-poster",
  "directions": [
   "loud-flat-poster",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Borrowed from a street poster or zine. A saturated flat field covers the top 82% of the viewport; a second flat colour forms a band across the bottom 18%. The headline is two lines in an ultra-heavy face at clamp(80px, 12vw, 190px), flush left on a 20px margin, leading 0.84, reaching about 62% across the width; its first line starts 16% below the top. Right of centre one photograph is stuck on like a poster scrap: a 3:4 portrait 30% of the viewport wide (columns 8-11), clipped to an arch with clip-path or a large top border-radius, rotated 5 degrees, its top edge leaving the viewport and its lower edge straddling the seam so it sits on both colours. The photograph overlaps the last letters of the first line by about 4vw and lies above the type in the stacking order. On the bottom band, on one baseline: one supporting sentence at 20px on the left (max 40ch) and the single action, a chunky rectangle in the upper field's colour, at the right. The wordmark sits top-left at 20px; three text links top-right.",
  "mobile": "The field takes the top 78% and the band the rest. The headline sets at 19vw on two lines flush to 16px margins, starting at 12% of the height. The arch photograph moves under the type: 58% of the width, pushed against the right edge and cropped by it, still tilted 5 degrees and straddling the seam. The sentence and the full-width action stack on the band.",
  "headline": "A 2-4 word shout, at most 16 characters, lowercase or capitals throughout, one or two words per line.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-aardvark-book-club",
   "opl-postaway",
   "opl-the-sapphic-bar-index"
  ]
 },
 {
  "id": "menu-column-sticky-photo",
  "name": "Menu column, sticky photograph",
  "archetype": "split",
  "directions": [
   "split-stage",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "The viewport is two panels of equal width with a 12px gap between them and 12px outer margins, both with a 20px radius. Right panel: one photograph of the place filling it edge to edge (object-fit: cover), set to position: sticky; top: 12px; height: calc(100svh - 24px), so it stays put while the left side scrolls. A solid bar in the paper colour, 56px tall, lies across the photograph's bottom edge inside the panel and carries opening hours on the left and the address on the right at 15px. Left panel: a filled paper-colour sheet set like a menu card, everything centred. Its top row holds the wordmark, about 300px wide, in the centre with one text link either side. Below, at 34% of the height, the headline in a light serif at clamp(40px, 3.6vw, 56px) on two lines in the accent ink, max 18ch per line. Under it one sentence at 17px, max 40ch, then the single action as a solid rectangle in the accent ink. The lower quarter of the sheet is empty before the first section begins.",
  "mobile": "The photograph comes first as a rounded panel 56svh tall on 12px margins, sticky at the top so the paper sheet slides up over its lower part. The hours bar stays on the photograph's bottom edge. The sheet holds the wordmark centred at 46% of the width, the headline at 9vw on two lines, the sentence and a full-width action.",
  "headline": "A warm 3-6 word line in a light serif, at most 34 characters, centred on two lines.",
  "motion": "slow-crossfade",
  "seenIn": [
   "opl-le-petit-bleu",
   "opl-zara-webb"
  ]
 },
 {
  "id": "inset-photo-wordmark-crossing",
  "name": "Inset photograph, crossing wordmark",
  "archetype": "split",
  "directions": [
   "split-stage",
   "dark-room",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One deep, dark flat field. A tall portrait photograph fills the left 47% of the viewport, inset 20px from the top, left and bottom edges, with a 12px radius and object-fit: cover; it is position: sticky for the length of the page while the right side scrolls. Five text links at 15px sit top-right on a 32px margin, the last of them the single action. There is no logo at the top. In the right half, at 44% of the height and centred on that half's own axis, one supporting sentence in the light ink at 24px on up to three lines, max 26ch. Along the bottom runs the name in lowercase at clamp(120px, 12vw, 200px), light or regular weight, tracking -0.04em, on one line. It starts over the photograph's lower-right corner, overlapping the image by about 10% of the viewport width (z-index above it; mix-blend-mode: difference keeps the letters legible on the picture), and ends flush with the right margin, its baseline 40px above the bottom edge.",
  "mobile": "The photograph takes the top 58% of the screen, inset 12px, with the same radius. The name runs under it at 21vw on one line, its ascenders overlapping the photograph's bottom edge by 3vw. The sentence follows at 17px, left-aligned on a 20px margin. Links reduce to a menu word and the action on the top row, set over the photograph.",
  "headline": "The name in lowercase on one line, up to 9 characters at this size; a longer name drops to 9vw.",
  "motion": "scaling-wordmark",
  "seenIn": [
   "aww-lxl-creative",
   "opl-kereliott"
  ]
 },
 {
  "id": "anchored-text-specimen-half",
  "name": "Anchored text, specimen half",
  "archetype": "split",
  "directions": [
   "split-stage",
   "specimen-catalogue",
   "hero-object"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "The viewport is split exactly in half by two flat tones that meet at a clean vertical edge: the ground on the left, a palette colour on the right. Left half: the wordmark sits top-left on a 40px margin; the next two thirds of the height are empty; everything else is anchored bottom-left and ends 40px above the bottom edge: a headline on one or two lines at clamp(28px, 2.6vw, 40px), medium weight; one sentence at 18px in the soft ink, max 38ch; the single action as a solid rectangle. Right half: position: sticky; top: 0; height: 100svh. In its centre sits a square tile in a second palette colour, 450px wide (31vw) with a 24px radius, holding one cut-out item at 70% of the tile, object-fit: contain, mix-blend-mode: multiply on light fills. Under the tile's left corner the item's name and one fact sit at 15px. As the left column scrolls through the catalogue, the tile's item and colour swap to match the entry in view. One text link sits top-right on the colour field.",
  "mobile": "The colour half comes first as a block 52svh tall holding the square tile at 62vw, centred, and stays sticky while the text slides over its lower edge. The text block follows on the ground: the headline at 7vw on two lines, the sentence at 16px, the action full width. The wordmark and one link sit on the top row over the colour.",
  "headline": "A modest 3-6 word line at 28-40px, at most 40 characters; the object, not the type, carries the screen.",
  "motion": "pinned-story",
  "seenIn": [
   "opl-dabbit"
  ]
 },
 {
  "id": "two-by-two-board",
  "name": "Two-by-two board",
  "archetype": "tiles",
  "directions": [
   "dense-tiles",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "A board with 8px outer margins and 8px gaps throughout; every tile is a filled rectangle with one shared radius (0 or 8px). A 60px top row is itself made of tiles: a square ink tile with the logo mark, a wide ground-tone tile holding four text links that begin at the centre line, and an ink tile at the right end holding the single action. Below it the rest of the viewport is a two-by-two grid. Top-left: a paper tile with the name, or its first word, at clamp(96px, 12.5vw, 190px), medium weight, tracking -0.05em, sitting bottom-left on 24px padding. Top-right and bottom-left: photographs covering their tiles (object-fit: cover), both greyscale so they read as one picture continued across the board. Bottom-right is divided into two tiles side by side: a dark tile with one sentence at 24px, top-left aligned, and a tile in the accent colour with a second short statement at 24px, also top-left. Text never centres inside a tile. No tile is empty and none is outlined.",
  "mobile": "The board becomes one column of tiles with 6px gaps: a top row with the logo tile and a menu tile; the name tile at 30% of the screen height with the name at 22vw bottom-left; one photograph tile at 4:3; then the dark tile and the accent tile side by side as two squares. The second photograph moves below the first screen.",
  "headline": "The name, or its first word, up to 8 characters at 12.5vw; the two 24px statements are one sentence each.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-aspen-search"
  ]
 },
 {
  "id": "name-band-and-rails",
  "name": "Name band and rails",
  "archetype": "tiles",
  "directions": [
   "dense-tiles",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One viewport filled completely with tiles on 8px gaps and 8px margins. Across the top, a band tile 170px tall holds the name on one line at clamp(140px, 14.4vw, 220px), heavy, tracking -0.04em, centred and deliberately wider than the band so both ends are cropped by the tile's edges (overflow: hidden, white-space: nowrap). Under it a row of four equal tiles, 64px tall, each holding one text link at 16px, top-left aligned. The remaining 62% of the height is three columns. Left rail (columns 1-2): two stacked tiles, a portrait photograph above and a short list of five facts below. Centre (columns 3-10): one large photograph tile, object-fit: cover, with a statement tile in the ground tone laid along its bottom third holding one sentence at clamp(24px, 2.4vw, 36px), top-left aligned on 24px padding. Right rail (columns 11-12): a full-height stack of five flat colour tiles, a light-to-dark run from the palette; the lowest takes the accent colour and holds the single action, the others each carry one short fact at 15px.",
  "mobile": "Tiles restack in two columns with 6px gaps. The name band stays full width: 26vw type cropped at both sides in a 110px band. The four link tiles become a two-by-two block. The large photograph tile follows at full width, 4:3, with the statement tile under it at 6vw. The colour rail becomes a horizontal row of five short tiles, the action tile last and twice as wide.",
  "headline": "The name on one cropped line, any length, plus one sentence of up to 60 characters in the statement tile.",
  "motion": "edge-marquee",
  "seenIn": [
   "opl-shawn-golden",
   "aww-seasats"
  ]
 },
 {
  "id": "stepped-title-two-photos",
  "name": "Stepped title, two photographs",
  "archetype": "type-poster",
  "directions": [
   "uneven-photographs",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A pale neutral ground with 6vw side margins on a 12-column grid. Navigation is three small items on the top edge at 15px: one link hard left, the wordmark centred, the single action hard right. The headline is two words or short phrases on two lines in a thin serif at clamp(72px, 8.3vw, 128px), leading 1.0, stepping diagonally across the middle third of the screen: the first line starts at column 2 with its baseline at 44% of the height; the second is indented to start at column 6 and ends near column 12. Two photographs are placed against the step, both with square corners. A small 16:10 landscape in columns 10-12 hangs from 10% of the height and stops about 20px above the first line. A 4:5 photograph in columns 2-5 starts 24px under the first line's baseline, to the left of the indented second line, and is cropped by the bottom edge of the viewport. Under the second line, starting on column 6, one sentence at 20px in the soft ink, max 36ch. Columns 6-12 of the lower third stay empty.",
  "mobile": "The step survives: the first line at 15vw flush to the 20px left margin, the second line indented by 22vw. The small landscape sits above the title at 46% of the width, pushed to the right margin. The tall photograph follows the title at 72% of the width on the left margin, cropped by the bottom of the screen; the sentence at 16px moves beneath it.",
  "headline": "Two words or short phrases, one per line, at most 9 characters each so the indented second line fits a phone.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-tengile-malamala-collection"
  ]
 },
 {
  "id": "three-prints-one-line",
  "name": "Three prints, one line",
  "archetype": "mosaic",
  "directions": [
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "Photographs lead and the type stays small. A pale ground, a 12-column grid, 5vw side margins. The top row at 15px holds the wordmark left, three text links centred and the single action right. Three photographs with square corners hang from one shared top line at 18% of the viewport height, like prints pinned along a rail, each a different size so their bottom edges fall at three heights. A small 1:1 in columns 1-2 (about 190px). A tall 3:4 in columns 4-7, about 440px wide and 590px tall, the largest thing on screen. A 16:10 landscape in columns 9-12 that continues past the right margin and is cropped by the viewport edge. Columns 3 and 8 are left empty as breathing space. The headline sits under the small photograph, left-aligned to its left edge across columns 1-3: a light serif or slender sans at clamp(30px, 2.8vw, 44px) on two lines, followed by one sentence at 16px in the soft ink, max 30ch. One line of caption at 14px sits under the left corner of each of the other two photographs.",
  "mobile": "The rail turns vertical, so left edges vary instead of bottoms. The tall photograph comes first at 72% of the width on the left margin; the small square follows at 38% of the width, pushed to the right margin and overlapping the first by 40px; the landscape runs full bleed below. The headline at 8vw sits beside the square on the left, with the sentence at 16px under it.",
  "headline": "A quiet 3-6 word line at about 40px, at most 36 characters across two lines.",
  "motion": "shutter-image",
  "seenIn": [
   "opl-the-deep-end",
   "opl-kereliott",
   "aww-normal-is-boring"
  ]
 },
 {
  "id": "two-prints-in-darkness",
  "name": "Two prints in darkness",
  "archetype": "object-centre",
  "directions": [
   "dark-room",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "One near-black field with a slight colour tint, about 75% of it left empty. Navigation is split between the top corners at 15px on 30px margins: the wordmark and three text links top-left, the single action top-right as the page's one solid light rectangle. Two low-key photographs sit in the dark like lit exhibits, both with square corners and no frame. The larger, a 4:5 portrait about 34% of the viewport wide (columns 6-9), has its top at 14% of the height, so it sits a little above the midline. The smaller, a 1:1 about 12% of the viewport wide (columns 2-3), sits far from it with its top at 52% of the height, dimmed with filter: brightness(0.7) so the large one stays the brightest thing on screen. The headline is modest: two lines at clamp(28px, 2.5vw, 40px) in a regular weight, flush to the bottom-left corner on the 30px margin. Columns 10-12 hold one sentence at 16px in the dimmer ink, bottom-aligned to the headline's last baseline, max 24ch. Nothing else is on screen.",
  "mobile": "The large photograph takes 78% of the width, pushed to the right margin, its top at 12% of the height. The small square sits at 30% of the width on the left margin, overlapping the large one's lower-left corner by 24px. The headline sets at 6.4vw on two lines bottom-left with the sentence beneath at 15px. The action is a small solid rectangle top-right; links fold behind a menu word.",
  "headline": "A quiet statement of 4-7 words at 28-40px, at most 44 characters on two lines.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-revelatio-studio",
   "opl-kereliott",
   "opl-the-deep-end"
  ]
 },
 {
  "id": "two-line-statement-full-width",
  "name": "Two-line statement, full width",
  "archetype": "type-poster",
  "directions": [
   "type-only-statement",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Paper and ink only; no image. The wordmark at 15px sits top-left and two or three text links at the same size top-right, both on 24px margins; there is no bar. The statement fills the upper two thirds: two lines at clamp(96px, 12.5vw, 200px), medium to bold weight, tracking -0.05em, leading 0.86, starting 15% below the top edge. The first line is flush to the left margin and the second flush to the right margin, so the pair spans the full width and steps across the screen. Size the type so the longer line runs margin to margin: tune the vw value to the character count and test it at 1440 and 390. Between the statement and the bottom row about 18% of the height is left empty. The bottom row sits on one baseline 28px above the bottom edge: one supporting sentence at 22px on the left, max 44ch on two lines, and the single action bottom-right as a solid ink rectangle with square corners. There is no colour beyond ground and ink.",
  "mobile": "Both lines turn flush left on 16px margins at 17vw, leading 0.9, starting at 14% of the height and filling the width. A gap of about 20% of the screen follows. The sentence sits at 17px above a full-width action, both anchored to the bottom with 20px to spare. Navigation reduces to the wordmark and one menu word.",
  "headline": "A 3-5 word statement on two lines, at most 11 characters per line so 17vw type fits a phone.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-produx-design",
   "opl-specia1ne",
   "aww-made-with-gsap-1"
  ]
 },
 {
  "id": "void-then-statement-on-axis",
  "name": "Void, then statement on axis",
  "archetype": "bottom-anchored-type",
  "directions": [
   "type-only-statement",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "A deliberate void: the upper 60% of the viewport holds nothing but the top row. That row, 44px from the top on 48px margins, has the wordmark at the left margin, two text links starting exactly on a vertical axis at 42% of the width, and the single action at the right margin as a text link. Everything else hangs from the same axis in the bottom third. The statement starts on the 42% line: two lines at clamp(56px, 6.6vw, 104px), regular or medium weight, tracking -0.04em, leading 0.95, running towards the right margin, with its last baseline 72px above the bottom edge. Left of the axis, in columns 1-4, one supporting sentence at 18px in the soft ink, max 32ch, is bottom-aligned to the statement's last baseline. Nothing sits in the left zone above it. No image and no shape; the ground is one flat tone and the ink one colour. The emptiness is composed: three elements share the axis, so the blank area reads as intended and not as a page that failed to load.",
  "mobile": "The axis collapses to the 20px left margin. The top 48% of the screen stays empty under the wordmark row. The statement sets at 12.5vw on two lines, flush left, followed by the sentence at 17px and the action as a text link, the whole block ending 28px above the bottom edge. The two links fold behind a menu word.",
  "headline": "A 3-6 word statement, at most 30 characters over two lines, about 15 per line at 6.6vw from the axis.",
  "motion": "masked-headline",
  "seenIn": [
   "opl-lovably",
   "aww-ai-in-design-report-2026",
   "opl-tiny-computer-co",
   "aww-cerebrium"
  ]
 },
 {
  "id": "colour-field-two-ink-statement",
  "name": "Colour field, two-ink statement",
  "archetype": "colour-field",
  "directions": [
   "type-only-statement",
   "loud-flat-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "One saturated flat colour covers the whole viewport; there is no navigation bar and no image. Two short pieces of 15px text sit in the top corners on 3% margins: the wordmark at the left, the single action as a text link in the dark ink at the right. The statement starts 15% down, left-aligned from the 3% margin, in a soft heavy serif or a heavy grotesk at clamp(110px, 14vw, 220px), tracking -0.03em, leaded so tightly (0.82) that the two lines nearly touch. Line one is set in a pale ink and line two in a dark ink, the only two inks on the field; the pair fills the top 55% of the screen and the longer line reaches about 90% of the width. A long name may be broken across the two lines with a hyphen if the second half reads as a word of its own. In the bottom-right corner, right-aligned in a column 24% of the viewport wide, one supporting sentence at 17px in the pale ink. The bottom-left quarter stays empty colour.",
  "mobile": "The same field. The corner texts stay at the top at 14px. The statement sets at 21vw on two lines from a 16px margin, starting at 16% of the height, pale line over dark line. The supporting sentence moves to the bottom-left at 16px across 80% of the width, ending 24px above the bottom edge. The middle fifth of the screen stays empty colour.",
  "headline": "The name or a two-word statement over two lines, at most 8 characters per line at 21vw on a phone.",
  "motion": "colour-on-scroll",
  "seenIn": [
   "opl-marina-rachello"
  ]
 },
 {
  "id": "one-word-cropped",
  "name": "One word, cropped",
  "archetype": "giant-wordmark",
  "directions": [
   "type-only-statement",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "A single word is the whole composition, set so large that the viewport crops it. No image. The word is one line in a heavy condensed or heavy grotesk face at clamp(360px, 46vw, 760px), line-height 0.8, white-space: nowrap, placed so its capitals fill the lower half of the screen: the baseline sits 6% below the bottom edge so the feet of the letters are cut, and the line is shifted 8vw to the left so the first letter is cut by the left edge and the last runs off the right (overflow: hidden on the section). Raise the size until both end letters are cut; three to five letters stay fully visible and the word still reads. The top 30% holds everything else on 28px margins: the wordmark at 16px top-left, three text links top-right, and, starting 96px down on the left margin, one supporting sentence at clamp(20px, 1.7vw, 26px), max 34ch, with the single action directly beneath it as a solid rectangle. Ground and ink are two flat values; the word may take the palette's one accent instead of the ink.",
  "mobile": "The word turns a quarter turn (writing-mode: vertical-rl) and runs down the right side at 62vw, a fifth of its height cut by the right edge and its ends cut by the top and bottom. The left 45% of the screen holds the wordmark, the sentence at 17px and the action, stacked from the top on a 20px margin. Links fold behind a menu word.",
  "headline": "One word of 4-8 letters, the name or the single noun the subject owns; it is cropped but must stay readable.",
  "motion": "scaling-wordmark",
  "seenIn": [
   "opl-shawn-golden",
   "aww-px-push",
   "opl-lorenzo-bocchi-photography"
  ]
 },
 {
  "id": "window-opens-to-the-edges",
  "name": "Window that opens to the edges",
  "archetype": "pinned-stage",
  "directions": [
   "pinned-poster",
   "place-carries-it",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "A flat paper or pale ground, and one picture seen through a small window that becomes the whole screen as the page scrolls. The hero is a wrapper 200svh tall holding a stage with `position: sticky; top: 0; height: 100svh`. On the first frame the window is a 4:5 portrait opening 36% of the viewport wide (518×648px), centred, its top and bottom 126px from the edges, square corners; give it a shape from the subject if it has one (an arch, a rounded diamond, a lens). Build it as `clip-path: inset(126px 461px)` on a layer that is itself 100vw by 100svh, so the photograph or muted film inside is laid out full-bleed and never moves: only the opening grows. The headline sits left of the window on columns 1–3: two lines at clamp(44px, 4.4vw, 64px), leading 0.95, flush to the 32px margin, its last baseline level with the window's lower edge. Right of the window on columns 10–12, bottom-aligned to the same line: one supporting sentence at 18px, max 26ch, and the single action as a solid rectangular button. Wordmark top-left and three text links top-right at 15px on the same margin; nothing else sits on the ground. Over the first 100svh of scroll the inset runs to zero on a scroll-linked timeline while the text, which sits above the picture layer, rises and fades out before the opening reaches it; the picture then holds full-bleed for 50svh before the next section slides over it. Without scroll timelines, or under reduced motion, show the open picture with the headline over a neutral scrim in its bottom-left corner.",
  "mobile": "The stage stays sticky. The window starts 78vw wide at 4:5, centred, its top at 30% of the height, leaving about a quarter of the screen below it. The headline sits above it at 10vw on two lines inside 20px margins, 72px down; the supporting sentence at 16px and a full-width action sit under the window. The opening reaches the edges within 70svh of scroll. The links fold behind a menu word.",
  "headline": "Two short lines of up to about 9 characters each: the name with what it is, or a 2–4 word claim. It scrolls away as the picture takes the screen.",
  "motion": "pinned-story",
  "seenIn": [
   "aww-decathlon-yestalgia",
   "aww-leo-parpeix-portfolio-2026",
   "aww-revelatio-studio",
   "aww-vero-new-york"
  ]
 },
 {
  "id": "ring-of-frames-around-the-headline",
  "name": "Ring of frames around the headline",
  "archetype": "mosaic",
  "directions": [
   "contact-sheet",
   "dark-room",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One near-black or deep flat field. Eighteen to twenty-four photographs or film stills sit on an ellipse centred on the viewport, with radii of 560px across and 310px down, so their centres run from x 160 to 1280 and from y 140 to 760. Frames have square corners and no border, mix 4:3 and 3:4, and grow towards the viewer: about 100px wide at the top of the ring and 150px at the bottom and at both ends, so the ring reads as tilted. Each frame is turned so its base lies along the curve (never more than 30 degrees), and neighbours may overlap by up to a fifth. Inside the ring an empty core about 600×340px holds the text, centred: the headline on two lines at clamp(56px, 5.4vw, 80px), leading 0.95, in the light ink; one supporting sentence at 18px, max 40ch, 24px under it; then the single action as a solid light rectangle. Wordmark top-left and three text links top-right at 15px on 32px margins, outside the ring. Every frame is a link to its project: pointing at one, or focusing it, brings it to full brightness, drops the others to 45% and replaces the supporting sentence with that project's name and year. Place the frames with a few lines of trigonometry (left and top in percentages) and keep them still; the wheel scrolls the page as usual.",
  "mobile": "The ellipse becomes a circle 132vw across, centred at half height, so the frames at its left and right ends run off both edges and the ring reads as two arcs, one above and one below the text. Keep twelve frames at 64–88px. The headline sits in the core at 10.5vw on two lines inside 28px margins, with the sentence at 16px and the action at the full width of the core beneath it. Wordmark top-left and a menu word top-right sit above the top arc. A tap on a frame opens its project.",
  "headline": "A 3–6 word line on two lines, at most about 14 characters per line, so it fits the empty core.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-cipher",
   "aww-squarespace-foundations",
   "aww-studio-k95-3",
   "aww-kononenko-architectural-bureau"
  ]
 },
 {
  "id": "navigation-on-the-midline",
  "name": "Navigation on the midline",
  "archetype": "fullbleed-media",
  "directions": [
   "place-carries-it",
   "quiet-instrument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "One photograph or muted film fills the viewport at 100vw by 100svh, `object-fit: cover`, framed so the middle of the screen cuts the subject: a face or a skyline in the upper half, hands or ground in the lower. There is no headline block and nothing in the top or bottom bands. One rail 64px tall runs across the exact vertical centre (its top at 418px) and carries everything on one baseline in the paper colour: the `h1` on the 24px left margin, the name with what it is in two to five words at clamp(32px, 2.6vw, 40px) in medium weight, doing the job of the wordmark; from column 6, one supporting sentence at 18px, max 44ch; at the right margin, two text links at 15px and the single action as a small solid button in the paper colour. If the frame is light behind the rail, give the rail alone a neutral dark scrim at 35%. The rail is `position: sticky; top: 0` in the hero's flow, so as the page scrolls it rises with the content and docks as the header bar without changing size or layout, taking the page ground once the pictures have passed. A second full-bleed photograph, a closer detail of the same subject, follows directly beneath, so the rail is seen crossing two pictures before it reaches the top. On a black ground with one photographed object instead of a film, stand the object on the rail at about 17% of the width, between the sentence and the links, so the line seems to pass behind it.",
  "mobile": "A portrait crop keeps the subject's middle on the screen's middle. The rail stays on the vertical centre, as tall as the `h1` needs at 28px on up to two lines, with the `h1` on the left and Menu on the right. The sentence leaves the rail and sits at 17px in the bottom-left on a 20px margin with the action as a full-width button under it, over a neutral scrim limited to that corner. The rail docks at the top as on desktop.",
  "headline": "The `h1` on the rail: the name with what it is, two to five words at 32–40px on one line, and one sentence of up to 50 characters beside it; the picture does the rest.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-odyssee",
   "aww-milledollars",
   "aww-twks-1"
  ]
 },
 {
  "id": "title-in-the-sky-line-on-the-horizon",
  "name": "Title in the sky, line on the horizon",
  "archetype": "fullbleed-media",
  "directions": [
   "place-carries-it",
   "hero-object",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "One wide photograph or render fills the viewport, chosen with the subject in the lower 55% and open sky, fog or a plain wall above it. The headline is centred in that empty upper zone: two lines at clamp(72px, 7.2vw, 112px), leading 0.9, its cap line 72px from the top, the first line short and the second longer so the block widens towards the subject. On the horizon, with its baseline at 52% of the height, one supporting sentence is split into two halves at 22px: the first half right-aligned to end at column 5, the second left-aligned from column 8, so the subject shows through the 260px gap between them. Wordmark top-left on a 32px margin; up to three text links from column 9; the single action top-right as a solid rectangle in the paper colour. All type is in one ink chosen against the sky; on a pale sky use the photograph's darkest tone rather than black. Nothing else crosses the lower half.",
  "mobile": "The picture is re-cropped to portrait with the subject in the lower 45%. The headline sits centred in the sky at 11vw on two lines, 96px down, and the two halves of the sentence become two centred lines at 17px directly under it. The action is a full-width button 24px above the bottom edge on a neutral scrim band 96px tall. Wordmark top-left and a menu word top-right.",
  "headline": "Two lines, the second the longer, at most about 14 characters per line: the name of the place, or a 3–5 word claim about it.",
  "motion": "shutter-image",
  "seenIn": [
   "aww-era-residence",
   "aww-vectr"
  ]
 },
 {
  "id": "name-across-the-middle",
  "name": "Name across the middle",
  "archetype": "giant-wordmark",
  "directions": [
   "name-as-monument",
   "type-only-statement",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Paper and ink only; no picture on the first screen. The name runs on one line across the vertical centre, sized to span from the 18px left margin to the 18px right margin (97.5% of the width). Set it as inline SVG text with a `viewBox` fitted to the word and `width: 100%`, or tune its vw size to the letter count, so it never wraps. In a heavy grotesk the letters stand about 280px tall, from about 310px to 590px; nothing touches or overlaps them. The four corners carry everything else on 18px margins. Top-left: one supporting sentence at 18px on two lines, max 36ch, and 16px below it the single action as a solid ink rectangle. Top-right: a menu word or three text links at 15px. Bottom-left, on a baseline 24px above the edge: the city or the kind of work at 16px. Bottom-right on the same baseline: two or three text links at 15px. The ground between the corners and the name, about 230px above and below it, stays empty and is closed on both sides by the corner texts. The name returns in the footer at the same width with ground and ink swapped and a two-line invitation above it, so the page closes like a bracket.",
  "mobile": "The name stays on one line across the width inside 16px margins and moves up to 22% of the height. The supporting sentence follows on the left margin at 17px, then the action at full width. The lower half holds the first picture of the work as a 4:5 frame cropped by the bottom edge, so the screen does not end in empty paper. A menu word sits top-right.",
  "headline": "The name itself on one line, up to about 12 characters; the 18px sentence in the top-left corner says what it is.",
  "motion": "scaling-wordmark",
  "seenIn": [
   "aww-nothin",
   "aww-produx-design"
  ]
 },
 {
  "id": "square-over-a-moving-line",
  "name": "Square over a moving line",
  "archetype": "type-poster",
  "directions": [
   "name-as-monument",
   "place-carries-it",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A white or pale flat ground. One line of display type at clamp(150px, 15vw, 230px), weight 500, tracking -0.02em, runs edge to edge with its cap band centred 360px from the top, moving left at a slow, constant 40px a second: the name, or a claim of two or three words, repeated with a solid round dot one third of the cap height between copies and duplicated for a seamless loop. One square photograph 272px across (19vw), square corners, `object-fit: cover`, sits exactly on the band's centre behind the type, so the words slide across it; the moving line is set twice, the second copy in the paper colour and clipped to the square with `clip-path`, so the letters turn light as they cross the photograph and every word reads whole. It shows the subject's best detail. In the study the photograph sits on top of the words; here nothing lies on top of a headline. 150px under the photograph, centred: one supporting sentence at 22px on two lines, max 36ch, and 24px below it the key fact (a price, a date, an address) at 17px. Top row on 32px margins: one text link on the left, the wordmark centred, one text link on the right. Bottom row on the same margins, 40px above the edge: a location line at 16px on the left and the single action as a solid rectangle on the right. The `h1` holds the moving words once as plain text and the moving copies are `aria-hidden`. Under reduced motion the line stops with one copy centred and both ends cropped.",
  "mobile": "The line drops to 34vw with its band at 26% of the height and keeps moving; the photograph shrinks to a 46vw square and stays centred on it. The sentence sits centred at 18px about 64px below the photograph, then the fact at 16px. The action becomes a full-width button 20px above the bottom edge. The wordmark stays centred at the top and the two links fold into a menu word on the right.",
  "headline": "The name or a 2–3 word claim of at most 14 characters, set once in the `h1` and repeated in the moving line.",
  "motion": "edge-marquee",
  "seenIn": [
   "aww-realevate"
  ]
 },
 {
  "id": "two-doors-on-a-dark-wall",
  "name": "Two doors on a dark wall",
  "archetype": "split",
  "directions": [
   "dark-room",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "For a subject with two halves a visitor must choose between: two exhibitions, two audiences, two places, two collections. One near-black flat field. Two portrait photographs at 3:4, each 280×373px, stand side by side 80px apart, so the pair spans x 400–1040 (44% of the width) with their tops at 300px; square corners, no frame, the pictures supplying every colour on the screen. Each photograph is the link to its half, and directly beneath it, left-aligned to the frame, sit its name at 22px in the light ink and one line at 16px in the soft ink. The headline is centred above the pair: two lines at clamp(36px, 3.4vw, 52px), leading 1.05, its first line 104px from the top, at most 26 characters per line. Wordmark top-left and one text link top-right at 15px on 32px margins. About 150px of dark ground stays below the pair and 400px either side of it, closed by the two doors at the centre. Pointing at or focusing one door keeps it at full strength and dims the other to 50%. The page continues below with the two halves in order, so each door is also an anchor to its half.",
  "mobile": "The pair stays side by side: each photograph 168px wide at 3:4 with a 14px gap, centred at 46% of the height, its name at 17px and its line at 16px beneath it. The headline sits above at 8vw on two lines, centred, 64px down. The wordmark and the link stay in the top corners.",
  "headline": "A 4–8 word sentence on two lines that names the choice, at most 26 characters per line.",
  "motion": "stillness",
  "seenIn": [
   "aww-hearst-exhibit-2026"
  ]
 },
 {
  "id": "title-from-top-to-bottom-edge",
  "name": "Title from the top edge to the bottom edge",
  "archetype": "bottom-anchored-type",
  "directions": [
   "place-carries-it",
   "name-as-monument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "One full-bleed photograph or film at 100vw by 100svh, with a horizon near half height and empty sky or darkness above. The headline is two lines in a heavy face at clamp(140px, 13vw, 200px), leading 0.85, pulled as far apart as the screen allows: line one flush to the 32px left margin with its cap line 72px from the top; line two indented 28vw (403px) and anchored to the bottom, its baseline 24px above the lower edge, running towards the right margin. Lay the hero out as a grid of three rows (`grid-template-rows: auto 1fr auto`): line one in the first row, line two in the third, both spans of one `h1` with `display: contents`. The middle row leaves the picture clear apart from one supporting sentence at 18px, max 44ch, crossing the horizon at 48% of the height from 30vw, and the single action directly beneath it as a solid rectangular button with its label at 18px. The wordmark and two text links sit top-right on the 32px margin at 15px; the top-left belongs to line one. Choose a picture whose upper-left and lower-right are calm enough for the type, or lay a neutral scrim under those two corners only.",
  "mobile": "Keep the two lines at 19vw: line one flush left on a 16px margin 88px down, line two right-aligned to the 16px margin with its baseline 20px above the bottom. The sentence at 17px and the action button sit between them on the left margin, at 46% of the height. The picture is re-cropped to portrait around the horizon.",
  "headline": "Two words or short phrases, one per line, at most about 8 characters each.",
  "motion": "masked-headline",
  "seenIn": [
   "aww-the-state-of-the-gallery"
  ]
 },
 {
  "id": "line-up-on-an-arc",
  "name": "Line-up on an arc",
  "archetype": "object-centre",
  "directions": [
   "specimen-catalogue",
   "hero-object",
   "dark-room"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "For a range: flavours, colourways, editions, sizes. One deep flat ground, which may take the current item's own colour. The current item, a cut-out photograph, stands at the exact centre about 240px wide and 450px tall (y 210–660), tilted no more than 8 degrees. Its siblings stand behind it on a shallow arc, alternately a little higher and lower: each step out is 30% smaller, a quarter darker (`filter: brightness()`) and about 260px further from the centre, three on each side, so the outermost pair is cut by the left and right edges. Under the current item, centred: its name at clamp(32px, 2.8vw, 44px) and one fact at 17px (what it tastes of, what it is made of, the price). The headline sits top-left on columns 1–4 at clamp(36px, 3.2vw, 48px) on two lines with the supporting sentence at 17px under it; the single action, a solid rectangle, sits top-right on a 32px margin. The arc is a horizontal scroller with `scroll-snap-type: x mandatory`, each item snapping to the centre, its scale and brightness set from its distance to the centre, so a drag, a swipe, the arrow keys or a click on a sibling brings it forward; the ground colour follows with a 300ms transition. No arrows drawn on the screen and no counter.",
  "mobile": "The current item takes 55% of the width and half the height, centred at 46% of the height; one sibling peeks in at each edge at 60% scale, half cut off. The headline sits at the top at 8vw on two lines with the supporting sentence beneath; the item's name and fact sit under the item at 24px and 16px. The action becomes a full-width button 20px above the bottom edge.",
  "headline": "A plain 3–6 word line on two lines, at most 20 characters per line; the item's own name under the object changes with the selection.",
  "motion": "drag-row",
  "seenIn": [
   "aww-ciao-energy-launch-website"
  ]
 },
 {
  "id": "view-with-two-cards-in-the-corner",
  "name": "View with two cards in the corner",
  "archetype": "fullbleed-media",
  "directions": [
   "place-carries-it",
   "hero-object",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "One photograph or muted film fills the viewport, `object-fit: cover`, with the subject in the right half. The headline sits on the left at mid-height: two lines at clamp(44px, 3.9vw, 60px), bold, leading 1.08, flush to the 32px margin on columns 1–5, the block vertically centred; 20px below it, one supporting sentence at 18px, max 40ch, in the paper colour. In the bottom-right corner, 32px from the right and bottom edges, two cards sit side by side with a 16px gap, each two columns wide (about 210px) at 3:2: a photograph of the newest thing (a project, an edition, an article) with a paper-colour bar 40px tall fused to its lower edge, holding its title at 16px on the left and a small arrow on the right. They are the next steps after the single action, which is a solid rectangle top-right beside a menu word. One short fact at 16px in the paper colour may be pinned onto the subject itself, beside a solid 8px dot on the point it describes, with no line joining them. Wordmark top-left on the 32px margin. If the left half is light, lay a neutral dark scrim under columns 1–6 only.",
  "mobile": "The picture is re-cropped to portrait with the subject in the upper half. The headline sits at 9vw on two lines on the 16px margin at 44% of the height, with the sentence at 16px beneath it. The two cards sit side by side across the full width at the bottom, 16px from the edges, each about 171px wide at 3:2 with its title bar. Wordmark top-left; the action and a menu word top-right.",
  "headline": "A plain two-line claim of 4–7 words, at most about 17 characters per line at 3.9vw.",
  "motion": "slow-crossfade",
  "seenIn": [
   "aww-cominvi",
   "aww-no-art"
  ]
 }
]
library/motions.json›
[
 {
  "id": "masked-headline",
  "name": "Masked headline",
  "description": "On load, each line of the headline rises from behind its own baseline, about 900ms with a strong ease-out, the second line 110ms after the first; the supporting sentence and the action then fade up together. Nothing else on the page performs. Because headlines are two lines at most, you author the line breaks and wrap each line yourself. Recipe: 'Masked line reveal' in references/motion.md."
 },
 {
  "id": "shutter-image",
  "name": "Shutter image",
  "description": "The one hero or showcase image opens from an edge with a clip-path over about 1.1 seconds while the picture inside settles from 1.12 scale to rest. Use it once, on the image that matters most; later images simply are there. Recipe: 'Image shutter' in references/motion.md."
 },
 {
  "id": "scaling-wordmark",
  "name": "Scaling wordmark",
  "description": "The giant name responds to the scroll: as the first screen leaves, the wordmark scales down a few percent (or grows from 0.94 to 1 as the footer arrives) on a scroll-linked timeline, so the monument feels heavy. Travel stays under 8%. Static fallback where scroll timelines are unsupported. Recipe: 'Scroll-linked progress' in references/motion.md, animating `scale`."
 },
 {
  "id": "pinned-story",
  "name": "Pinned story",
  "description": "One element holds its place with `position: sticky` (a title, a frame, a product fragment) while the items that belong to it scroll past or swap inside it. The page's ordinary scrolling does the work; nothing is hijacked. Hold it for about one screen per step and three screens at most, two held stages per page at most, with a line of text on every held screen. Recipe: 'Pinned story' in references/motion.md."
 },
 {
  "id": "index-preview",
  "name": "Index with preview",
  "description": "Rows of text are the navigation through the work; pointing at a row brings up its image in one fixed frame and dims the other rows. On touch screens each row shows its image inline instead. Fast and exact (about 240ms crossfade), no lag behind the pointer. Recipe: 'Index with preview, anchored to the row' in references/motion.md."
 },
 {
  "id": "curtain-footer",
  "name": "Curtain footer",
  "description": "The page content slides up like a sheet to uncover the footer waiting beneath it, so the last screen is revealed rather than scrolled to. Build with a sticky footer at the bottom and a main element with its own background and a higher stacking order. No JavaScript."
 },
 {
  "id": "slow-crossfade",
  "name": "Slow crossfade",
  "description": "The principal photograph quietly changes: three or four images of the same framing crossfade over about 1.6 seconds, holding five to six seconds each, or the fixed image changes when its matching section arrives. It should feel like light shifting in a room. Pause when the tab is hidden; under reduced motion show only the first image."
 },
 {
  "id": "edge-marquee",
  "name": "Edge-to-edge marquee",
  "description": "One line of very large type travels sideways across the full width at a slow constant speed, as a band between two sections or as the closing statement. Linear timing, duplicated content for a seamless loop, paused on hover and under reduced motion. One marquee per page, carrying real words (the name, the claim), never a list of buzzwords."
 },
 {
  "id": "drag-row",
  "name": "Drag row",
  "description": "A row of large items wider than the screen that the visitor pulls sideways: native horizontal scrolling with scroll snapping, the first item aligned to the page margin and the last allowed to run off the edge so the row visibly continues. No carousel library, no arrows, no dots."
 },
 {
  "id": "colour-on-scroll",
  "name": "Colour by chapter",
  "description": "Each chapter is its own full-bleed field of one palette colour, so the ground changes exactly at section edges as you scroll, never as a global recolouring of the screen. The only movement: the fixed header (or the one pinned element) takes the ink of the chapter beneath it, switching with a 300ms colour transition as each edge passes under it. Do not drive the page background from an observer: in blind tests a page recoloured mid-section, with a paragraph sitting on two colours, read as broken twice."
 },
 {
  "id": "stillness",
  "name": "Stillness",
  "description": "No showpiece. The page is simply there, instantly, and the discipline is the statement. Motion is limited to exact feedback: a 140ms press on the button, a 180ms colour change on links, focus rings that appear without delay. Do not add a load animation or scroll reveals to this direction."
 },
 {
  "id": "opening-window",
  "name": "Opening window",
  "description": "A type screen hands over to the one great photograph: the picture first shows as a window about 45% of the width in the middle of a held stage and opens to full-bleed over about one screen of scroll, while the image inside settles from 1.15 scale. Scroll-linked and linear, so the reader sets the pace; it is fully open after three quarters of the hold and rests a moment before the page moves on. Use it once, for the picture that carries the subject. Where scroll timelines are unsupported, or under reduced motion, the picture is simply a full-bleed screen. Recipe: 'Opening window' in references/motion.md."
 },
 {
  "id": "ink-fill",
  "name": "Ink-fill statement",
  "description": "One statement of three to five lines at 32px or more starts at 45% ink and fills to full ink line by line as it rises from three quarters of the way down the screen to just past half way, so the reading pace follows the scroll. Every word is readable before it fills; the fill only sets the rhythm. Once per page, never on body copy, never on a headline the visitor needs at once. Full ink without scroll timelines or under reduced motion. Recipe: 'Ink fill' in references/motion.md."
 },
 {
  "id": "sheet-over",
  "name": "Sheet over a held picture",
  "description": "A full-bleed photograph holds its place for one screen and the next chapter, an opaque sheet in the page ground carrying its heading at the top edge, slides up over it like a page being turned. The chapter change is read, not just seen, and nothing is hijacked: the photograph is a 100svh figure with position: sticky; top: 0, followed by a section with its own background and a higher stacking order. Hold for one screen at most, use it at one or two chapter changes, and give each held photograph a caption so the held screen carries a line of text."
 },
 {
  "id": "sideways-gallery",
  "name": "Sideways gallery",
  "description": "A short set of pictures (eight or fewer: rooms, kitchens, pieces) moves sideways while the vertical scroll holds the stage, one pixel of scroll for one pixel of travel, and the hold ends the moment the last picture reaches the margin. Only on wide screens with motion allowed and only when the row is under three screens long; phones, reduced motion and longer rows get the native drag row with snapping. Focusing a link scrolls its picture into view, and the only copy of a call to action never sits inside the moving row. Recipe: 'Sideways gallery' in references/motion.md."
 },
 {
  "id": "pushed-line",
  "name": "Pushed line",
  "description": "One line of very large type, the name or the claim, is pushed sideways by the scroll rather than by a clock: it travels from 12% right to 12% left of its place while it crosses the screen, linear and locked to the scrollbar, and reads whole at the middle of its run. One per page, never across running text, and the words must never be cropped at the moment the line sits in the middle of the screen. Build with animation-timeline: view() on the line and a translateX keyframe; static where scroll timelines are unsupported and under reduced motion."
 },
 {
  "id": "rolling-figures",
  "name": "Rolling figures",
  "description": "For a page whose case is a handful of numbers (a report, a fund, a practice with a founding year): three or four figures at display size roll each digit up to its value once as they come into view, about 1.1 seconds on a strong ease-out, the columns 70ms apart. Tabular figures, the real number kept in the text for screen readers and search, never in running text and never for a value that changes after load. Set at its value under reduced motion. Recipe: 'Rolling figures' in references/motion.md."
 },
 {
  "id": "carried-picture",
  "name": "Carried picture",
  "description": "Between the index and a case page, the picture the visitor clicked travels into place as the new page's hero while the old page fades out in 200ms and the new one rises in, all within 480ms, using the browser's own cross-document view transitions with no library and no full-screen curtain. Give the card picture and the hero the same view-transition-name; each name appears once per page. Browsers without view transitions simply navigate; reduced motion turns it off. Recipe: 'Page to page' in references/motion.md."
 }
]
library/palettes.json›
[
 {
  "id": "white-link-blue",
  "name": "Sheet and biro",
  "tags": [
   "light-neutral",
   "mono-accent"
  ],
  "bg": "#ffffff",
  "surface": "#f4f4f4",
  "ink": "#111111",
  "muted": "#6b6b6b",
  "accent": "#1a4be0",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#111111",
    "ink": "#ffffff"
   }
  ],
  "note": "White holds the whole page with black type, one grey for secondary lines and a pale grey step for tiles and inputs; the footer may invert to black. Blue is the colour of text links and the one button, and nothing else is tinted.",
  "seenIn": [
   "opl-shawn-golden",
   "opl-lovably",
   "aww-bleibtgleich26"
  ]
 },
 {
  "id": "cool-white-slate",
  "name": "Frost and slate",
  "tags": [
   "light-neutral"
  ],
  "bg": "#f7f9fa",
  "surface": "#ecf0f2",
  "ink": "#1f3038",
  "muted": "#5d6b72",
  "accent": "#0d7480",
  "accentInk": "#ffffff",
  "note": "Cool white and one paler blue-grey step hold everything, with type in dark slate instead of black. Teal appears once per screen at most, as the primary button or one small detail beside the product photograph.",
  "seenIn": [
   "opl-aevion",
   "aww-cominvi"
  ]
 },
 {
  "id": "rose-grey-green",
  "name": "Rose grey, green",
  "tags": [
   "light-neutral"
  ],
  "bg": "#ebe5e3",
  "surface": "#e1dad7",
  "ink": "#2b2622",
  "muted": "#6a615d",
  "accent": "#0f7d45",
  "accentInk": "#ffffff",
  "note": "A single rosy grey covers the page from top to bottom with brown-black text and grey secondary lines; images are kept greyscale. Green appears only on the current-page link and on one button in the footer.",
  "seenIn": [
   "opl-the-queue"
  ]
 },
 {
  "id": "paper-grey-brass",
  "name": "Newsprint and brass",
  "tags": [
   "light-neutral"
  ],
  "bg": "#e8e8e3",
  "surface": "#dcdcd6",
  "ink": "#181715",
  "muted": "#63625c",
  "accent": "#bfb21a",
  "accentInk": "#181715",
  "fields": [
   {
    "bg": "#181715",
    "ink": "#e8e8e3"
   }
  ],
  "note": "Warm paper grey with near-black type holds the upper page, then whole sections flip to near-black with paper-grey type; no section is tinted. Brass yellow marks one small detail per screen, such as the button or a single word.",
  "seenIn": [
   "aww-monolog"
  ]
 },
 {
  "id": "stone-blueprint",
  "name": "Stone and blueprint",
  "tags": [
   "light-neutral"
  ],
  "bg": "#dfdedb",
  "surface": "#d3d2ce",
  "ink": "#26251e",
  "muted": "#5f5d55",
  "accent": "#2456d6",
  "accentInk": "#ffffff",
  "note": "Stone paper carries the whole page with near-black and grey text. One image at a time is printed in the accent blue as its only ink, and the same blue fills the single button.",
  "seenIn": [
   "opl-compile"
  ]
 },
 {
  "id": "white-navy-tab",
  "name": "Navy on white",
  "tags": [
   "light-neutral",
   "photographic-neutral"
  ],
  "bg": "#ffffff",
  "surface": "#f4f4f2",
  "ink": "#1f2b4d",
  "muted": "#6a7186",
  "accent": "#f7941d",
  "accentInk": "#1f2b4d",
  "fields": [
   {
    "bg": "#05070a",
    "ink": "#ffffff"
   },
   {
    "bg": "#e3e1dc",
    "ink": "#1f2b4d"
   }
  ],
  "note": "Pure white pages with navy type sit between full-bleed photographs, opening on near-black and closing on a warm grey footer. Tangerine is one small tab or button, never larger than a thumbnail.",
  "seenIn": [
   "aww-white-desert",
   "aww-realevate"
  ]
 },
 {
  "id": "three-stones",
  "name": "Three stones",
  "tags": [
   "light-neutral",
   "photographic-neutral"
  ],
  "bg": "#eeecea",
  "surface": "#e4e1dd",
  "ink": "#333231",
  "muted": "#6b6763",
  "accent": "#5c6b54",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#cdc3b6",
    "ink": "#333231"
   }
  ],
  "note": "Three close stone tones share the interface: the lightest for the page, the middle one for panels, the darkest for a single band, all with charcoal type. Saturated colour is left to the photographs and their backdrops; the grey-green fills the button only.",
  "seenIn": [
   "aww-noho",
   "aww-vero-new-york"
  ]
 },
 {
  "id": "putty-black-teal",
  "name": "Putty and black",
  "tags": [
   "light-neutral",
   "photographic-neutral"
  ],
  "bg": "#dedad3",
  "surface": "#d3cec6",
  "ink": "#0a0a0a",
  "muted": "#5c5852",
  "accent": "#256f82",
  "accentInk": "#ffffff",
  "note": "Warm putty covers the page and pure black handles all type and the main shapes. The only tint in the interface is teal, used for the one button or a single marker placed near the photographs.",
  "seenIn": [
   "aww-colonia-zacamil",
   "opl-archiv"
  ]
 },
 {
  "id": "moss-sand",
  "name": "Sand and moss",
  "tags": [
   "light-neutral",
   "earth"
  ],
  "bg": "#ddd6c6",
  "surface": "#d2cab8",
  "ink": "#2a2a26",
  "muted": "#59574e",
  "accent": "#4f6238",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#3f5a2a",
    "ink": "#ffffff"
   }
  ],
  "note": "Sand grey holds the page with charcoal type, and one deep leaf-green band may close it. Moss green is reserved for buttons; brighter greens and blues arrive only through photographs.",
  "seenIn": [
   "opl-vestris",
   "aww-izanami"
  ]
 },
 {
  "id": "green-mat-chocolate",
  "name": "Bone, green, chocolate",
  "tags": [
   "light-neutral",
   "earth"
  ],
  "bg": "#e5e1d8",
  "surface": "#dad5ca",
  "ink": "#2a1d17",
  "muted": "#6b5b51",
  "accent": "#456f3a",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#456f3a",
    "ink": "#ffffff"
   }
  ],
  "note": "Bone ground with chocolate-brown text takes most of the page. The green is spent on one large flat block, about a third of a screen, and on the button; all other colour comes from photographs.",
  "seenIn": [
   "opl-davesocozy",
   "opl-mat"
  ]
 },
 {
  "id": "lunar-grey-bronze",
  "name": "Lunar and bronze",
  "tags": [
   "dark-neutral"
  ],
  "bg": "#111111",
  "surface": "#1c1c1c",
  "ink": "#f2f2f2",
  "muted": "#8f8f8f",
  "accent": "#a08672",
  "accentInk": "#111111",
  "note": "Soft black holds the page with panels one step lighter, white text and grey secondary lines, so photographs supply all the colour. Muted bronze goes on small markers and the one button only.",
  "seenIn": [
   "aww-21-hrs-on-the-moon",
   "opl-dialkit",
   "opl-lorenzo-bocchi-photography"
  ]
 },
 {
  "id": "blue-black-cornflower",
  "name": "Night and cornflower",
  "tags": [
   "dark-neutral"
  ],
  "bg": "#0a0d14",
  "surface": "#131824",
  "ink": "#e9edf3",
  "muted": "#8891a1",
  "accent": "#5a9bfc",
  "accentInk": "#0a0d14",
  "note": "Blue-black ground and slate panels with silver-white text fill the page; no section changes colour. Cornflower blue marks the buttons and at most one word in a heading.",
  "seenIn": [
   "opl-viewport-2",
   "aww-moto-finance",
   "opl-rafael-derolez-2026"
  ]
 },
 {
  "id": "green-black-bone",
  "name": "Green-black and bone",
  "tags": [
   "dark-neutral",
   "earth"
  ],
  "bg": "#161c1a",
  "surface": "#1f2725",
  "ink": "#e3e2da",
  "muted": "#8e948d",
  "accent": "#9fc7b8",
  "accentInk": "#161c1a",
  "note": "Bone type on green-black from top to bottom, with panels and drawn details barely lighter than the ground so photographs supply the colour. Pale jade is for the booking or contact button alone.",
  "seenIn": [
   "opl-kereliott",
   "aww-lama-lama-2"
  ]
 },
 {
  "id": "black-crimson",
  "name": "Black and crimson",
  "tags": [
   "dark-neutral",
   "mono-accent"
  ],
  "bg": "#0a0a0a",
  "surface": "#151515",
  "ink": "#efefef",
  "muted": "#8c8c8c",
  "accent": "#b11226",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#efefef",
    "ink": "#0a0a0a"
   }
  ],
  "note": "Black with off-white text and mid-grey texture takes nearly the whole page, and the last sections may invert to off-white with black type. Deep crimson is scattered sparingly: a few characters in a heading, a slider fill, the one button.",
  "seenIn": [
   "opl-textmode-overlay"
  ]
 },
 {
  "id": "forest-pale-lime",
  "name": "Forest and lime",
  "tags": [
   "dark-neutral",
   "earth"
  ],
  "bg": "#0f1f10",
  "surface": "#162a17",
  "ink": "#ffffff",
  "muted": "#9aa39a",
  "accent": "#c6f19d",
  "accentInk": "#0f1f10",
  "fields": [
   {
    "bg": "#000000",
    "ink": "#ffffff"
   },
   {
    "bg": "#ffffff",
    "ink": "#0f1f10"
   }
  ],
  "note": "Forest green, black and white sections alternate, with forest green holding the most; photographs stay in moss greens and stone browns. Pale lime sits on small squares only, such as arrow buttons and the closing wordmark.",
  "seenIn": [
   "aww-alethia"
  ]
 },
 {
  "id": "charcoal-room-sage",
  "name": "Charcoal and sage",
  "tags": [
   "dark-neutral",
   "photographic-neutral"
  ],
  "bg": "#1c1c1c",
  "surface": "#262626",
  "ink": "#f1f1ef",
  "muted": "#9a9b96",
  "accent": "#8fa68e",
  "accentInk": "#1c1c1c",
  "fields": [
   {
    "bg": "#c8c5ba",
    "ink": "#1c1c1c"
   }
  ],
  "note": "Charcoal holds the page with off-white text, and one section may flip to a pale concrete tone with dark text. Colour comes from the photographs; grey sage is used for the single button and the active state of a control.",
  "seenIn": [
   "opl-transport",
   "opl-amanda-guo-design"
  ]
 },
 {
  "id": "warm-black-gold",
  "name": "Warm black, gold",
  "tags": [
   "dark-neutral",
   "photographic-neutral"
  ],
  "bg": "#0c0a08",
  "surface": "#181512",
  "ink": "#ffffff",
  "muted": "#8f8a84",
  "accent": "#c9a45a",
  "accentInk": "#0c0a08",
  "note": "Warm near-black fills the page and photographs sit bright against it, with pure white small type; panels warm to a brown-black. Gold is for one text link or small button per screen, never a field.",
  "seenIn": [
   "aww-cipher",
   "opl-hebbia"
  ]
 },
 {
  "id": "graphite-safety-orange",
  "name": "Graphite and orange",
  "tags": [
   "mono-accent",
   "dark-neutral"
  ],
  "bg": "#18191b",
  "surface": "#222427",
  "ink": "#eff0f1",
  "muted": "#9c9ea3",
  "accent": "#ff4a1f",
  "accentInk": "#18191b",
  "fields": [
   {
    "bg": "#eff0f1",
    "ink": "#18191b"
   }
  ],
  "note": "Graphite and cool light-grey sections alternate, graphite taking about two thirds, and photographs are desaturated so nothing competes. Safety orange marks the buttons, one thick solid bar across the top of the page and the highlighted item in a list or chart.",
  "seenIn": [
   "aww-sstr-friction-reduction",
   "aww-boc-studio",
   "aww-dragonfly-redux"
  ]
 },
 {
  "id": "soot-pale-lime",
  "name": "Soot and lime",
  "tags": [
   "mono-accent",
   "dark-neutral"
  ],
  "bg": "#0f0f0f",
  "surface": "#1a1a1a",
  "ink": "#ffffff",
  "muted": "#8f8f8f",
  "accent": "#dafa9c",
  "accentInk": "#0f0f0f",
  "fields": [
   {
    "bg": "#ffffff",
    "ink": "#0f0f0f"
   },
   {
    "bg": "#dafa9c",
    "ink": "#0f0f0f"
   }
  ],
  "note": "A soot-black opening and close bracket white middle sections, all in black, white and grey. Pale lime is the single action colour: every button, and at most one headline set in it on the dark ground.",
  "seenIn": [
   "opl-builders-table",
   "aww-made-with-gsap-1",
   "aww-hilden-kaira"
  ]
 },
 {
  "id": "plum-black-pink",
  "name": "Plum-black and pink",
  "tags": [
   "mono-accent",
   "dark-neutral"
  ],
  "bg": "#0d060b",
  "surface": "#1b0e17",
  "ink": "#ffffff",
  "muted": "#9a8f97",
  "accent": "#ff488b",
  "accentInk": "#0d060b",
  "fields": [
   {
    "bg": "#470c35",
    "ink": "#ffffff"
   },
   {
    "bg": "#ffffff",
    "ink": "#101421"
   }
  ],
  "note": "Near-black and plum at the top and bottom bracket a white middle with navy-black text. Hot pink is reserved for primary buttons, bars in charts and map markers; it never fills a section.",
  "seenIn": [
   "aww-cerebrium"
  ]
 },
 {
  "id": "white-signal-red",
  "name": "Signal red, white",
  "tags": [
   "mono-accent"
  ],
  "bg": "#ffffff",
  "surface": "#f2f2f2",
  "ink": "#0a0a0a",
  "muted": "#6b6b6b",
  "accent": "#d92b21",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#000000",
    "ink": "#ffffff"
   }
  ],
  "note": "White ground, black text and paper grey for filled panels, with one full black band allowed. Signal red is the only colour in the interface: one word in a headline, a few solid blocks, or the button.",
  "seenIn": [
   "opl-rsquad",
   "aww-serotoninn",
   "opl-estacion-venenta"
  ]
 },
 {
  "id": "white-mint-tiles",
  "name": "Greys and mint",
  "tags": [
   "mono-accent"
  ],
  "bg": "#ffffff",
  "surface": "#f3f3f3",
  "ink": "#232323",
  "muted": "#6e6e6e",
  "accent": "#a1ffcb",
  "accentInk": "#232323",
  "fields": [
   {
    "bg": "#a1ffcb",
    "ink": "#232323"
   },
   {
    "bg": "#232323",
    "ink": "#ffffff"
   }
  ],
  "note": "White, near-black and two greys fill a board of flat tiles. Mint floods a few whole tiles (one statement, one figure, the closing call to action) and appears nowhere else.",
  "seenIn": [
   "aww-aspen-search"
  ]
 },
 {
  "id": "cool-grey-cobalt",
  "name": "Cool grey, cobalt",
  "tags": [
   "mono-accent"
  ],
  "bg": "#eef0f3",
  "surface": "#e2e5ea",
  "ink": "#0b0c0e",
  "muted": "#5d6470",
  "accent": "#1f2de6",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#1f2de6",
    "ink": "#ffffff"
   }
  ],
  "note": "Cool pale grey holds the page with near-black type and nothing else tinted. Cobalt starts as the button and one highlighted sentence, takes one whole section as a flat field with white text, then returns as a single button block.",
  "seenIn": [
   "opl-vessa",
   "opl-specia1ne",
   "opl-brikkenco"
  ]
 },
 {
  "id": "offwhite-bottle-yellow",
  "name": "Bottle ink, yellow",
  "tags": [
   "mono-accent"
  ],
  "bg": "#f7f7f7",
  "surface": "#ebebeb",
  "ink": "#0d2a1c",
  "muted": "#5f6b64",
  "accent": "#f6e016",
  "accentInk": "#0d2a1c",
  "fields": [
   {
    "bg": "#f6e016",
    "ink": "#0d2a1c"
   }
  ],
  "note": "Off-white ground with all type in a near-black bottle green instead of black. Yellow is the only saturated note: the button or one small object on most screens, and one later section that turns fully yellow.",
  "seenIn": [
   "aww-leo-parpeix-portfolio-2026",
   "opl-wild-as"
  ]
 },
 {
  "id": "teal-on-paper",
  "name": "Teal on paper",
  "tags": [
   "two-tone"
  ],
  "bg": "#f7f9f8",
  "surface": "#ebf0ee",
  "ink": "#06525c",
  "muted": "#2f737d",
  "accent": "#06525c",
  "accentInk": "#f7f9f8",
  "fields": [
   {
    "bg": "#06525c",
    "ink": "#f7f9f8"
   }
  ],
  "note": "Cool white paper and a single teal used for every word, shape and button, with no black anywhere. The accent is that same teal filled solid, for the button and for one inverted band with paper-coloured type.",
  "seenIn": [
   "opl-le-petit-bleu"
  ]
 },
 {
  "id": "navy-pink",
  "name": "Navy and pink",
  "tags": [
   "two-tone",
   "single-field"
  ],
  "bg": "#1a2039",
  "surface": "#212846",
  "ink": "#f9a3c0",
  "muted": "#b9879c",
  "accent": "#f9a3c0",
  "accentInk": "#1a2039",
  "note": "One navy field runs the entire page. Pink does everything else, from the name and headings to body text, and fills one solid button.",
  "seenIn": [
   "aww-trevor-noah"
  ]
 },
 {
  "id": "blush-aubergine",
  "name": "Blush and aubergine",
  "tags": [
   "two-tone"
  ],
  "bg": "#edccbe",
  "surface": "#e8c2b2",
  "ink": "#351a2b",
  "muted": "#6c4857",
  "accent": "#351a2b",
  "accentInk": "#edccbe",
  "fields": [
   {
    "bg": "#351a2b",
    "ink": "#edccbe"
   }
  ],
  "note": "Blush pink and dark aubergine alternate as full fields and serve as each other's text colour, in roughly equal shares. There is no third colour: the button is a solid block of whichever of the two is not the ground.",
  "seenIn": [
   "aww-house-of-honey-1",
   "opl-sarah-matt-wedding"
  ]
 },
 {
  "id": "cobalt-on-sand",
  "name": "Cobalt on sand",
  "tags": [
   "two-tone"
  ],
  "bg": "#e6dbc7",
  "surface": "#ddd0b9",
  "ink": "#062b8a",
  "muted": "#3a5aa8",
  "accent": "#062b8a",
  "accentInk": "#e6dbc7",
  "note": "Sand paper and one cobalt do everything: text, drawings and solid shapes are all the same blue. The button is a cobalt block with sand-coloured type, and no black or second hue is used.",
  "seenIn": [
   "aww-illoca"
  ]
 },
 {
  "id": "oxblood-grey",
  "name": "Oxblood and grey",
  "tags": [
   "two-tone"
  ],
  "bg": "#e2e2e2",
  "surface": "#d6d6d6",
  "ink": "#7d0c14",
  "muted": "#7a4a4e",
  "accent": "#7d0c14",
  "accentInk": "#e2e2e2",
  "fields": [
   {
    "bg": "#7d0c14",
    "ink": "#e2e2e2"
   }
  ],
  "note": "Deep red text on grey paper holds most of the page, then pale grey text on a solid red field takes one long section and the footer. Photographs are desaturated so nothing breaks the pair, and the button is a solid red block.",
  "seenIn": [
   "opl-belgrade-architecture"
  ]
 },
 {
  "id": "olive-on-bone",
  "name": "Olive on bone",
  "tags": [
   "two-tone",
   "earth"
  ],
  "bg": "#ecebe7",
  "surface": "#e1dfd9",
  "ink": "#38450f",
  "muted": "#5d683a",
  "accent": "#38450f",
  "accentInk": "#ecebe7",
  "fields": [
   {
    "bg": "#38450f",
    "ink": "#ecebe7"
   }
  ],
  "note": "A bone page with every word, large or small, in one olive green; the footer inverts to a solid olive field with bone text. Photography supplies all other colour, and the button is the same olive, filled.",
  "seenIn": [
   "aww-tengile-malamala-collection"
  ]
 },
 {
  "id": "burgundy-bubblegum",
  "name": "Burgundy and bubblegum",
  "tags": [
   "two-tone"
  ],
  "bg": "#3d0012",
  "surface": "#4d0519",
  "ink": "#ff99d3",
  "muted": "#c77aa0",
  "accent": "#ff99d3",
  "accentInk": "#3d0012",
  "note": "Deep burgundy is the ground for the whole page and bubblegum pink sets every word on it. The button and the active state of a control are solid pink with burgundy type; product photographs supply any other colour.",
  "seenIn": [
   "opl-madie"
  ]
 },
 {
  "id": "bottle-green-gold",
  "name": "Bottle green, gold",
  "tags": [
   "two-tone",
   "earth"
  ],
  "bg": "#133621",
  "surface": "#1a4429",
  "ink": "#f4d96c",
  "muted": "#b3a55e",
  "accent": "#f4d96c",
  "accentInk": "#133621",
  "note": "Bottle green covers the page and warm gold sets all the type, with panels one step lighter than the ground. The button is solid gold with green text, and photographs are kept dark and warm so the pair is not disturbed.",
  "seenIn": [
   "aww-lacoste-ace-breaker"
  ]
 },
 {
  "id": "cobalt-field",
  "name": "Cobalt field",
  "tags": [
   "single-field"
  ],
  "bg": "#0034eb",
  "surface": "#002fd6",
  "ink": "#ffffff",
  "muted": "#c5d0ff",
  "accent": "#e9e34a",
  "accentInk": "#111111",
  "note": "One electric cobalt fills everything, with white and pale-blue type; photographs are tinted into blue monotone or kept as small thumbnails. A single yellow dot or button is the only other colour on the page.",
  "seenIn": [
   "opl-george-wood",
   "aww-studio-k95-3"
  ]
 },
 {
  "id": "mustard-field",
  "name": "Mustard and black",
  "tags": [
   "single-field"
  ],
  "bg": "#e1ac10",
  "surface": "#cd9d0f",
  "ink": "#0b0b0b",
  "muted": "#4d3d0a",
  "accent": "#24338f",
  "accentInk": "#ffffff",
  "note": "One warm mustard field for the whole page with black ink only. Royal blue is the single interruption, kept to a sliver along one edge or one small button.",
  "seenIn": [
   "opl-beige-force"
  ]
 },
 {
  "id": "carmine-field",
  "name": "Carmine field",
  "tags": [
   "single-field"
  ],
  "bg": "#9c1220",
  "surface": "#8a101c",
  "ink": "#ffffff",
  "muted": "#f0b9bd",
  "accent": "#ffd9dc",
  "accentInk": "#9c1220",
  "fields": [
   {
    "bg": "#2a2a2a",
    "ink": "#ffffff"
   }
  ],
  "note": "A deep red field bookends or fills the page, with white type and pale pink secondary lines; work shown in between sits on flat charcoal so it keeps its own colours. The button is a pale pink block with red type.",
  "seenIn": [
   "opl-marina-rachello",
   "opl-belgrade-architecture"
  ]
 },
 {
  "id": "pink-field-lime",
  "name": "Bubblegum and lime",
  "tags": [
   "single-field"
  ],
  "bg": "#ff87ab",
  "surface": "#ff739d",
  "ink": "#0a0a0a",
  "muted": "#6b1f3a",
  "accent": "#def915",
  "accentInk": "#0a0a0a",
  "fields": [
   {
    "bg": "#0a0a0a",
    "ink": "#ff87ab"
   }
  ],
  "note": "A flat bubblegum pink field with black type from top to bottom. Fluorescent lime is the action colour only: the menu button, one highlighter swipe behind a word and the main button.",
  "seenIn": [
   "opl-nodeck",
   "aww-decathlon-yestalgia"
  ]
 },
 {
  "id": "sky-field-navy",
  "name": "Sky and navy",
  "tags": [
   "single-field"
  ],
  "bg": "#6bb4ee",
  "surface": "#7dbdf0",
  "ink": "#0e1822",
  "muted": "#1b3d5c",
  "accent": "#ff5a1f",
  "accentInk": "#0e1822",
  "fields": [
   {
    "bg": "#ffffff",
    "ink": "#0e1822"
   },
   {
    "bg": "#0e1822",
    "ink": "#ffffff"
   }
  ],
  "note": "Sky blue sits behind everything, white cards lie on top of it, and navy handles all text and the footer. Orange is for buttons and one highlighted word, so orange against blue is the single clash on the page.",
  "seenIn": [
   "opl-ohhmydesign",
   "opl-anidachi"
  ]
 },
 {
  "id": "orange-field-black",
  "name": "Orange and black",
  "tags": [
   "single-field",
   "two-tone"
  ],
  "bg": "#ff7a1f",
  "surface": "#ff8633",
  "ink": "#050f16",
  "muted": "#5a2306",
  "accent": "#050f16",
  "accentInk": "#ff7a1f",
  "fields": [
   {
    "bg": "#ffffff",
    "ink": "#050f16"
   }
  ],
  "note": "Hot orange is the page: one flat field with blue-black type, flipping at a hard horizontal seam to a white section when the content needs a rest. The button is a solid block of the same blue-black with orange text, and every other colour comes from the objects shown.",
  "seenIn": [
   "opl-postaway",
   "opl-the-new-industrials"
  ]
 },
 {
  "id": "tobacco-olive",
  "name": "Tobacco and olive",
  "tags": [
   "single-field",
   "earth"
  ],
  "bg": "#472913",
  "surface": "#55321a",
  "ink": "#e4e0cf",
  "muted": "#b39f84",
  "accent": "#525e1b",
  "accentInk": "#e4e0cf",
  "fields": [
   {
    "bg": "#c79a3e",
    "ink": "#2b170a"
   },
   {
    "bg": "#525e1b",
    "ink": "#e4e0cf"
   }
  ],
  "note": "Tobacco brown fills the page with bone type, and photographs stay in browns and ochre. One olive block holds the main text or the button, and a single ochre band may break the brown once.",
  "seenIn": [
   "aww-the-tuscan-journey-begins"
  ]
 },
 {
  "id": "poster-chapters",
  "name": "Poster chapters",
  "tags": [
   "flat-loud"
  ],
  "bg": "#e6e0d2",
  "surface": "#dcd5c5",
  "ink": "#1a1a1a",
  "muted": "#5f5b52",
  "accent": "#18bc42",
  "accentInk": "#1a1a1a",
  "fields": [
   {
    "bg": "#ed4b44",
    "ink": "#0f0f0f"
   },
   {
    "bg": "#1a1a1a",
    "ink": "#e6e0d2"
   },
   {
    "bg": "#18bc42",
    "ink": "#1a1a1a"
   },
   {
    "bg": "#f2c21a",
    "ink": "#1a1a1a"
   },
   {
    "bg": "#e6e0d2",
    "ink": "#1a1a1a"
   }
  ],
  "note": "Each chapter takes one flat field in turn (coral red, near-black, bone, green, yellow) with black or bone type and no tints between them. Green doubles as the accent for the one button when the ground is bone or black.",
  "seenIn": [
   "aww-pp-neue-montreal"
  ]
 },
 {
  "id": "blue-red-black",
  "name": "Blue, red, black",
  "tags": [
   "flat-loud"
  ],
  "bg": "#ffffff",
  "surface": "#f3f3f3",
  "ink": "#0a0a0a",
  "muted": "#6b6b6b",
  "accent": "#2436ff",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#2436ff",
    "ink": "#ffffff"
   },
   {
    "bg": "#ff3b1f",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#0a0a0a",
    "ink": "#ffffff"
   },
   {
    "bg": "#050a5c",
    "ink": "#ffffff"
   }
  ],
  "note": "Three flat poster colours each own whole sections: electric blue, red-orange and black, with white for the areas that show the product. No tints sit between them, and blue is the button colour on white.",
  "seenIn": [
   "opl-juggle"
  ]
 },
 {
  "id": "charcoal-primaries",
  "name": "Charcoal and primaries",
  "tags": [
   "flat-loud"
  ],
  "bg": "#191919",
  "surface": "#242424",
  "ink": "#ffffff",
  "muted": "#9a9a9a",
  "accent": "#0c7866",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#d71e1e",
    "ink": "#ffffff"
   },
   {
    "bg": "#1e4bd7",
    "ink": "#ffffff"
   },
   {
    "bg": "#0c7866",
    "ink": "#ffffff"
   },
   {
    "bg": "#ffe927",
    "ink": "#111111"
   }
  ],
  "note": "A charcoal ground holds flat poster primaries assigned to whole panels: red, blue, teal green and yellow, each placed against a different one. Type is white, or black on yellow; teal green is the button colour on charcoal.",
  "seenIn": [
   "aww-mosbys-files"
  ]
 },
 {
  "id": "ultramarine-yellow",
  "name": "Ultramarine and yellow",
  "tags": [
   "flat-loud"
  ],
  "bg": "#1423b8",
  "surface": "#1220a6",
  "ink": "#ffffff",
  "muted": "#b4bcf0",
  "accent": "#1fc27a",
  "accentInk": "#0a0a0a",
  "fields": [
   {
    "bg": "#1423b8",
    "ink": "#ffffff"
   },
   {
    "bg": "#000000",
    "ink": "#ffffff"
   },
   {
    "bg": "#f7dd47",
    "ink": "#000000"
   },
   {
    "bg": "#e9479f",
    "ink": "#000000"
   }
  ],
  "note": "Each section is one flat field in sequence: ultramarine, black, yellow, then ultramarine again, with white type on blue and black, and black type on yellow. Green is the only small accent and never grows larger than a button.",
  "seenIn": [
   "aww-warm-fuzzy",
   "aww-px-push"
  ]
 },
 {
  "id": "candy-chapters",
  "name": "Candy chapters",
  "tags": [
   "flat-loud"
  ],
  "bg": "#ffd249",
  "surface": "#f5c63c",
  "ink": "#0a0a0a",
  "muted": "#5e4a0a",
  "accent": "#f53fd6",
  "accentInk": "#0a0a0a",
  "fields": [
   {
    "bg": "#ffd249",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#ffffff",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#ffc4ef",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#a4f6f8",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#f9a21f",
    "ink": "#0a0a0a"
   }
  ],
  "note": "Each section owns one flat candy colour: yellow first, then white, pink, cyan and orange, with all type in black. Magenta is kept for buttons alone.",
  "seenIn": [
   "aww-aardvark-book-club"
  ]
 },
 {
  "id": "orange-sky-lime",
  "name": "Orange, sky, lime",
  "tags": [
   "flat-loud"
  ],
  "bg": "#ff8126",
  "surface": "#ff8d3a",
  "ink": "#111111",
  "muted": "#5c2a05",
  "accent": "#de194b",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#ff8126",
    "ink": "#111111"
   },
   {
    "bg": "#4db3ff",
    "ink": "#111111"
   },
   {
    "bg": "#c5f53d",
    "ink": "#111111"
   },
   {
    "bg": "#161615",
    "ink": "#f2f2f2"
   }
  ],
  "note": "The page ground changes as you read: orange first, then sky blue, then acid lime at the end, with one near-black band between; type stays black on all three colours. Crimson is one small button per field.",
  "seenIn": [
   "opl-the-new-industrials",
   "opl-anidachi"
  ]
 },
 {
  "id": "post-red-mustard",
  "name": "Post red, mustard",
  "tags": [
   "flat-loud"
  ],
  "bg": "#e2dccf",
  "surface": "#d7d0c1",
  "ink": "#2a1110",
  "muted": "#6b524c",
  "accent": "#4a6f84",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#dc1928",
    "ink": "#ffffff"
   },
   {
    "bg": "#952625",
    "ink": "#ffffff"
   },
   {
    "bg": "#e0a912",
    "ink": "#2a1110"
   },
   {
    "bg": "#4a6f84",
    "ink": "#ffffff"
   },
   {
    "bg": "#e2dccf",
    "ink": "#2a1110"
   }
  ],
  "note": "Post red and bone alternate as full section fields, with red holding the opening and the close. Mustard, slate blue and maroon are kept for whole card frames, and slate blue also fills the one button.",
  "seenIn": [
   "opl-locker-land"
  ]
 },
 {
  "id": "mint-orange-grey",
  "name": "Grey, mint, orange",
  "tags": [
   "flat-loud"
  ],
  "bg": "#ffffff",
  "surface": "#f5f5f5",
  "ink": "#000000",
  "muted": "#666666",
  "accent": "#a7ff9c",
  "accentInk": "#000000",
  "fields": [
   {
    "bg": "#c5c5c5",
    "ink": "#000000"
   },
   {
    "bg": "#a7ff9c",
    "ink": "#000000"
   },
   {
    "bg": "#ff6831",
    "ink": "#000000"
   },
   {
    "bg": "#000000",
    "ink": "#ffffff"
   }
  ],
  "note": "Black on white to start, then whole sections flood with flat mid-grey, acid mint and orange in turn. Photographs inside a flooded section are shown in black-and-white, and mint is also the button colour on white and black.",
  "seenIn": [
   "aww-paul-kalkbrenner"
  ]
 },
 {
  "id": "report-blocks",
  "name": "Report blocks",
  "tags": [
   "flat-loud"
  ],
  "bg": "#ffffff",
  "surface": "#f6f6f6",
  "ink": "#000000",
  "muted": "#5f5f5f",
  "accent": "#fe7141",
  "accentInk": "#000000",
  "fields": [
   {
    "bg": "#fe7141",
    "ink": "#000000"
   },
   {
    "bg": "#d1ddd3",
    "ink": "#000000"
   },
   {
    "bg": "#f0ff1c",
    "ink": "#000000"
   },
   {
    "bg": "#000000",
    "ink": "#f4f4f4"
   }
  ],
  "note": "A white base with flat blocks of orange, sage, acid yellow and black assigned to whole modules: figure tiles, quote panels and chapter bands. Text is black on every colour and off-white on black, and orange is also the button colour.",
  "seenIn": [
   "aww-ai-in-design-report-2026"
  ]
 },
 {
  "id": "card-pastels",
  "name": "Card pastels",
  "tags": [
   "pastel-fields"
  ],
  "bg": "#f7f7f5",
  "surface": "#ebebe8",
  "ink": "#000000",
  "muted": "#666662",
  "accent": "#d0106f",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#97aaf6",
    "ink": "#000000"
   },
   {
    "bg": "#bbf697",
    "ink": "#000000"
   },
   {
    "bg": "#fed878",
    "ink": "#000000"
   },
   {
    "bg": "#6ef8fb",
    "ink": "#000000"
   }
  ],
  "note": "Neutral paper ground and black type, with each card or tile given its own flat pastel (periwinkle, lime, amber or cyan), never two alike side by side. Magenta is reserved for the logo, the email address and text links.",
  "seenIn": [
   "opl-medium-rare"
  ]
 },
 {
  "id": "mint-pink-accounts",
  "name": "Mint and pink",
  "tags": [
   "pastel-fields"
  ],
  "bg": "#f8f8f8",
  "surface": "#eeeeee",
  "ink": "#0a0a0a",
  "muted": "#6a6a6a",
  "accent": "#1f6b3a",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#c3edce",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#f8c7c9",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#ffffff",
    "ink": "#0a0a0a"
   }
  ],
  "note": "Near-white base with black type; mint and pink are the only two colours and each stands for one side of the product. They first appear as small circles, then as full-screen section fields, and deep green is used only for the button.",
  "seenIn": [
   "opl-tara"
  ]
 },
 {
  "id": "swap-pastels",
  "name": "Swapping pastels",
  "tags": [
   "pastel-fields"
  ],
  "bg": "#c3cbe5",
  "surface": "#d0d6eb",
  "ink": "#141414",
  "muted": "#4a5068",
  "accent": "#0f5257",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#c3cbe5",
    "ink": "#141414"
   },
   {
    "bg": "#efd3da",
    "ink": "#141414"
   },
   {
    "bg": "#e6dccd",
    "ink": "#141414"
   },
   {
    "bg": "#cfd3cb",
    "ink": "#141414"
   },
   {
    "bg": "#f9cdbd",
    "ink": "#141414"
   }
  ],
  "note": "One flat pastel is the whole background and it swaps per project or chapter: periwinkle, pink, sand, sage, peach. Type and one solid box stay near-black on all five, and dark teal is only for the button.",
  "seenIn": [
   "opl-belen-jones"
  ]
 },
 {
  "id": "blush-sage-slate",
  "name": "Blush, sage, slate",
  "tags": [
   "pastel-fields",
   "earth"
  ],
  "bg": "#a6b3bc",
  "surface": "#b2bdc5",
  "ink": "#1c1c1c",
  "muted": "#38424a",
  "accent": "#2c3620",
  "accentInk": "#e3e2da",
  "fields": [
   {
    "bg": "#a6b3bc",
    "ink": "#1c1c1c"
   },
   {
    "bg": "#b7c8be",
    "ink": "#1c1c1c"
   },
   {
    "bg": "#d6c4b8",
    "ink": "#1c1c1c"
   },
   {
    "bg": "#2c3620",
    "ink": "#e3e2da"
   }
  ],
  "note": "Slate blue-grey, sage and blush share the page as soft flat blocks, one per chapter or menu tile, with dark type; one deep olive block anchors the close with pale type. Olive is also the button colour, and photographs of sea and sky do the rest.",
  "seenIn": [
   "aww-seasats",
   "opl-nia-creative"
  ]
 },
 {
  "id": "candy-on-black",
  "name": "Candy on black",
  "tags": [
   "pastel-fields"
  ],
  "bg": "#000000",
  "surface": "#0d0d0d",
  "ink": "#ffffff",
  "muted": "#999999",
  "accent": "#93f1f7",
  "accentInk": "#000000",
  "fields": [
   {
    "bg": "#9ae187",
    "ink": "#000000"
   },
   {
    "bg": "#93f1f7",
    "ink": "#000000"
   },
   {
    "bg": "#f3ea7a",
    "ink": "#000000"
   },
   {
    "bg": "#fbb65c",
    "ink": "#000000"
   },
   {
    "bg": "#b9c8ff",
    "ink": "#000000"
   }
  ],
  "note": "Black ground and dark photographs take nearly all of the surface. The only flat colour is a solid block behind each title, which takes a new candy pastel per project (mint, cyan, lemon, orange, cornflower); cyan is the default for the button.",
  "seenIn": [
   "aww-glitch-grit"
  ]
 },
 {
  "id": "sky-sand",
  "name": "Sky and sand",
  "tags": [
   "pastel-fields"
  ],
  "bg": "#cbe4f2",
  "surface": "#bcdcee",
  "ink": "#0a0a0a",
  "muted": "#44606e",
  "accent": "#b0298f",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#cbe4f2",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#f1e3b5",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#ffc013",
    "ink": "#0a0a0a"
   },
   {
    "bg": "#0a0a0a",
    "ink": "#f2f2f2"
   }
  ],
  "note": "Sky blue and sand yellow are the constant ground, each holding whole sections, with black text throughout. Saturated colour arrives only as small solid objects, and magenta is kept for the contact card and its button.",
  "seenIn": [
   "opl-applied-imagination"
  ]
 },
 {
  "id": "studio-grey",
  "name": "Studio grey",
  "tags": [
   "photographic-neutral"
  ],
  "bg": "#a8a8aa",
  "surface": "#b4b4b6",
  "ink": "#0e0e0e",
  "muted": "#3a3a3c",
  "accent": "#1f5c49",
  "accentInk": "#ffffff",
  "fields": [
   {
    "bg": "#1b1b1c",
    "ink": "#ffffff"
   }
  ],
  "note": "One mid grey serves as both page and studio backdrop, so the photographed object seems to stand on the page itself; type is black and one black band comes late. Colour comes only from the product, and racing green is for the single button.",
  "seenIn": [
   "aww-longbow",
   "aww-l-i-s-a"
  ]
 },
 {
  "id": "walnut-tan",
  "name": "Walnut and tan",
  "tags": [
   "photographic-neutral",
   "earth"
  ],
  "bg": "#2b2216",
  "surface": "#382d1e",
  "ink": "#e4e2da",
  "muted": "#a39a8b",
  "accent": "#bda38b",
  "accentInk": "#2b2216",
  "fields": [
   {
    "bg": "#bda38b",
    "ink": "#1c150d"
   }
  ],
  "note": "Dark walnut holds the opening and the close with bone type, and the middle of the page may turn to flat tan around honey-lit photographs. There is no bright accent: tan is the fill for the one button on walnut.",
  "seenIn": [
   "opl-the-deep-end"
  ]
 }
]
library/photos.json›
{
 "source": "Lorem Picsum (https://picsum.photos): photographs from Unsplash, used under the Unsplash licence",
 "url": "https://picsum.photos/id/{id}/{width}/{height}",
 "sets": {
  "fog-and-quiet": {
   "title": "Fog and quiet",
   "note": "misty, muted, minimal landscapes and empty horizons; calm areas for text",
   "sectors": [
    "hospitality",
    "health",
    "fashion",
    "agency",
    "studio-portfolio",
    "personal-portfolio",
    "saas",
    "ai-product",
    "finance",
    "architecture",
    "real-estate",
    "other",
    "nonprofit",
    "culture"
   ],
   "keywords": [
    "fog",
    "mist",
    "quiet",
    "calm",
    "minimal",
    "silence",
    "retreat",
    "wellness",
    "spa",
    "meditation",
    "slow"
   ],
   "primary": [
    "health"
   ],
   "photos": [
    {
     "id": 70,
     "alt": "Road lined with tall trees fading into thick fog",
     "tone": "mid",
     "colours": [
      "grey",
      "dark green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 5,
     "ratio": 1.51,
     "by": "Dorothy Lin",
     "page": "https://unsplash.com/photos/OokBLPrkCNk"
    },
    {
     "id": 172,
     "alt": "Stone jetty stretching into still water and dense grey fog",
     "tone": "mid",
     "colours": [
      "grey",
      "warm grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.51,
     "by": "Aleksi Tappura",
     "page": "https://unsplash.com/photos/TQeX8khR54I"
    },
    {
     "id": 194,
     "alt": "Wooden deck and a single rock disappearing into thick fog",
     "tone": "mid",
     "colours": [
      "cool grey",
      "weathered brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.51,
     "by": "Aleksi Tappura",
     "page": "https://unsplash.com/photos/hVOv8me9ue8"
    },
    {
     "id": 228,
     "alt": "Single round hay bale in a field under dense fog",
     "tone": "mid",
     "colours": [
      "grey green",
      "golden tan"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.33,
     "by": "Michal Kubicek",
     "page": "https://unsplash.com/photos/0AbVUUorULU"
    },
    {
     "id": 255,
     "alt": "Gravel path leading to a cluster of bare trees on a hill",
     "tone": "mid",
     "colours": [
      "olive green",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Jon Ottosson",
     "page": "https://unsplash.com/photos/Xg1e5bR_bL4"
    },
    {
     "id": 266,
     "alt": "Three ships on a distant horizon across a calm blue sea",
     "tone": "mid",
     "colours": [
      "pale blue",
      "deep blue"
     ],
     "mono": false,
     "calm": "all",
     "quality": 5,
     "ratio": 1.78,
     "by": "John Maravelakis",
     "page": "https://unsplash.com/photos/Vosp2wlY3Bs"
    },
    {
     "id": 269,
     "alt": "Gulls perched on a concrete wall beside a pale still sea",
     "tone": "light",
     "colours": [
      "white",
      "pale blue",
      "grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Jill Heyer",
     "page": "https://unsplash.com/photos/bbV7QzDUJus"
    },
    {
     "id": 279,
     "alt": "Still misty water fading into a soft pink-grey sky",
     "tone": "mid",
     "colours": [
      "dusty pink",
      "slate grey"
     ],
     "mono": false,
     "calm": "all",
     "quality": 5,
     "ratio": 1.5,
     "by": "Taylor Leopold",
     "page": "https://unsplash.com/photos/OHS0yA7OkrQ"
    },
    {
     "id": 302,
     "alt": "Lone marker post standing in still water wrapped in fog",
     "tone": "light",
     "colours": [
      "pale grey",
      "white"
     ],
     "mono": false,
     "calm": "all",
     "quality": 5,
     "ratio": 1.6,
     "by": "Billy Lam",
     "page": "https://unsplash.com/photos/O3rmGgNYLKo"
    },
    {
     "id": 353,
     "alt": "Low cloud drifting over dark green forested hills",
     "tone": "dark",
     "colours": [
      "deep green",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.78,
     "by": "Forrest Cavale",
     "page": "https://unsplash.com/photos/qfmd9bu7IgA"
    },
    {
     "id": 465,
     "alt": "Dark rocky peak emerging from thick white cloud",
     "tone": "light",
     "colours": [
      "white",
      "charcoal",
      "pale grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.51,
     "by": "Paula Vermeulen",
     "page": "https://unsplash.com/photos/_f2m3mEkaaU"
    },
    {
     "id": 510,
     "alt": "Small red-roofed church below a mountain under a blank white sky",
     "tone": "light",
     "colours": [
      "white",
      "olive brown",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.33,
     "by": "Jeff Sheldon",
     "page": "https://unsplash.com/photos/4IPe3tnBKK0"
    },
    {
     "id": 544,
     "alt": "Layered hill ridges fading into warm beige mist",
     "tone": "mid",
     "colours": [
      "beige",
      "warm brown",
      "cream"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Francesco Gallarotti",
     "page": "https://unsplash.com/photos/-HkuuUSY1pE"
    },
    {
     "id": 552,
     "alt": "Tiny sailboat on a calm sea with distant hills, black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "white",
      "black"
     ],
     "mono": true,
     "calm": "all",
     "quality": 5,
     "ratio": 1.5,
     "by": "Hugo Kerr",
     "page": "https://unsplash.com/photos/Ke96snCJ1m0"
    },
    {
     "id": 594,
     "alt": "Small boathouse at the end of a jetty on dark water at night",
     "tone": "dark",
     "colours": [
      "black",
      "deep purple",
      "warm white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Aurélien bellanger",
     "page": "https://unsplash.com/photos/uoSEDUu8F-I"
    },
    {
     "id": 634,
     "alt": "Pine forest dissolving into fog beneath a pale pink sky",
     "tone": "mid",
     "colours": [
      "dark green",
      "pale pink",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Jay Mantri",
     "page": "https://unsplash.com/photos/TFyi0QOx08c"
    },
    {
     "id": 813,
     "alt": "Still blue lake with a faint distant shoreline in haze",
     "tone": "mid",
     "colours": [
      "slate blue",
      "powder blue"
     ],
     "mono": false,
     "calm": "all",
     "quality": 5,
     "ratio": 1.5,
     "by": "Roger Burkhard",
     "page": "https://unsplash.com/photos/rlX9J28Pd9g"
    },
    {
     "id": 842,
     "alt": "Wooden steps with railings descending into pale pink fog",
     "tone": "light",
     "colours": [
      "blush pink",
      "pale grey",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 0.75,
     "by": "Joe Beck",
     "page": "https://unsplash.com/photos/NbUokuw0BQI"
    },
    {
     "id": 922,
     "alt": "Dark treeline dissolving upward into pale fog, black and white",
     "tone": "mid",
     "colours": [
      "charcoal",
      "grey",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 5,
     "ratio": 1.8,
     "by": "Patrick Hendry",
     "page": "https://unsplash.com/photos/37ZuGYD3JOk"
    },
    {
     "id": 926,
     "alt": "Lone figure standing on a hilltop in thick beige fog",
     "tone": "light",
     "colours": [
      "warm beige",
      "olive grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.78,
     "by": "Harman Abiwardani",
     "page": "https://unsplash.com/photos/mU_aX6JBYmg"
    },
    {
     "id": 941,
     "alt": "Small island on a pale, glassy sea beneath soft clouds",
     "tone": "light",
     "colours": [
      "pale blue",
      "soft white",
      "grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 5,
     "ratio": 1.5,
     "by": "Ivan Slade",
     "page": "https://unsplash.com/photos/RdyMe8KQAV0"
    },
    {
     "id": 989,
     "alt": "Low cloud covering a forested lakeshore under a pale sky",
     "tone": "light",
     "colours": [
      "pale grey",
      "dark teal",
      "soft blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/eBDdgbM5dSo"
    },
    {
     "id": 994,
     "alt": "Silky long-exposure sea beneath a blue and orange sunset sky",
     "tone": "mid",
     "colours": [
      "soft blue",
      "peach",
      "grey"
     ],
     "mono": false,
     "calm": "all",
     "quality": 5,
     "ratio": 1.5,
     "by": "Jonathan Bean",
     "page": "https://unsplash.com/photos/ywnnwzcdR5o"
    },
    {
     "id": 11,
     "alt": "Misty river winding through a grassy valley between forested slopes",
     "tone": "mid",
     "colours": [
      "pale green",
      "grey green",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/Cm7oKel-X2Q"
    },
    {
     "id": 93,
     "alt": "Marshland with scattered pine trees under a soft pastel sky",
     "tone": "mid",
     "colours": [
      "moss green",
      "pale blue",
      "soft pink"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Caroline Sada",
     "page": "https://unsplash.com/photos/r1XwWjI4PyE"
    },
    {
     "id": 130,
     "alt": "Single seagull gliding over a grey sea beneath pale clouds",
     "tone": "light",
     "colours": [
      "pale grey",
      "white"
     ],
     "mono": true,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ryan Jacques",
     "page": "https://unsplash.com/photos/ywiAen4L4qA"
    },
    {
     "id": 131,
     "alt": "Tiny figure on a pier under a vast pale yellow sky",
     "tone": "light",
     "colours": [
      "pale yellow",
      "sand beige"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.48,
     "by": "Charlie Foster",
     "page": "https://unsplash.com/photos/yPaskTQiNjA"
    },
    {
     "id": 135,
     "alt": "Dark choppy sea fading into dense blue-grey fog",
     "tone": "dark",
     "colours": [
      "indigo",
      "slate blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Yuriy Khimanin",
     "page": "https://unsplash.com/photos/o4H20aIIAt8"
    },
    {
     "id": 213,
     "alt": "Soft orange sunset sky above a calm misty sea",
     "tone": "mid",
     "colours": [
      "peach orange",
      "slate blue"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.51,
     "by": "Kelly Sikkema",
     "page": "https://unsplash.com/photos/X7dy114KWs4"
    },
    {
     "id": 227,
     "alt": "Railway tracks curving into a misty forest of bare trees",
     "tone": "dark",
     "colours": [
      "dark teal",
      "grey green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.5,
     "by": "Andrea Boldizsar",
     "page": "https://unsplash.com/photos/1iP1dozVO8I"
    },
    {
     "id": 264,
     "alt": "Wooden post standing among stones beside still misty water",
     "tone": "mid",
     "colours": [
      "warm beige",
      "grey",
      "tan"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jon Phillips",
     "page": "https://unsplash.com/photos/XsPM2CBTTpI"
    },
    {
     "id": 282,
     "alt": "Wooden railing overlooking a pine forest covered by low fog",
     "tone": "mid",
     "colours": [
      "muted brown",
      "dark green",
      "warm grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Taylor Leopold",
     "page": "https://unsplash.com/photos/9dw0xLlxw1s"
    },
    {
     "id": 291,
     "alt": "Two figures standing in a misty blue field at dusk",
     "tone": "dark",
     "colours": [
      "deep blue",
      "muted rust"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Caleb George",
     "page": "https://unsplash.com/photos/zdjOYZeJj3w"
    },
    {
     "id": 300,
     "alt": "Hay bales scattered across a pale field under white sky",
     "tone": "light",
     "colours": [
      "white",
      "light grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Pavel Voinov",
     "page": "https://unsplash.com/photos/2f_CnEeZsVg"
    },
    {
     "id": 313,
     "alt": "Distant clump of trees in a foggy field at dusk",
     "tone": "dark",
     "colours": [
      "dusty mauve",
      "slate blue",
      "dark olive"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sonja Langford",
     "page": "https://unsplash.com/photos/4xYgBTYC2-Q"
    },
    {
     "id": 385,
     "alt": "Calm sea stretching to a thin glowing horizon at dusk",
     "tone": "mid",
     "colours": [
      "slate blue",
      "teal",
      "soft orange"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Griffin Keller",
     "page": "https://unsplash.com/photos/7oS_26cb1Wo"
    },
    {
     "id": 406,
     "alt": "Low clouds drifting across steep forested mountain slopes",
     "tone": "mid",
     "colours": [
      "white",
      "dark green",
      "grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.51,
     "by": "Roland Batke-Mutschler",
     "page": "https://unsplash.com/photos/SmhN4FejPFs"
    },
    {
     "id": 443,
     "alt": "Mist and cloud hanging over dark pine-covered mountains",
     "tone": "mid",
     "colours": [
      "grey",
      "dark green",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Tim Mossholder",
     "page": "https://unsplash.com/photos/p3kpqGBRPok"
    },
    {
     "id": 457,
     "alt": "Faint ships on a pale foggy sea with almost no horizon",
     "tone": "light",
     "colours": [
      "white",
      "pale grey"
     ],
     "mono": true,
     "calm": "all",
     "quality": 4,
     "ratio": 1.77,
     "by": "Volkan Olmez",
     "page": "https://unsplash.com/photos/ccPpd02Spe0"
    },
    {
     "id": 467,
     "alt": "Dark sea fading into white fog in black and white",
     "tone": "mid",
     "colours": [
      "white",
      "grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Taylor Leopold",
     "page": "https://unsplash.com/photos/COE-CIGj8VA"
    },
    {
     "id": 472,
     "alt": "Small wooded island wrapped in fog on a dark lake",
     "tone": "dark",
     "colours": [
      "dark green",
      "grey",
      "white"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dustin Scarpitti",
     "page": "https://unsplash.com/photos/gR_zHJqbtzo"
    },
    {
     "id": 481,
     "alt": "Still lake at dusk with mist and dark conifers along the shore",
     "tone": "mid",
     "colours": [
      "teal grey",
      "slate",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dustin Scarpitti",
     "page": "https://unsplash.com/photos/RdF3apSExR0"
    },
    {
     "id": 722,
     "alt": "Tree silhouettes in thick golden fog",
     "tone": "mid",
     "colours": [
      "ochre",
      "olive brown",
      "pale gold"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 0.67,
     "by": "Sebastian Kostrubala",
     "page": "https://unsplash.com/photos/4yDg-dDX9uM"
    },
    {
     "id": 727,
     "alt": "Sandy mound with dark shrubs beneath a blank foggy sky",
     "tone": "light",
     "colours": [
      "pale grey",
      "ochre",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Devan Freeman",
     "page": "https://unsplash.com/photos/HrUbyeQvL_w"
    },
    {
     "id": 731,
     "alt": "Grassy rocky mountain ridge emerging from thick grey mist",
     "tone": "mid",
     "colours": [
      "mist grey",
      "olive green",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Rob Bye",
     "page": "https://unsplash.com/photos/61XW8CMNX6A"
    },
    {
     "id": 807,
     "alt": "Lone tree on a grassy slope disappearing into thick fog",
     "tone": "mid",
     "colours": [
      "moss green",
      "mist grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.78,
     "by": "Tiago Aguiar",
     "page": "https://unsplash.com/photos/FtVQIQ9rDFI"
    },
    {
     "id": 886,
     "alt": "Flat grassy field vanishing into pale yellow fog",
     "tone": "light",
     "colours": [
      "pale grey",
      "straw yellow"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Glen Carrie",
     "page": "https://unsplash.com/photos/p62O--o1x-U"
    },
    {
     "id": 925,
     "alt": "Conifer trees fading into dense grey fog on a hillside",
     "tone": "mid",
     "colours": [
      "mist grey",
      "dark green"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Patrick Fore",
     "page": "https://unsplash.com/photos/vpAKO21abME"
    },
    {
     "id": 965,
     "alt": "Long bridge disappearing into thick grey fog",
     "tone": "mid",
     "colours": [
      "mist grey",
      "slate",
      "pale green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Liane Metzler",
     "page": "https://unsplash.com/photos/c5uShPcKLAE"
    },
    {
     "id": 966,
     "alt": "Forested mountainside sloping diagonally beneath white fog",
     "tone": "light",
     "colours": [
      "white",
      "olive green",
      "brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alberto Restifo",
     "page": "https://unsplash.com/photos/kKrfPaKgE1c"
    },
    {
     "id": 991,
     "alt": "Hazy sun above layered dark hills in sepia tones",
     "tone": "mid",
     "colours": [
      "sepia",
      "dark brown",
      "pale gold"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Fritz Bielmeier",
     "page": "https://unsplash.com/photos/ooJi3CJQRa8"
    },
    {
     "id": 1028,
     "alt": "Bare dead trees standing in dry ground under sepia haze",
     "tone": "mid",
     "colours": [
      "sepia",
      "tan",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dikaseva",
     "page": "https://unsplash.com/photos/zvf7cZ0PC20"
    }
   ]
  },
  "coast": {
   "title": "Coast",
   "note": "sea, shoreline, cliffs, piers and beaches",
   "sectors": [
    "hospitality",
    "health",
    "real-estate",
    "restaurant",
    "event",
    "fashion",
    "other"
   ],
   "keywords": [
    "coast",
    "sea",
    "ocean",
    "beach",
    "surf",
    "island",
    "harbour",
    "harbor",
    "seaside",
    "sail",
    "boat",
    "fish",
    "marine",
    "cliff",
    "guesthouse",
    "shore",
    "bay",
    "atlantic",
    "lookout"
   ],
   "primary": [
    "hospitality"
   ],
   "photos": [
    {
     "id": 77,
     "alt": "Wooden jetty leading to a small pavilion over calm sea",
     "tone": "mid",
     "colours": [
      "warm tan",
      "pale teal",
      "soft grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.48,
     "by": "May Pamintuan",
     "page": "https://unsplash.com/photos/j9nfqTi5T5o"
    },
    {
     "id": 626,
     "alt": "Red and white lighthouse in turquoise sea below a chalk cliff",
     "tone": "light",
     "colours": [
      "turquoise",
      "white",
      "grass green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Nick Diamantidis",
     "page": "https://unsplash.com/photos/jkPKQWK1sKU"
    },
    {
     "id": 640,
     "alt": "Aerial view of turquoise surf breaking onto golden sand",
     "tone": "mid",
     "colours": [
      "sand",
      "turquoise",
      "white"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 5,
     "ratio": 1.5,
     "by": "Sarah Bürvenich",
     "page": "https://unsplash.com/photos/vigsqYux_-8"
    },
    {
     "id": 641,
     "alt": "Empty lifeguard chair on a beach at pastel dusk",
     "tone": "mid",
     "colours": [
      "peach",
      "slate blue",
      "dusty teal"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Fré Sonneveld",
     "page": "https://unsplash.com/photos/2qLT_Rq-2tk"
    },
    {
     "id": 853,
     "alt": "Lone rock in a calm blue sea beneath a pastel sky",
     "tone": "mid",
     "colours": [
      "sea blue",
      "peach",
      "pale pink"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.51,
     "by": "Stephen Radford",
     "page": "https://unsplash.com/photos/674bDNKmnIk"
    },
    {
     "id": 1049,
     "alt": "Rock island reflected on a wet beach under a pale sky",
     "tone": "light",
     "colours": [
      "pale grey",
      "soft white",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.25,
     "by": "Rosan Harmens",
     "page": "https://unsplash.com/photos/Sd8O2SgKDJA"
    },
    {
     "id": 1052,
     "alt": "Black sea stacks off a dark beach with white surf under a pale sky",
     "tone": "light",
     "colours": [
      "black",
      "white",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Annie Spratt",
     "page": "https://unsplash.com/photos/x8R2oSWZRSE"
    },
    {
     "id": 12,
     "alt": "Dark rocks on a sandy beach with calm sea and islands",
     "tone": "mid",
     "colours": [
      "sand beige",
      "dark brown",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/I_9ILwtsl_k"
    },
    {
     "id": 14,
     "alt": "Rocky shoreline meeting a calm pale sea under hazy sky",
     "tone": "mid",
     "colours": [
      "pale blue",
      "warm brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/IQ1kOQTJrOQ"
    },
    {
     "id": 16,
     "alt": "Blue sea and distant islands seen over a rocky driftwood shore",
     "tone": "mid",
     "colours": [
      "sky blue",
      "deep blue",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/gkT4FfgHO5o"
    },
    {
     "id": 27,
     "alt": "Person standing on a rocky shore watching misty waves at sunset",
     "tone": "mid",
     "colours": [
      "warm orange",
      "brown",
      "pale gold"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.78,
     "by": "Yoni Kaplan-Nadel",
     "page": "https://unsplash.com/photos/iJnZwLBOB1I"
    },
    {
     "id": 47,
     "alt": "Long wooden pier over misty surf in black and white",
     "tone": "light",
     "colours": [
      "light grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Christopher Sardegna",
     "page": "https://unsplash.com/photos/uDUiRS8YroY"
    },
    {
     "id": 68,
     "alt": "Curving pier with a railing, lamp post and weather vane",
     "tone": "light",
     "colours": [
      "light grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Cristian Moscoso",
     "page": "https://unsplash.com/photos/2SfRAWkinpU"
    },
    {
     "id": 108,
     "alt": "Palm trees silhouetted against a bright washed-out sky",
     "tone": "light",
     "colours": [
      "white",
      "soft pink",
      "dark brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Florian Klauer",
     "page": "https://unsplash.com/photos/t1mqA3V3-7g"
    },
    {
     "id": 144,
     "alt": "Pavilion at the end of a pier over waves, monochrome",
     "tone": "mid",
     "colours": [
      "grey",
      "black",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Mouly Kumar",
     "page": "https://unsplash.com/photos/TuOiIpkIea8"
    },
    {
     "id": 154,
     "alt": "Hazy beach at dusk with distant walkers and soft surf",
     "tone": "mid",
     "colours": [
      "dusty pink",
      "muted purple",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Christopher Sardegna",
     "page": "https://unsplash.com/photos/x_jTtMOOMd4"
    },
    {
     "id": 162,
     "alt": "Aerial view of a coastline with turquoise sea and patchwork fields",
     "tone": "mid",
     "colours": [
      "turquoise",
      "sand brown",
      "deep blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dillon McIntosh",
     "page": "https://unsplash.com/photos/SlGVAKyP20U"
    },
    {
     "id": 179,
     "alt": "Waves breaking around dark rocks on a misty shoreline",
     "tone": "mid",
     "colours": [
      "slate grey",
      "white",
      "warm brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Angelina Odemchuk",
     "page": "https://unsplash.com/photos/lp0IFw6YqZg"
    },
    {
     "id": 215,
     "alt": "Wide empty sandy beach with palm trees and blue sky",
     "tone": "light",
     "colours": [
      "pale sand",
      "sky blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.33,
     "by": "Paula Borowska",
     "page": "https://unsplash.com/photos/04paG2PxKGs"
    },
    {
     "id": 218,
     "alt": "Calm turquoise bay between two limestone cliffs under storm clouds",
     "tone": "mid",
     "colours": [
      "turquoise",
      "slate grey",
      "dark green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.5,
     "by": "Monika Majkowska",
     "page": "https://unsplash.com/photos/Nq8LdWC7HnM"
    },
    {
     "id": 268,
     "alt": "White lifeguard chair standing in dune grass under overcast sky",
     "tone": "light",
     "colours": [
      "white",
      "straw yellow",
      "green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.52,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/vc3ZOCf5BN8"
    },
    {
     "id": 317,
     "alt": "Long wooden pier with railings reaching toward a pastel horizon",
     "tone": "mid",
     "colours": [
      "weathered brown",
      "pale peach",
      "soft blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Paula Borowska",
     "page": "https://unsplash.com/photos/cKYgt4K0g5w"
    },
    {
     "id": 323,
     "alt": "Steep green headlands dropping into a deep blue bay",
     "tone": "mid",
     "colours": [
      "deep blue",
      "olive green",
      "tan"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paweł Wojciechowski",
     "page": "https://unsplash.com/photos/QYAojSRu82c"
    },
    {
     "id": 347,
     "alt": "Dune grass bending in wind beneath a wide white sky",
     "tone": "light",
     "colours": [
      "white",
      "straw gold",
      "grey blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alex Talmon",
     "page": "https://unsplash.com/photos/umq-jgTjr28"
    },
    {
     "id": 356,
     "alt": "Lone surfer walking along a misty beach in black and white",
     "tone": "mid",
     "colours": [
      "charcoal",
      "silver grey",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Daniel Ruswick",
     "page": "https://unsplash.com/photos/fqtOAqbbnJg"
    },
    {
     "id": 372,
     "alt": "Lifeguard tower on an empty sandy beach under a hazy sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "sand",
      "soft grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Gabriel Santiago",
     "page": "https://unsplash.com/photos/KWkMiUIxcD0"
    },
    {
     "id": 374,
     "alt": "Coastline with green headlands, a curving beach and turquoise sea",
     "tone": "mid",
     "colours": [
      "turquoise",
      "deep green",
      "sky blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ma. Alejandra",
     "page": "https://unsplash.com/photos/TDahcrcPhhA"
    },
    {
     "id": 404,
     "alt": "Waves breaking in white foam around a rugged brown rock",
     "tone": "mid",
     "colours": [
      "grey blue",
      "white",
      "rust brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Cole Patrick",
     "page": "https://unsplash.com/photos/H28ODcp4EUk"
    },
    {
     "id": 440,
     "alt": "Rugged coastal headlands in soft backlit haze at sunset",
     "tone": "mid",
     "colours": [
      "pale gold",
      "warm brown",
      "cream"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Artur Pokusin",
     "page": "https://unsplash.com/photos/0nv-h04oPhM"
    },
    {
     "id": 483,
     "alt": "Dark sea cliffs and rocks with white surf breaking below",
     "tone": "dark",
     "colours": [
      "charcoal",
      "teal",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Paulo Simões Mendes",
     "page": "https://unsplash.com/photos/hrcRtexM7M0"
    },
    {
     "id": 485,
     "alt": "White chalk cliffs above a curved beach in sea mist",
     "tone": "light",
     "colours": [
      "pale grey",
      "turquoise",
      "sand"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.51,
     "by": "David Marcu",
     "page": "https://unsplash.com/photos/DNXWtB33WWE"
    },
    {
     "id": 574,
     "alt": "Grassy cliff and black sand beach with sea stacks offshore",
     "tone": "mid",
     "colours": [
      "green",
      "charcoal",
      "pale blue"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Julien Lavallée",
     "page": "https://unsplash.com/photos/lVtlz3JRV6o"
    },
    {
     "id": 606,
     "alt": "Wooden boardwalk winding through grassy dunes toward the sea",
     "tone": "light",
     "colours": [
      "pale grey",
      "sage green",
      "sand"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Aleksandra Boguslawska",
     "page": "https://unsplash.com/photos/MS7KD9Ti7FQ"
    },
    {
     "id": 614,
     "alt": "Aerial view of white surf washing over a dark rocky shore",
     "tone": "dark",
     "colours": [
      "charcoal",
      "white",
      "dark brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Josefa Holland-Merten",
     "page": "https://unsplash.com/photos/GnP5Kfg5ebU"
    },
    {
     "id": 732,
     "alt": "Dark rocky shoreline beside a calm sea under a pale cloudy sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "soft grey",
      "charcoal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dominik Schröder",
     "page": "https://unsplash.com/photos/ivieBAESVWM"
    },
    {
     "id": 851,
     "alt": "Aerial view of turquoise sea meeting a curved reddish coastline",
     "tone": "mid",
     "colours": [
      "turquoise",
      "teal",
      "rust brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.66,
     "by": "Tiago Aguiar",
     "page": "https://unsplash.com/photos/uXzNVOZT5no"
    },
    {
     "id": 969,
     "alt": "Wet rocky shore with sea stacks under a bright sky, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "grey",
      "silver"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Lee Miller",
     "page": "https://unsplash.com/photos/_rsGm7nob3w"
    },
    {
     "id": 997,
     "alt": "Aerial view of a forested headland beside deep blue sea",
     "tone": "mid",
     "colours": [
      "deep blue",
      "forest green",
      "yellow green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.68,
     "by": "Mickey O'neil",
     "page": "https://unsplash.com/photos/GSzD6vGIWKM"
    },
    {
     "id": 1055,
     "alt": "Wide wet sand flat with a distant shoreline under a pale sky",
     "tone": "mid",
     "colours": [
      "warm brown",
      "sand",
      "off-white"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Neil Thomas",
     "page": "https://unsplash.com/photos/12rzbJhQ89E"
    }
   ]
  },
  "mountain": {
   "title": "Mountain",
   "note": "peaks, alpine valleys, snow and rock",
   "sectors": [
    "hospitality",
    "health",
    "event",
    "physical-product",
    "nonprofit",
    "other",
    "education"
   ],
   "keywords": [
    "mountain",
    "alpine",
    "peak",
    "summit",
    "hike",
    "hiking",
    "climb",
    "outdoor",
    "lodge",
    "chalet",
    "trail",
    "expedition"
   ],
   "photos": [
    {
     "id": 235,
     "alt": "Snow-covered peak rising above dark forested slopes and a lake",
     "tone": "mid",
     "colours": [
      "slate blue",
      "white",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Paul E. Harrer",
     "page": "https://unsplash.com/photos/TI-B-TNYJMU"
    },
    {
     "id": 260,
     "alt": "Snowy mountain peak framed by dark pine trees in mist",
     "tone": "light",
     "colours": [
      "white",
      "pale grey",
      "dark green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 5,
     "ratio": 0.67,
     "by": "Sylwia Bartyzel",
     "page": "https://unsplash.com/photos/OdAqbedkfiA"
    },
    {
     "id": 830,
     "alt": "Layered blue mountain ridges receding into haze",
     "tone": "mid",
     "colours": [
      "cobalt blue",
      "pale blue",
      "navy"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Luca Zanon",
     "page": "https://unsplash.com/photos/X0OoHrPvgXE"
    },
    {
     "id": 906,
     "alt": "Sunlit snowy pyramid peak against a clear blue sky",
     "tone": "mid",
     "colours": [
      "sky blue",
      "white",
      "dark slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.33,
     "by": "Andras Toth",
     "page": "https://unsplash.com/photos/E9CrQou9K0Y"
    },
    {
     "id": 931,
     "alt": "Layered mountain slopes fading into blue haze",
     "tone": "mid",
     "colours": [
      "steel blue",
      "pale blue",
      "dark teal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.51,
     "by": "Paul Earle",
     "page": "https://unsplash.com/photos/xJ2tjuUHD9M"
    },
    {
     "id": 1018,
     "alt": "Green cliffs and a winding road under low mist",
     "tone": "mid",
     "colours": [
      "moss green",
      "mist grey",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.33,
     "by": "Andrew Ridley",
     "page": "https://unsplash.com/photos/Kt5hRENuotI"
    },
    {
     "id": 29,
     "alt": "Rocky ridges and snow-covered peaks in a high mountain range",
     "tone": "mid",
     "colours": [
      "slate grey",
      "white",
      "blue"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Go Wild",
     "page": "https://unsplash.com/photos/V0yAek6BgGk"
    },
    {
     "id": 66,
     "alt": "Lone mountain peak beyond open grassland under heavy grey cloud",
     "tone": "mid",
     "colours": [
      "grey",
      "olive green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Nicholas Swanson",
     "page": "https://unsplash.com/photos/agnhLQWqr1Q"
    },
    {
     "id": 121,
     "alt": "Hazy mountain gorge with steep slopes in black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "black",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Radio Pink",
     "page": "https://unsplash.com/photos/p-bkdO43shE"
    },
    {
     "id": 166,
     "alt": "Moody valley with a river, dark hills and a foreground boulder",
     "tone": "dark",
     "colours": [
      "charcoal",
      "grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Romain Briaux",
     "page": "https://unsplash.com/photos/yD3PXDV7Sjc"
    },
    {
     "id": 296,
     "alt": "Snow-capped mountain range beneath a deep blue sky",
     "tone": "mid",
     "colours": [
      "deep blue",
      "white",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Greg Shield",
     "page": "https://unsplash.com/photos/Du30R57aCyM"
    },
    {
     "id": 327,
     "alt": "Snow-covered mountain range under a clear teal sky",
     "tone": "mid",
     "colours": [
      "teal",
      "white",
      "rust brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ryan Schroeder",
     "page": "https://unsplash.com/photos/Gg7uKdHFb_c"
    },
    {
     "id": 368,
     "alt": "Sheer granite cliffs above a hazy valley in warm evening light",
     "tone": "mid",
     "colours": [
      "peach",
      "warm grey",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Tyssul Patel",
     "page": "https://unsplash.com/photos/o-zOatT4kQw"
    },
    {
     "id": 377,
     "alt": "Snow-capped mountain range under a dark blue evening sky",
     "tone": "dark",
     "colours": [
      "navy",
      "slate blue",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Martin Staněk",
     "page": "https://unsplash.com/photos/8WClaa1CmZ0"
    },
    {
     "id": 393,
     "alt": "Green mountain streaked with snow rising above a dark gravel plain",
     "tone": "mid",
     "colours": [
      "moss green",
      "charcoal",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alex Talmon",
     "page": "https://unsplash.com/photos/UFskvng2OMU"
    },
    {
     "id": 426,
     "alt": "Layered blue mountains and a hazy valley under streaked clouds",
     "tone": "mid",
     "colours": [
      "slate blue",
      "teal",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ales Krivec",
     "page": "https://unsplash.com/photos/01ZeHnK3F_4"
    },
    {
     "id": 450,
     "alt": "Turquoise lake in a forested valley between snow-dusted mountains",
     "tone": "mid",
     "colours": [
      "turquoise",
      "slate grey",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Tanvi Malik",
     "page": "https://unsplash.com/photos/OeC1wIsKNpk"
    },
    {
     "id": 523,
     "alt": "Volcanic crater rising from a hazy plain beneath a teal sky",
     "tone": "mid",
     "colours": [
      "teal",
      "tan",
      "brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 2.21,
     "by": "Harman Abiwardani",
     "page": "https://unsplash.com/photos/WLUHO9A_xik"
    },
    {
     "id": 538,
     "alt": "Bird soaring above snowy mountain ridges in black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "white",
      "black"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Julia Revitt",
     "page": "https://unsplash.com/photos/yypv2Tu-mxU"
    },
    {
     "id": 565,
     "alt": "Snowy peak veiled in cloud with birds crossing a white sky",
     "tone": "light",
     "colours": [
      "white",
      "pale grey",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "René Reichelt",
     "page": "https://unsplash.com/photos/HzaT5l4Fzqc"
    },
    {
     "id": 591,
     "alt": "Jagged rock pinnacle rising through swirling white cloud",
     "tone": "light",
     "colours": [
      "white",
      "grey",
      "tan"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 0.75,
     "by": "Billy Onjea",
     "page": "https://unsplash.com/photos/_qGq1Z2Bk6c"
    },
    {
     "id": 684,
     "alt": "Jagged snow-covered peaks under a dark blue sky",
     "tone": "mid",
     "colours": [
      "navy",
      "white",
      "slate blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Lee Roylland",
     "page": "https://unsplash.com/photos/dfZbts6B4yw"
    },
    {
     "id": 735,
     "alt": "Steep dark cliffs with fog drifting through the gap between them",
     "tone": "mid",
     "colours": [
      "charcoal",
      "mist grey",
      "deep green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 0.62,
     "by": "wyman H",
     "page": "https://unsplash.com/photos/ZQeRH1XD4Tc"
    },
    {
     "id": 809,
     "alt": "Rocky mountain peak breaking through thick white clouds",
     "tone": "light",
     "colours": [
      "white",
      "slate grey",
      "blue grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Namphuong Van",
     "page": "https://unsplash.com/photos/sP4UZnpSiN4"
    },
    {
     "id": 814,
     "alt": "Jagged rocky peaks under a soft pink and violet dusk sky",
     "tone": "mid",
     "colours": [
      "dusty violet",
      "soft pink",
      "slate grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.53,
     "by": "Ales Krivec",
     "page": "https://unsplash.com/photos/duoMdMm3aY0"
    },
    {
     "id": 827,
     "alt": "Conical mountain silhouette above a still lake under a starry dusk sky",
     "tone": "dark",
     "colours": [
      "indigo",
      "dusky violet",
      "navy"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.67,
     "by": "kazuend",
     "page": "https://unsplash.com/photos/2KXEb_8G5vo"
    },
    {
     "id": 866,
     "alt": "Snowy mountain peak behind a snow slope under pink-tinted clouds",
     "tone": "mid",
     "colours": [
      "dusky blue",
      "soft pink",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Samuel Zeller",
     "page": "https://unsplash.com/photos/9RqA6tnT0gA"
    },
    {
     "id": 873,
     "alt": "Turquoise lake between steep rocky mountains with distant snow",
     "tone": "mid",
     "colours": [
      "teal",
      "slate grey",
      "pale cream"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.67,
     "by": "Daniel Roe",
     "page": "https://unsplash.com/photos/lpjb_UMOyx8"
    },
    {
     "id": 918,
     "alt": "Rocky peak wrapped in sunlit cloud above a forested valley",
     "tone": "mid",
     "colours": [
      "warm gold",
      "dark green",
      "grey brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "David Marcu",
     "page": "https://unsplash.com/photos/CaQ_KITtnVY"
    },
    {
     "id": 929,
     "alt": "Green cliff face rising out of dense white mist",
     "tone": "light",
     "colours": [
      "white",
      "moss green",
      "grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Tiago Muraro",
     "page": "https://unsplash.com/photos/1kEHpwZdmtc"
    }
   ]
  },
  "forest": {
   "title": "Forest",
   "note": "woodland, paths between trees, moss and filtered light",
   "sectors": [
    "hospitality",
    "health",
    "nonprofit",
    "education",
    "physical-product",
    "other"
   ],
   "keywords": [
    "forest",
    "wood",
    "tree",
    "cabin",
    "nature",
    "green",
    "organic",
    "sustainable",
    "timber",
    "garden"
   ],
   "photos": [
    {
     "id": 229,
     "alt": "Path through tall pine trees fading into blue-grey fog",
     "tone": "dark",
     "colours": [
      "blue grey",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 1.5,
     "by": "Orlova Maria",
     "page": "https://unsplash.com/photos/7FLh300YONc"
    },
    {
     "id": 10,
     "alt": "Dense evergreen forest with a blue lake and distant hills beyond",
     "tone": "mid",
     "colours": [
      "deep green",
      "teal blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/6J--NXulQCs"
    },
    {
     "id": 190,
     "alt": "Path winding between rows of tall pine trunks",
     "tone": "dark",
     "colours": [
      "dark brown",
      "moss green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "James Forbes",
     "page": "https://unsplash.com/photos/jrzvClypPq8"
    },
    {
     "id": 216,
     "alt": "Narrow dirt trail through mossy green woodland",
     "tone": "mid",
     "colours": [
      "moss green",
      "brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Paul Jarvis",
     "page": "https://unsplash.com/photos/9702xTENR-M"
    },
    {
     "id": 243,
     "alt": "Autumn larch forest on a foggy hillside with a footpath",
     "tone": "mid",
     "colours": [
      "rust brown",
      "pale grey",
      "olive green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Orlova Maria",
     "page": "https://unsplash.com/photos/DYW1s0AfLwo"
    },
    {
     "id": 423,
     "alt": "Snow-laden fir trees fading into pale winter fog",
     "tone": "light",
     "colours": [
      "white",
      "pale blue",
      "grey green"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.5,
     "by": "Gabriel Santiago",
     "page": "https://unsplash.com/photos/pTgTMVxQE_k"
    },
    {
     "id": 476,
     "alt": "Sunlight bursting between tall redwood trunks in a shaded forest",
     "tone": "dark",
     "colours": [
      "dark brown",
      "deep green",
      "gold"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Mr. Marco",
     "page": "https://unsplash.com/photos/QP1dUyQ8WsI"
    },
    {
     "id": 498,
     "alt": "Upward view of towering redwood trunks converging toward the canopy",
     "tone": "mid",
     "colours": [
      "green",
      "reddish brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Kim Daniel",
     "page": "https://unsplash.com/photos/JsqAqevX6lg"
    },
    {
     "id": 559,
     "alt": "Snow-covered lane lined with bare frosted trees",
     "tone": "light",
     "colours": [
      "white",
      "grey brown"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.51,
     "by": "Ali Inay",
     "page": "https://unsplash.com/photos/yNaGxHqjOuw"
    },
    {
     "id": 600,
     "alt": "Sunbeams streaming through tall conifers in a dark forest",
     "tone": "dark",
     "colours": [
      "dark green",
      "pale gold",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Dustin Scarpitti",
     "page": "https://unsplash.com/photos/eWBseWsTEpA"
    },
    {
     "id": 704,
     "alt": "Upward view of tall bamboo stems converging in a green canopy",
     "tone": "dark",
     "colours": [
      "dark green",
      "green",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ståle Grut",
     "page": "https://unsplash.com/photos/NUgw97CVdAk"
    },
    {
     "id": 730,
     "alt": "Snow-dusted conifer forest fading into pale winter fog",
     "tone": "light",
     "colours": [
      "frost white",
      "pale grey",
      "slate green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ryan Pohanic",
     "page": "https://unsplash.com/photos/W3V2tQAZxPA"
    },
    {
     "id": 805,
     "alt": "Band of dark conifer forest between a meadow and a blank white sky",
     "tone": "mid",
     "colours": [
      "white",
      "dark green",
      "olive brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.49,
     "by": "Jens Lelie",
     "page": "https://unsplash.com/photos/GUneOBRhq9w"
    },
    {
     "id": 877,
     "alt": "Tall tree trunks in a misty forest with blue-grey light",
     "tone": "mid",
     "colours": [
      "slate blue",
      "dark brown",
      "mist grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.66,
     "by": "Sebastian Unrau",
     "page": "https://unsplash.com/photos/dJVU4jXV1Q8"
    },
    {
     "id": 878,
     "alt": "Bare gnarled trees in fog, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "grey",
      "white"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.51,
     "by": "Richard Loader",
     "page": "https://unsplash.com/photos/qsEJGX4VqYU"
    },
    {
     "id": 879,
     "alt": "Aerial view of a narrow path cutting through dense dark forest",
     "tone": "dark",
     "colours": [
      "deep green",
      "dark teal",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Nick Scheerbart",
     "page": "https://unsplash.com/photos/xFjAftU8lMY"
    },
    {
     "id": 919,
     "alt": "Tall straight pine trunks above a bright green forest floor",
     "tone": "mid",
     "colours": [
      "fresh green",
      "red brown",
      "dark brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jon Ottosson",
     "page": "https://unsplash.com/photos/JU2MgHOHDsw"
    },
    {
     "id": 923,
     "alt": "Misty autumn forest path covered in red fallen leaves",
     "tone": "mid",
     "colours": [
      "rust red",
      "soft pink",
      "mist grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.71,
     "by": "Sebastian Unrau",
     "page": "https://unsplash.com/photos/CoD2Q92UaEg"
    },
    {
     "id": 924,
     "alt": "Aerial view of autumn forest with orange and dark green treetops",
     "tone": "dark",
     "colours": [
      "burnt orange",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Jakub Sejkora",
     "page": "https://unsplash.com/photos/utqJcneoFjo"
    },
    {
     "id": 932,
     "alt": "Low clouds drifting over an autumn conifer forest",
     "tone": "mid",
     "colours": [
      "dark green",
      "rust orange",
      "mist white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Vadim Sherbakov",
     "page": "https://unsplash.com/photos/NQSWvyVRIJk"
    },
    {
     "id": 944,
     "alt": "Wooden footbridge with railings leading into lush green forest",
     "tone": "dark",
     "colours": [
      "deep green",
      "warm brown",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Tim Swaan",
     "page": "https://unsplash.com/photos/eOpewngf68w"
    }
   ]
  },
  "water": {
   "title": "Water",
   "note": "lakes, rivers, waterfalls and water surfaces",
   "sectors": [
    "health",
    "hospitality",
    "nonprofit",
    "finance",
    "ai-product",
    "other"
   ],
   "keywords": [
    "water",
    "lake",
    "river",
    "waterfall",
    "flow",
    "liquid",
    "stream",
    "swim",
    "pool"
   ],
   "photos": [
    {
     "id": 124,
     "alt": "Small red boat floating on clear turquoise water, seen from above",
     "tone": "mid",
     "colours": [
      "turquoise",
      "teal",
      "red"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Anton Sulsky",
     "page": "https://unsplash.com/photos/fj0tFloTPGQ"
    },
    {
     "id": 293,
     "alt": "Silhouetted pier on a hazy blue lake below faint mountains",
     "tone": "mid",
     "colours": [
      "pale blue",
      "navy"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Paul E. Harrer",
     "page": "https://unsplash.com/photos/AJqHzUbc1io"
    },
    {
     "id": 469,
     "alt": "Two people paddling a canoe across a misty green lake",
     "tone": "mid",
     "colours": [
      "emerald green",
      "pale grey",
      "teal"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 5,
     "ratio": 1.5,
     "by": "Matthew Clark",
     "page": "https://unsplash.com/photos/_IMinUHfUs0"
    },
    {
     "id": 1038,
     "alt": "Small red boat on a wide expanse of dark blue rippling sea",
     "tone": "dark",
     "colours": [
      "deep blue",
      "slate",
      "red"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 5,
     "ratio": 0.67,
     "by": "Marcelo Quinan",
     "page": "https://unsplash.com/photos/upywS5QFwr8"
    },
    {
     "id": 74,
     "alt": "Small boat on a bright blue bay with a distant city skyline",
     "tone": "mid",
     "colours": [
      "bright blue",
      "sky blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Isaak Dury",
     "page": "https://unsplash.com/photos/YhZbnxqtooM"
    },
    {
     "id": 87,
     "alt": "Wooden bench on a timber deck beside rippling water",
     "tone": "mid",
     "colours": [
      "grey brown",
      "slate grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.33,
     "by": "Barcelona",
     "page": "https://unsplash.com/photos/o697BgRH_-M"
    },
    {
     "id": 384,
     "alt": "Close view of a rippling deep blue water surface",
     "tone": "mid",
     "colours": [
      "deep blue",
      "teal"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Griffin Keller",
     "page": "https://unsplash.com/photos/-mNvCsNlsSE"
    },
    {
     "id": 434,
     "alt": "Wooden jetty leading into a green alpine lake ringed by forest",
     "tone": "mid",
     "colours": [
      "forest green",
      "grey wood",
      "slate blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.51,
     "by": "Ales Krivec",
     "page": "https://unsplash.com/photos/DgtRKZOOE0w"
    },
    {
     "id": 477,
     "alt": "Lone tree standing in a blue lake with mountains and clouds behind",
     "tone": "mid",
     "colours": [
      "deep blue",
      "white",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Jasper van der Meij",
     "page": "https://unsplash.com/photos/Xo3uIN_Q1Y0"
    },
    {
     "id": 509,
     "alt": "Tall waterfall pouring over a green mossy cliff into mist",
     "tone": "mid",
     "colours": [
      "green",
      "white",
      "dark grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Jeff Sheldon",
     "page": "https://unsplash.com/photos/SdSc4sWVMRU"
    },
    {
     "id": 536,
     "alt": "Seaside swimming pool at night with a wave breaking over its edge",
     "tone": "dark",
     "colours": [
      "black",
      "teal",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "David Di Veroli",
     "page": "https://unsplash.com/photos/4uCSqP5OKiI"
    },
    {
     "id": 637,
     "alt": "Metal ladder at the edge of a wooden dock over still water at dusk",
     "tone": "mid",
     "colours": [
      "pale blue",
      "peach",
      "dark brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Griffin Keller",
     "page": "https://unsplash.com/photos/leHDA08Lbpc"
    },
    {
     "id": 700,
     "alt": "Bird skimming over deep blue water leaving a trail of ripples",
     "tone": "dark",
     "colours": [
      "deep blue",
      "navy"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "John Cobb",
     "page": "https://unsplash.com/photos/6btEyS3AJrI"
    },
    {
     "id": 716,
     "alt": "Deep blue open sea with small rolling waves",
     "tone": "mid",
     "colours": [
      "deep blue",
      "teal"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Kosloski",
     "page": "https://unsplash.com/photos/BT_BUEwjeQg"
    },
    {
     "id": 774,
     "alt": "Deep blue twilight sky over a still, flat water horizon",
     "tone": "dark",
     "colours": [
      "cobalt blue",
      "navy"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alex Wigan",
     "page": "https://unsplash.com/photos/5qlegaTwZpM"
    },
    {
     "id": 846,
     "alt": "Inside the curl of a breaking teal wave",
     "tone": "mid",
     "colours": [
      "teal",
      "sea green",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Jeremy Bishop",
     "page": "https://unsplash.com/photos/l61smgU3Y7w"
    },
    {
     "id": 909,
     "alt": "Smooth blue ocean swell rising in close-up",
     "tone": "mid",
     "colours": [
      "deep blue",
      "navy",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Austin Schmid",
     "page": "https://unsplash.com/photos/5Dga0T0x6GY"
    },
    {
     "id": 912,
     "alt": "Churning blue sea water streaked with white foam",
     "tone": "mid",
     "colours": [
      "cobalt blue",
      "white",
      "navy"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Clem Onojeghuo",
     "page": "https://unsplash.com/photos/-YMhg0KYgVc"
    },
    {
     "id": 916,
     "alt": "Turquoise lake winding between forested mountains in soft evening light",
     "tone": "mid",
     "colours": [
      "turquoise",
      "forest green",
      "peach"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 2.02,
     "by": "Larry Chen",
     "page": "https://unsplash.com/photos/Nte-4RiRfwU"
    },
    {
     "id": 928,
     "alt": "Tall waterfall with a tiny figure at its base, black and white",
     "tone": "mid",
     "colours": [
      "white",
      "grey",
      "black"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jose Murillo",
     "page": "https://unsplash.com/photos/7x4dOkulU9E"
    },
    {
     "id": 1039,
     "alt": "Waterfall pouring into a lush mossy green gorge",
     "tone": "mid",
     "colours": [
      "vivid green",
      "dark green",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Andrew Coelho",
     "page": "https://unsplash.com/photos/VB-w_3dnyvI"
    },
    {
     "id": 1044,
     "alt": "Rocky river flowing through a misty mountain forest",
     "tone": "mid",
     "colours": [
      "grey green",
      "mist grey",
      "teal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Steve Carter",
     "page": "https://unsplash.com/photos/Ixp4YhCKZkI"
    },
    {
     "id": 1051,
     "alt": "Weathered wooden jetty reaching into a misty mountain lake",
     "tone": "mid",
     "colours": [
      "pale blue",
      "weathered grey",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Ales Krivec",
     "page": "https://unsplash.com/photos/HkTMcmlMOUQ"
    }
   ]
  },
  "desert-and-road": {
   "title": "Desert and road",
   "note": "dunes, dry land and open roads",
   "sectors": [
    "hospitality",
    "event",
    "physical-product",
    "consumer-app",
    "other",
    "fashion"
   ],
   "keywords": [
    "desert",
    "road",
    "journey",
    "travel",
    "route",
    "drive",
    "dune",
    "trip",
    "logistic",
    "delivery",
    "freight"
   ],
   "photos": [
    {
     "id": 261,
     "alt": "Sharp sand dune ridge dividing sunlit and shadowed slopes",
     "tone": "mid",
     "colours": [
      "golden sand",
      "deep indigo"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.33,
     "by": "Fabio Rose",
     "page": "https://unsplash.com/photos/HJSIZsC4te8"
    },
    {
     "id": 314,
     "alt": "Empty two-lane road curving through pale sand dunes",
     "tone": "mid",
     "colours": [
      "sand beige",
      "asphalt grey",
      "teal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Fré Sonneveld",
     "page": "https://unsplash.com/photos/1rZcfdsjoR4"
    },
    {
     "id": 525,
     "alt": "Orange sand dune with tiny figures on its ridge under blue sky",
     "tone": "mid",
     "colours": [
      "burnt orange",
      "sky blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Luca Zanon",
     "page": "https://unsplash.com/photos/aViOQZzikVs"
    },
    {
     "id": 563,
     "alt": "Straight road with a white centre line leading toward dark conical mountains",
     "tone": "mid",
     "colours": [
      "charcoal",
      "olive green",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.49,
     "by": "Bec Brown",
     "page": "https://unsplash.com/photos/7RIm0GqvvkM"
    },
    {
     "id": 964,
     "alt": "Empty road curving through a snow-covered landscape",
     "tone": "light",
     "colours": [
      "white",
      "asphalt grey",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.43,
     "by": "Jon Ottosson",
     "page": "https://unsplash.com/photos/YD1uvthZwg4"
    },
    {
     "id": 46,
     "alt": "Rolling golden-brown hills stretching to the horizon under blue sky",
     "tone": "mid",
     "colours": [
      "golden brown",
      "sky blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Jeffrey Kam",
     "page": "https://unsplash.com/photos/Nzw3HHsNHYU"
    },
    {
     "id": 141,
     "alt": "Smooth golden hills curving beneath a clear slate-blue sky",
     "tone": "mid",
     "colours": [
      "slate blue",
      "golden tan"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Greg Shield",
     "page": "https://unsplash.com/photos/v9eNihIWh8k"
    },
    {
     "id": 184,
     "alt": "Sand dune ridges catching the last light under a dark sky",
     "tone": "dark",
     "colours": [
      "deep purple",
      "burnt orange"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Tim de Groot",
     "page": "https://unsplash.com/photos/yNGQ830uFB4"
    },
    {
     "id": 185,
     "alt": "Flat empty desert plain fading into a warm hazy horizon",
     "tone": "mid",
     "colours": [
      "burnt orange",
      "pale gold"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Tim de Groot",
     "page": "https://unsplash.com/photos/M_eB1UjE0do"
    },
    {
     "id": 196,
     "alt": "Footprints crossing pale sand dunes toward a distant vehicle",
     "tone": "light",
     "colours": [
      "pale sand",
      "warm beige"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Dyaa Eldin Moustafa",
     "page": "https://unsplash.com/photos/mR_HR8NZwg8"
    },
    {
     "id": 379,
     "alt": "Empty asphalt road running through a brown moorland valley under overcast sky",
     "tone": "mid",
     "colours": [
      "warm grey",
      "brown",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Kamil Lehmann",
     "page": "https://unsplash.com/photos/2f5Ktwb8YXk"
    },
    {
     "id": 551,
     "alt": "Car driving along a dusty dirt road into a golden sunset",
     "tone": "dark",
     "colours": [
      "gold",
      "dark brown",
      "amber"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Forrest Cavale",
     "page": "https://unsplash.com/photos/29SqSdfvN_A"
    },
    {
     "id": 650,
     "alt": "Road curving past dark pine forest into fog",
     "tone": "mid",
     "colours": [
      "grey",
      "dark green",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Lou Levit",
     "page": "https://unsplash.com/photos/_6xC5v5fLpU"
    },
    {
     "id": 767,
     "alt": "Straight empty road running through a tall pine forest",
     "tone": "mid",
     "colours": [
      "forest green",
      "asphalt grey",
      "warm brown"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sébastien Marchand",
     "page": "https://unsplash.com/photos/EeEx8zpOESA"
    },
    {
     "id": 791,
     "alt": "Hot air balloons drifting over a rocky valley at sunrise",
     "tone": "mid",
     "colours": [
      "golden yellow",
      "amber",
      "dusky purple"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Daniela Cuevas",
     "page": "https://unsplash.com/photos/t7YycgAoVSw"
    }
   ]
  },
  "field-and-farm": {
   "title": "Field and farm",
   "note": "fields, grassland and rural land",
   "sectors": [
    "restaurant",
    "nonprofit",
    "education",
    "health",
    "physical-product",
    "ecommerce",
    "other"
   ],
   "keywords": [
    "farm",
    "field",
    "rural",
    "harvest",
    "grain",
    "wheat",
    "agric",
    "organic",
    "countryside",
    "vineyard",
    "wine"
   ],
   "photos": [
    {
     "id": 808,
     "alt": "Ploughed brown soil beside a green field under a pale sky",
     "tone": "light",
     "colours": [
      "earth brown",
      "grass green",
      "off-white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.33,
     "by": "elizabeth lies",
     "page": "https://unsplash.com/photos/YbgPWfWlvkE"
    },
    {
     "id": 75,
     "alt": "Cluster of dark blue grapes hanging on a vine with leaves",
     "tone": "mid",
     "colours": [
      "dark blue",
      "green",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.67,
     "by": "Jassy Onyae",
     "page": "https://unsplash.com/photos/1gBUXhf0PtA"
    },
    {
     "id": 85,
     "alt": "Tractor pulling a trailer of hay bales across a golden field",
     "tone": "mid",
     "colours": [
      "golden yellow",
      "sky blue",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.65,
     "by": "Gozha Net",
     "page": "https://unsplash.com/photos/xDrxJCdedcI"
    },
    {
     "id": 107,
     "alt": "Close view of green wheat ears beneath a white sky",
     "tone": "mid",
     "colours": [
      "fresh green",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Lukas Schweizer",
     "page": "https://unsplash.com/photos/9VWOr22LhVI"
    },
    {
     "id": 112,
     "alt": "Golden grass heads bending before a dark green treeline",
     "tone": "mid",
     "colours": [
      "golden tan",
      "dark green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Zugr",
     "page": "https://unsplash.com/photos/kmF_Aq8gkp0"
    },
    {
     "id": 187,
     "alt": "Frost-covered field with dark hedgerows at first light",
     "tone": "mid",
     "colours": [
      "frost grey",
      "dark green",
      "pale gold"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.5,
     "by": "Andre Koch",
     "page": "https://unsplash.com/photos/oSf8ePoG9NU"
    },
    {
     "id": 337,
     "alt": "Golden wheat field beneath dark trees and a cloudy sky",
     "tone": "mid",
     "colours": [
      "golden yellow",
      "dark green",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Cornelia Büchse",
     "page": "https://unsplash.com/photos/7eu-KaAJwks"
    },
    {
     "id": 387,
     "alt": "Pale track winding through flat fields under a deep blue sky",
     "tone": "mid",
     "colours": [
      "deep blue",
      "tan",
      "olive"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Martin Dörsch",
     "page": "https://unsplash.com/photos/8ZgJyLGbC7Y"
    },
    {
     "id": 542,
     "alt": "Flat green pasture beneath a heavy grey storm sky",
     "tone": "mid",
     "colours": [
      "grass green",
      "slate grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Crucifix Jean-Luc",
     "page": "https://unsplash.com/photos/19tQv51x4-A"
    },
    {
     "id": 699,
     "alt": "Shepherd with a flock of sheep in rolling green wooded hills",
     "tone": "mid",
     "colours": [
      "green",
      "olive",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Biegun Wschodni",
     "page": "https://unsplash.com/photos/vD3L-rN_qNw"
    },
    {
     "id": 915,
     "alt": "Rows of tea bushes covering a green plantation hillside",
     "tone": "mid",
     "colours": [
      "tea green",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 2.05,
     "by": "McDobbie Hu",
     "page": "https://unsplash.com/photos/24tsXm7qGQE"
    },
    {
     "id": 955,
     "alt": "Aerial view of diagonal crop rows in green and gold",
     "tone": "mid",
     "colours": [
      "olive green",
      "straw gold",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.49,
     "by": "Matt Benson",
     "page": "https://unsplash.com/photos/rHbob_bEsSs"
    }
   ]
  },
  "sky-and-space": {
   "title": "Sky and space",
   "note": "skies, clouds, stars and night skies",
   "sectors": [
    "ai-product",
    "saas",
    "dev-tool",
    "finance",
    "event",
    "music",
    "culture",
    "other",
    "education"
   ],
   "keywords": [
    "sky",
    "cloud",
    "star",
    "space",
    "night",
    "astro",
    "weather",
    "flight",
    "air"
   ],
   "photos": [
    {
     "id": 505,
     "alt": "Tiny silhouetted figure on a dark ridge under a blue and orange twilight sky",
     "tone": "dark",
     "colours": [
      "deep blue",
      "orange",
      "black"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "lee Scott",
     "page": "https://unsplash.com/photos/_VTgctRg0tA"
    },
    {
     "id": 38,
     "alt": "White cumulus cloud rising against a dark teal sky",
     "tone": "mid",
     "colours": [
      "dark teal",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Allyson Souza",
     "page": "https://unsplash.com/photos/JabLtzJl8bc"
    },
    {
     "id": 114,
     "alt": "Dark storm clouds hanging over a faint mountain ridge",
     "tone": "dark",
     "colours": [
      "dark teal",
      "muted beige"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.33,
     "by": "Brian Gonzalez",
     "page": "https://unsplash.com/photos/llYg8Ni43fc"
    },
    {
     "id": 427,
     "alt": "Small palm silhouettes along the horizon under a peach dusk sky",
     "tone": "mid",
     "colours": [
      "peach",
      "dusty mauve",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "James Douglas",
     "page": "https://unsplash.com/photos/LJb0yZ7UD7o"
    },
    {
     "id": 607,
     "alt": "Soft streaked clouds in a blue sky above a calm sea",
     "tone": "mid",
     "colours": [
      "sky blue",
      "white",
      "teal"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 0.67,
     "by": "Grégoire Hervé-Bazin",
     "page": "https://unsplash.com/photos/_LLcIlMoUaE"
    },
    {
     "id": 654,
     "alt": "Starry dusk sky glowing orange above silhouetted trees",
     "tone": "dark",
     "colours": [
      "deep purple",
      "burnt orange",
      "black"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Josh Felise",
     "page": "https://unsplash.com/photos/trvELSvNZoY"
    },
    {
     "id": 721,
     "alt": "Clear twilight sky fading from deep blue to an orange horizon",
     "tone": "dark",
     "colours": [
      "navy",
      "orange",
      "dusty pink"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jeremy Ricketts",
     "page": "https://unsplash.com/photos/cGe1PV_yEso"
    },
    {
     "id": 724,
     "alt": "Green and red aurora glowing above silhouetted pine trees",
     "tone": "dark",
     "colours": [
      "green",
      "magenta",
      "black"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Nelly Volkovich",
     "page": "https://unsplash.com/photos/ZSMgNjYrHRM"
    },
    {
     "id": 765,
     "alt": "Night sky fading to an orange glow along a dark horizon",
     "tone": "dark",
     "colours": [
      "midnight blue",
      "burnt orange",
      "black"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Grant McIver",
     "page": "https://unsplash.com/photos/pmUEwPKL5IE"
    },
    {
     "id": 833,
     "alt": "Tiny aeroplane crossing a wide, softly overcast sky",
     "tone": "light",
     "colours": [
      "warm grey",
      "off-white"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 0.67,
     "by": "Paul Green",
     "page": "https://unsplash.com/photos/OIbwHaG9NC8"
    },
    {
     "id": 847,
     "alt": "A few birds flying across a pale blue gradient sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "soft white",
      "indigo"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Andrej Chudy",
     "page": "https://unsplash.com/photos/uivWDK2Ifrg"
    },
    {
     "id": 869,
     "alt": "Starry night sky above a mountain glowing warm at its peak",
     "tone": "dark",
     "colours": [
      "navy",
      "deep blue",
      "amber"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Caleb Ralston",
     "page": "https://unsplash.com/photos/6hxvm0NzYP8"
    },
    {
     "id": 901,
     "alt": "Green aurora sweeping across the night sky above snowy ground",
     "tone": "dark",
     "colours": [
      "emerald green",
      "dark teal",
      "slate"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1,
     "by": "Marcelo Quinan",
     "page": "https://unsplash.com/photos/R3pUGn5YiTg"
    },
    {
     "id": 967,
     "alt": "Earth's curved horizon with green aurora seen from an orbiting spacecraft",
     "tone": "dark",
     "colours": [
      "emerald green",
      "violet",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "NASA",
     "page": "https://unsplash.com/photos/NuE8Nu3otjo"
    },
    {
     "id": 974,
     "alt": "Person standing beneath the Milky Way with a warm glow on the horizon",
     "tone": "dark",
     "colours": [
      "deep navy",
      "violet",
      "amber"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.93,
     "by": "Greg Rakozy",
     "page": "https://unsplash.com/photos/0LU4vO5iFpM"
    }
   ]
  },
  "architecture": {
   "title": "Architecture",
   "note": "structure, lines, bridges, facades and building interiors",
   "sectors": [
    "architecture",
    "real-estate",
    "agency",
    "studio-portfolio",
    "personal-portfolio",
    "saas",
    "dev-tool",
    "ai-product",
    "finance",
    "culture",
    "event",
    "education",
    "other"
   ],
   "keywords": [
    "architect",
    "building",
    "structure",
    "bridge",
    "tower",
    "construction",
    "office",
    "estate",
    "property",
    "interior",
    "museum",
    "gallery",
    "engineer",
    "infrastructure",
    "conference",
    "venue",
    "silo",
    "hall"
   ],
   "primary": [
    "architecture",
    "real-estate"
   ],
   "photos": [
    {
     "id": 508,
     "alt": "Tall pointed vaults and pale columns of a church interior",
     "tone": "light",
     "colours": [
      "cream",
      "warm grey",
      "soft orange"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 0.76,
     "by": "Jeff Sheldon",
     "page": "https://unsplash.com/photos/u3gES0SUsnI"
    },
    {
     "id": 618,
     "alt": "Curving glass and steel lattice roof in black and white",
     "tone": "light",
     "colours": [
      "white",
      "grey",
      "black"
     ],
     "mono": true,
     "calm": "none",
     "quality": 5,
     "ratio": 1.5,
     "by": "Mika Ruusunen",
     "page": "https://unsplash.com/photos/ypVM8PnygUo"
    },
    {
     "id": 620,
     "alt": "White arched bridge spanning grey water under a pale sky",
     "tone": "light",
     "colours": [
      "white",
      "pale grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "bottom",
     "quality": 5,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/HeVd38MWnw4"
    },
    {
     "id": 693,
     "alt": "Red suspension bridge tower rising from fog above rugged cliffs",
     "tone": "mid",
     "colours": [
      "rust red",
      "grey",
      "dark teal"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 2.15,
     "by": "Rob Bye",
     "page": "https://unsplash.com/photos/bopC0sTGu-E"
    },
    {
     "id": 855,
     "alt": "Small figure walking past a huge bright yellow wall",
     "tone": "light",
     "colours": [
      "bright yellow",
      "black"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Rodion Kutsaev",
     "page": "https://unsplash.com/photos/IJ25m7fXqtk"
    },
    {
     "id": 887,
     "alt": "White apartment block facade with repeating balconies and windows",
     "tone": "light",
     "colours": [
      "white",
      "pale grey",
      "soft red"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 1.5,
     "by": "Roman Kraft",
     "page": "https://unsplash.com/photos/7sMHTVJ0OTA"
    },
    {
     "id": 942,
     "alt": "White angular concrete building against a black sky",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "pale grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Carl Nenzen Loven",
     "page": "https://unsplash.com/photos/busqfOj6i80"
    },
    {
     "id": 954,
     "alt": "Narrow opening in a white wall revealing a white staircase",
     "tone": "light",
     "colours": [
      "off-white",
      "pale beige"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.52,
     "by": "Todd Quackenbush",
     "page": "https://unsplash.com/photos/JJB_K8aCPU4"
    },
    {
     "id": 1081,
     "alt": "Stepped white modern building corner rising into a pale blue sky",
     "tone": "light",
     "colours": [
      "white",
      "pale blue",
      "soft grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.49,
     "by": "Julien Moreau",
     "page": "https://unsplash.com/photos/688Fna1pwOQ"
    },
    {
     "id": 32,
     "alt": "Empty bench against a weathered white wall with a lamp above",
     "tone": "light",
     "colours": [
      "white",
      "light grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.33,
     "by": "Rodrigo Melo",
     "page": "https://unsplash.com/photos/eG3k60PrTGY"
    },
    {
     "id": 76,
     "alt": "Green bicycle leaning against a weathered wooden shed with blue door",
     "tone": "mid",
     "colours": [
      "weathered brown",
      "teal blue",
      "green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alexander Shustov",
     "page": "https://unsplash.com/photos/OxzhYtL-00Y"
    },
    {
     "id": 78,
     "alt": "Carved wooden double door set in an aged stone facade",
     "tone": "mid",
     "colours": [
      "honey brown",
      "stone beige"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.67,
     "by": "Paul Evans",
     "page": "https://unsplash.com/photos/CtkDsu4w-Rs"
    },
    {
     "id": 84,
     "alt": "Illuminated suspension bridge seen from below under a stormy night sky",
     "tone": "dark",
     "colours": [
      "deep blue",
      "warm amber"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.51,
     "by": "Johnny Lam",
     "page": "https://unsplash.com/photos/63qfL0TciY8"
    },
    {
     "id": 101,
     "alt": "Concrete bunker-like building against a pale teal sky",
     "tone": "mid",
     "colours": [
      "pale teal",
      "concrete grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Christian Bardenhorst",
     "page": "https://unsplash.com/photos/8lMhzUjD1Wk"
    },
    {
     "id": 134,
     "alt": "Steel truss bridge seen from below above brick arches",
     "tone": "mid",
     "colours": [
      "rust orange",
      "teal",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.51,
     "by": "Charlie Foster",
     "page": "https://unsplash.com/photos/Osl4I3IS9Cw"
    },
    {
     "id": 193,
     "alt": "Gothic stone college building facing a striped green lawn",
     "tone": "mid",
     "colours": [
      "lawn green",
      "sandstone",
      "sky blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.46,
     "by": "Vadim Sherbakov",
     "page": "https://unsplash.com/photos/d6ebY-faOO0"
    },
    {
     "id": 240,
     "alt": "Small figure sitting on wide stone steps beside a white wall",
     "tone": "light",
     "colours": [
      "white",
      "light grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.54,
     "by": "Elisabetta Foco",
     "page": "https://unsplash.com/photos/dFkCXUMB4r4"
    },
    {
     "id": 276,
     "alt": "Wide stone stairway rising into thick fog, black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "charcoal",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.52,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/sM3lniBo2LY"
    },
    {
     "id": 315,
     "alt": "View straight up a dark building lightwell to bright sky",
     "tone": "dark",
     "colours": [
      "dark brown",
      "white",
      "olive"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.4,
     "by": "Liam Andrew",
     "page": "https://unsplash.com/photos/W7CopGaHkEM"
    },
    {
     "id": 331,
     "alt": "Silhouette of a person standing beneath a steel-framed roof",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "grey"
     ],
     "mono": true,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Thong Vo",
     "page": "https://unsplash.com/photos/g28MgVK85bQ"
    },
    {
     "id": 354,
     "alt": "Suspension bridge walkway with stone tower and converging cables",
     "tone": "mid",
     "colours": [
      "warm brown",
      "pale blue",
      "tan"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Piotr Kwiatkowski",
     "page": "https://unsplash.com/photos/MNyW4STma6g"
    },
    {
     "id": 378,
     "alt": "Silhouetted people at tall windows overlooking a city, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "grey",
      "white"
     ],
     "mono": true,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Charles Forerunner",
     "page": "https://unsplash.com/photos/3fPXt37X6UQ"
    },
    {
     "id": 392,
     "alt": "Red suspension bridge spanning a bay beneath a pink sunset sky",
     "tone": "mid",
     "colours": [
      "dusty pink",
      "rust red",
      "warm brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Chris Brignola",
     "page": "https://unsplash.com/photos/n7n-nkadHRM"
    },
    {
     "id": 396,
     "alt": "Long escalator tunnel with a curved lit ceiling and blurred motion",
     "tone": "dark",
     "colours": [
      "amber",
      "black",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sam X",
     "page": "https://unsplash.com/photos/ko-wCySsj-I"
    },
    {
     "id": 500,
     "alt": "Glass skyscraper tapering into streaked clouds, seen from below",
     "tone": "mid",
     "colours": [
      "steel blue",
      "pale grey",
      "white"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Greg Shield",
     "page": "https://unsplash.com/photos/JV4Ic7fSZbw"
    },
    {
     "id": 520,
     "alt": "Suspension bridge tower framed between red brick buildings",
     "tone": "mid",
     "colours": [
      "brick red",
      "sky blue",
      "steel grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.67,
     "by": "Dorothee Hübner",
     "page": "https://unsplash.com/photos/4vPPtrfLRcs"
    },
    {
     "id": 545,
     "alt": "Cobbled bridge with lamp posts and statues at dusk",
     "tone": "dark",
     "colours": [
      "dusty mauve",
      "charcoal",
      "warm grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Ryan Lum",
     "page": "https://unsplash.com/photos/1ak3Z7ZmtQA"
    },
    {
     "id": 546,
     "alt": "Vaulted stone cloister corridor lit by warm light through arches",
     "tone": "dark",
     "colours": [
      "burnt orange",
      "dark brown",
      "amber"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Hartmut Tobies",
     "page": "https://unsplash.com/photos/O9TEKuI1Icw"
    },
    {
     "id": 554,
     "alt": "Stone bridge tower and fanned suspension cables seen from below",
     "tone": "light",
     "colours": [
      "white",
      "tan",
      "grey"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.51,
     "by": "Vita Vilcina",
     "page": "https://unsplash.com/photos/0G1r-Cg0zS8"
    },
    {
     "id": 608,
     "alt": "Symmetrical view beneath a pier with wooden pillars and breaking waves",
     "tone": "mid",
     "colours": [
      "dark brown",
      "teal",
      "pale blue"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Keith Misner",
     "page": "https://unsplash.com/photos/v99lFTVp_ws"
    },
    {
     "id": 613,
     "alt": "Red suspension bridge tower and cables fading into white fog",
     "tone": "light",
     "colours": [
      "white",
      "rust red",
      "pale grey"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 0.66,
     "by": "Axel  Antas-Bergkvist",
     "page": "https://unsplash.com/photos/6asyCyR0K1Q"
    },
    {
     "id": 623,
     "alt": "Underside of a red suspension bridge reaching toward hills in pastel light",
     "tone": "light",
     "colours": [
      "pale pink",
      "rust red",
      "soft blue"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.33,
     "by": "Michael Hirsch",
     "page": "https://unsplash.com/photos/-tXkzUEuheU"
    },
    {
     "id": 639,
     "alt": "White Ferris wheel curving against a clear blue sky",
     "tone": "mid",
     "colours": [
      "bright blue",
      "white"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.33,
     "by": "Ilya",
     "page": "https://unsplash.com/photos/D62hQefhteg"
    },
    {
     "id": 663,
     "alt": "Suspension bridge tower in fog seen from above, black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "charcoal",
      "white"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jared Erondu",
     "page": "https://unsplash.com/photos/tY1QwoLAJQ4"
    },
    {
     "id": 677,
     "alt": "Upward view of four tower corners framing a cross of bright sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "steel grey",
      "white"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.51,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/slgtMF8EjYE"
    },
    {
     "id": 679,
     "alt": "Spiralling concrete curves around a small round window, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "grey",
      "white"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1,
     "by": "Len  dela Cruz",
     "page": "https://unsplash.com/photos/ScEKf8u7y-c"
    },
    {
     "id": 682,
     "alt": "Slender tower narrowing into a pale blue-grey sky, seen from below",
     "tone": "light",
     "colours": [
      "pale grey",
      "soft blue"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 0.67,
     "by": "Logan Troxell",
     "page": "https://unsplash.com/photos/DsQmBlbywJ8"
    },
    {
     "id": 692,
     "alt": "Grand station concourse with arched windows and a blurred crowd",
     "tone": "mid",
     "colours": [
      "warm gold",
      "cream",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Nicolai Berntsen",
     "page": "https://unsplash.com/photos/2Qm47LI0W1c"
    },
    {
     "id": 736,
     "alt": "Art deco skyscraper lit at dusk against a plain mauve sky",
     "tone": "mid",
     "colours": [
      "dusky mauve",
      "warm tan",
      "grey blue"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 0.67,
     "by": "Ben Dumond",
     "page": "https://unsplash.com/photos/VedK8_UlmkY"
    },
    {
     "id": 739,
     "alt": "Upper arc of a Ferris wheel against a bright white sky",
     "tone": "light",
     "colours": [
      "white",
      "charcoal",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Siyan Ren",
     "page": "https://unsplash.com/photos/jN_VbffJHlI"
    },
    {
     "id": 743,
     "alt": "Narrow wedge-shaped building corner seen from below against a teal sky",
     "tone": "mid",
     "colours": [
      "teal blue",
      "sandstone",
      "warm brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Anthony DELANOIX",
     "page": "https://unsplash.com/photos/Aa8_X_YgrO4"
    },
    {
     "id": 744,
     "alt": "Suspension bridge rising above a thick blanket of fog in blue light",
     "tone": "mid",
     "colours": [
      "sky blue",
      "white",
      "rust red"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Modestas Urbonas",
     "page": "https://unsplash.com/photos/vj_9l20fzj0"
    },
    {
     "id": 781,
     "alt": "Dark station platform with a row of bright windows, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/azkWKhyr54U"
    },
    {
     "id": 840,
     "alt": "Aerial view of green grass courts with a small car on the road below",
     "tone": "mid",
     "colours": [
      "grass green",
      "white",
      "tan"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 0.67,
     "by": "Noah Kuhn",
     "page": "https://unsplash.com/photos/aB7g_LMJhRs"
    },
    {
     "id": 859,
     "alt": "Red brick building with a cream door, red shutters and a parked bicycle",
     "tone": "mid",
     "colours": [
      "brick red",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1,
     "by": "Brennan Ehrhardt",
     "page": "https://unsplash.com/photos/HALe2SmkWAI"
    },
    {
     "id": 860,
     "alt": "Bridge walkway framed by a web of suspension cables",
     "tone": "mid",
     "colours": [
      "warm grey",
      "pale beige",
      "brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Skyler Smith",
     "page": "https://unsplash.com/photos/ISHD1ovpJ-k"
    },
    {
     "id": 870,
     "alt": "White lighthouse with its lamp lit against a deep blue starry sky",
     "tone": "mid",
     "colours": [
      "deep blue",
      "white",
      "warm orange"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 0.67,
     "by": "Joshua Hibbert",
     "page": "https://unsplash.com/photos/Pn6iimgM-wo"
    },
    {
     "id": 893,
     "alt": "Curved latticed building facade forming sweeping lines, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "grey"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Samuel Zeller",
     "page": "https://unsplash.com/photos/j0g8taxHZa0"
    },
    {
     "id": 939,
     "alt": "Wedge-shaped historic tower against a white sky, black and white",
     "tone": "light",
     "colours": [
      "white",
      "grey",
      "black"
     ],
     "mono": true,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Alec Cutter",
     "page": "https://unsplash.com/photos/Sfn8f32ZIj0"
    },
    {
     "id": 946,
     "alt": "Yellow brick house front with a dark green door and window",
     "tone": "mid",
     "colours": [
      "ochre yellow",
      "dark green",
      "charcoal"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.4,
     "by": "Padurariu Alexandru",
     "page": "https://unsplash.com/photos/2UE1givDiPM"
    },
    {
     "id": 947,
     "alt": "Whitewashed rooftop terrace with a clay pot and a small domed tower",
     "tone": "light",
     "colours": [
      "cream white",
      "terracotta",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Anthony DELANOIX",
     "page": "https://unsplash.com/photos/vNAZubsDWMg"
    },
    {
     "id": 948,
     "alt": "White latticed tower seen from below against a blue sky",
     "tone": "light",
     "colours": [
      "white",
      "sky blue",
      "pale grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.67,
     "by": "Mihail Ribkin",
     "page": "https://unsplash.com/photos/VjmlDjePHjE"
    },
    {
     "id": 949,
     "alt": "White spiral atrium with a glass dome skylight, seen from below",
     "tone": "light",
     "colours": [
      "white",
      "pale grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.66,
     "by": "Drew Patrick Miller",
     "page": "https://unsplash.com/photos/VLT62-JzddA"
    },
    {
     "id": 951,
     "alt": "Building facade of red, yellow and white rectangular panels",
     "tone": "light",
     "colours": [
      "red",
      "yellow",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.6,
     "by": "Sérgio Rola",
     "page": "https://unsplash.com/photos/4aQY2CrXsa8"
    },
    {
     "id": 972,
     "alt": "Glass skyscrapers converging overhead into a white sky",
     "tone": "light",
     "colours": [
      "white",
      "steel grey",
      "dark grey"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/VviFtDJakYk"
    },
    {
     "id": 983,
     "alt": "Dark angular skyscraper with lit edges, black and white",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "charcoal"
     ],
     "mono": true,
     "calm": "left",
     "quality": 4,
     "ratio": 1.52,
     "by": "Padurariu Alexandru",
     "page": "https://unsplash.com/photos/ZKBQmgMyf8s"
    },
    {
     "id": 1031,
     "alt": "Twin glass towers converging upward against a grey sky",
     "tone": "mid",
     "colours": [
      "steel grey",
      "dark blue",
      "pale grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Mike Wilson",
     "page": "https://unsplash.com/photos/rM7B4DheQc0"
    },
    {
     "id": 1033,
     "alt": "Symmetrical dark concourse with escalators and teal-lit glass walls",
     "tone": "dark",
     "colours": [
      "dark teal",
      "black",
      "amber"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Erez Attias",
     "page": "https://unsplash.com/photos/KqVHRmHVwwM"
    },
    {
     "id": 1054,
     "alt": "Weathered grey concrete apartment block with scattered small windows",
     "tone": "mid",
     "colours": [
      "concrete grey",
      "pale blue",
      "charcoal"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Sérgio Rola",
     "page": "https://unsplash.com/photos/B1amIgaNkwA"
    }
   ]
  },
  "city-day": {
   "title": "City by day",
   "note": "streets and buildings in daylight",
   "sectors": [
    "real-estate",
    "consumer-app",
    "agency",
    "event",
    "culture",
    "fashion",
    "education",
    "nonprofit",
    "other",
    "finance"
   ],
   "keywords": [
    "city",
    "street",
    "urban",
    "town",
    "neighbourhood",
    "neighborhood",
    "local",
    "commute",
    "downtown"
   ],
   "photos": [
    {
     "id": 49,
     "alt": "Whitewashed hillside village with a windmill under hazy blue sky",
     "tone": "light",
     "colours": [
      "white",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.62,
     "by": "Margaret Barley",
     "page": "https://unsplash.com/photos/Qo51KwK1dKg"
    },
    {
     "id": 57,
     "alt": "Narrow city street lined with brick buildings in shadow",
     "tone": "mid",
     "colours": [
      "brick red",
      "teal blue",
      "dark grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 0.75,
     "by": "Nicholas Swanson",
     "page": "https://unsplash.com/photos/SyBYM8R6VU4"
    },
    {
     "id": 188,
     "alt": "Snow-dusted rooftops of an old town seen from above",
     "tone": "mid",
     "colours": [
      "steel blue",
      "white",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Wojtek Witkowski",
     "page": "https://unsplash.com/photos/1fpyA_z2woY"
    },
    {
     "id": 212,
     "alt": "Bicycle with a basket parked on a wet city street",
     "tone": "mid",
     "colours": [
      "warm brown",
      "amber",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.43,
     "by": "Sylwia Bartyzel",
     "page": "https://unsplash.com/photos/eDDQRAYKo7k"
    },
    {
     "id": 238,
     "alt": "Dense city skyline of skyscrapers seen from above, black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "white",
      "black"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Anders Jildén",
     "page": "https://unsplash.com/photos/yb0Qs65aZmc"
    },
    {
     "id": 259,
     "alt": "Person on a bench facing a city skyline across snowy waterfront",
     "tone": "light",
     "colours": [
      "pale grey",
      "white",
      "soft blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.33,
     "by": "Namphuong Van",
     "page": "https://unsplash.com/photos/hfIheOEJp9M"
    },
    {
     "id": 288,
     "alt": "Old town waterfront with pastel buildings and church spires",
     "tone": "mid",
     "colours": [
      "cream",
      "terracotta",
      "pale blue"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Stockholm",
     "page": "https://unsplash.com/photos/eVBg7A07NGg"
    },
    {
     "id": 299,
     "alt": "City street between tall brick buildings with a glass tower beyond",
     "tone": "mid",
     "colours": [
      "warm brown",
      "pale blue",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.52,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/nOhUx3tiaQQ"
    },
    {
     "id": 308,
     "alt": "Narrow street of colourful houses with laundry hung between them",
     "tone": "mid",
     "colours": [
      "terracotta red",
      "pale yellow",
      "sky blue"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Charles L.",
     "page": "https://unsplash.com/photos/5z8CIELxW1Y"
    },
    {
     "id": 322,
     "alt": "Narrow cobbled alley between worn ochre and terracotta walls",
     "tone": "mid",
     "colours": [
      "ochre",
      "terracotta",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Afroz Nawaf",
     "page": "https://unsplash.com/photos/dSyWD7oRns8"
    },
    {
     "id": 344,
     "alt": "Hazy golden city skyline reflected in still water",
     "tone": "mid",
     "colours": [
      "muted gold",
      "olive",
      "pale yellow"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.48,
     "by": "Alex Gindin",
     "page": "https://unsplash.com/photos/ifpBOcQlhoY"
    },
    {
     "id": 430,
     "alt": "Distant city skyline across the water under towering white clouds",
     "tone": "light",
     "colours": [
      "sky blue",
      "white",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Carli Jean",
     "page": "https://unsplash.com/photos/Gk6YgzmrLgM"
    },
    {
     "id": 439,
     "alt": "City skyline low in the frame beneath a vast muted grey sky",
     "tone": "mid",
     "colours": [
      "warm grey",
      "taupe",
      "slate"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/VQIbwDaqJKc"
    },
    {
     "id": 826,
     "alt": "Boat on a canal between tall brick warehouses in hazy light",
     "tone": "dark",
     "colours": [
      "dark brown",
      "hazy cream",
      "dark teal"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.52,
     "by": "Nick Scheerbart",
     "page": "https://unsplash.com/photos/QmemudqzrXQ"
    },
    {
     "id": 868,
     "alt": "Aerial view of a dense city skyline fading into haze",
     "tone": "mid",
     "colours": [
      "grey blue",
      "slate",
      "pale grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.11,
     "by": "Jeffrey Swanson",
     "page": "https://unsplash.com/photos/T72-kQyQxtA"
    },
    {
     "id": 1029,
     "alt": "Aerial view of a large city park surrounded by towers",
     "tone": "mid",
     "colours": [
      "forest green",
      "grey",
      "pale haze"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.77,
     "by": "freddie marriage",
     "page": "https://unsplash.com/photos/utwYoEu9SU8"
    },
    {
     "id": 1065,
     "alt": "Narrow alley between old stone buildings opening onto the sea",
     "tone": "mid",
     "colours": [
      "stone beige",
      "sea blue",
      "dark brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.67,
     "by": "Bertrand Zuchuat",
     "page": "https://unsplash.com/photos/Uax2bg5EizU"
    },
    {
     "id": 1067,
     "alt": "Aerial view of a city skyline along the water at sunset",
     "tone": "mid",
     "colours": [
      "warm orange",
      "dusky blue",
      "dark grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Kevin Young",
     "page": "https://unsplash.com/photos/-icmOdYWXuQ"
    }
   ]
  },
  "city-night": {
   "title": "City at night",
   "note": "night streets, lights and long exposures",
   "sectors": [
    "music",
    "event",
    "restaurant",
    "consumer-app",
    "fashion",
    "culture",
    "finance",
    "other"
   ],
   "keywords": [
    "night",
    "club",
    "bar",
    "nightlife",
    "neon",
    "concert",
    "festival",
    "late",
    "evening",
    "cocktail"
   ],
   "primary": [
    "music",
    "event"
   ],
   "photos": [
    {
     "id": 43,
     "alt": "Suspension bridge and city skyline at night in black and white",
     "tone": "dark",
     "colours": [
      "black",
      "silver grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.54,
     "by": "Oleg Chursin",
     "page": "https://unsplash.com/photos/IoCWq07GaG4"
    },
    {
     "id": 122,
     "alt": "Domed cathedral and city lights reflected in a river at night",
     "tone": "dark",
     "colours": [
      "dark teal",
      "pale gold"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Vadim Sherbakov",
     "page": "https://unsplash.com/photos/xS_RzdD5CFE"
    },
    {
     "id": 171,
     "alt": "Raindrops on a window with blurred traffic lights at night",
     "tone": "dark",
     "colours": [
      "charcoal",
      "dark teal",
      "amber"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Riley Briggs",
     "page": "https://unsplash.com/photos/cSe3oKQ03OQ"
    },
    {
     "id": 195,
     "alt": "Narrow alley lit by strings of hanging lights at night",
     "tone": "dark",
     "colours": [
      "warm amber",
      "dark brown",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.75,
     "by": "Matthew Skinner",
     "page": "https://unsplash.com/photos/t05kfHeygbE"
    },
    {
     "id": 220,
     "alt": "Tram tracks leading into fog under glowing street lamps at night",
     "tone": "dark",
     "colours": [
      "muted purple",
      "amber"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.6,
     "by": "Robin Röcker",
     "page": "https://unsplash.com/photos/qUToqliACNA"
    },
    {
     "id": 232,
     "alt": "Twin-globe street lamp glowing in falling snow at night",
     "tone": "dark",
     "colours": [
      "black",
      "warm white"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.51,
     "by": "Hide Obara",
     "page": "https://unsplash.com/photos/Qh6wsKk1HWg"
    },
    {
     "id": 496,
     "alt": "City skyline silhouette across the water under a deep blue dusk sky",
     "tone": "dark",
     "colours": [
      "deep blue",
      "amber",
      "black"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/mOPCiXScx08"
    },
    {
     "id": 579,
     "alt": "Blurred night street with glowing traffic and building lights",
     "tone": "dark",
     "colours": [
      "navy",
      "amber",
      "teal"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Israel Sundseth",
     "page": "https://unsplash.com/photos/BYu8ITUWMfc"
    },
    {
     "id": 797,
     "alt": "Red umbrella on a rainy street at night with blurred lights",
     "tone": "dark",
     "colours": [
      "deep red",
      "dark teal",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Gabriel Santiago",
     "page": "https://unsplash.com/photos/1vYkQVDWXl0"
    },
    {
     "id": 985,
     "alt": "City skyline silhouetted at dusk across rippling water",
     "tone": "dark",
     "colours": [
      "navy",
      "deep blue",
      "pale orange"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Hide Obara",
     "page": "https://unsplash.com/photos/2p1HOcpi14U"
    },
    {
     "id": 1078,
     "alt": "Grid of lit office windows on a tower at night",
     "tone": "dark",
     "colours": [
      "dark navy",
      "warm yellow",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Vladimir Kudinov",
     "page": "https://unsplash.com/photos/KBX9XHk266s"
    }
   ]
  },
  "interior-and-table": {
   "title": "Interior and table",
   "note": "rooms, windows, chairs, cups, food and tabletops",
   "sectors": [
    "restaurant",
    "hospitality",
    "real-estate",
    "physical-product",
    "ecommerce",
    "health",
    "other"
   ],
   "keywords": [
    "restaurant",
    "cafe",
    "coffee",
    "kitchen",
    "table",
    "food",
    "dining",
    "bakery",
    "room",
    "home",
    "interior",
    "furniture",
    "tea",
    "breakfast",
    "hotel",
    "ceramic",
    "cup",
    "pasta",
    "wine",
    "dinner",
    "osteria",
    "trattoria",
    "bistro",
    "menu",
    "chef",
    "cook",
    "dish",
    "lunch",
    "brunch"
   ],
   "primary": [
    "restaurant"
   ],
   "photos": [
    {
     "id": 326,
     "alt": "Hand holding a white bowl with yellow slices on a pale surface",
     "tone": "light",
     "colours": [
      "cream",
      "pale yellow",
      "warm beige"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.51,
     "by": "Dominik Martin",
     "page": "https://unsplash.com/photos/JYFmYif4n70"
    },
    {
     "id": 625,
     "alt": "Wooden dining table with white moulded chairs beside large windows",
     "tone": "light",
     "colours": [
      "white",
      "warm wood",
      "cream"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Breather",
     "page": "https://unsplash.com/photos/DEdM9Vs6s8w"
    },
    {
     "id": 1068,
     "alt": "Small plant in a vase on a white table against a blue wall",
     "tone": "mid",
     "colours": [
      "bright blue",
      "white",
      "green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.74,
     "by": "Padurariu Alexandru",
     "page": "https://unsplash.com/photos/iNmouRApXYM"
    },
    {
     "id": 42,
     "alt": "Coffee cup on a long wooden table inside a bright cafe",
     "tone": "mid",
     "colours": [
      "warm brown",
      "white"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Luke Chesser",
     "page": "https://unsplash.com/photos/KR2mdHJ5qMg"
    },
    {
     "id": 102,
     "alt": "Raspberries on a white ledge with warm golden blurred background",
     "tone": "light",
     "colours": [
      "golden yellow",
      "raspberry red",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.33,
     "by": "Ben Moore",
     "page": "https://unsplash.com/photos/pJILiyPdrXI"
    },
    {
     "id": 192,
     "alt": "Silhouetted people at cafe tables before large bright windows",
     "tone": "mid",
     "colours": [
      "white",
      "black",
      "grey"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1,
     "by": "Adam Przewoski",
     "page": "https://unsplash.com/photos/umchkHwkdyM"
    },
    {
     "id": 208,
     "alt": "Multi-pane window in a dark room looking onto green trees",
     "tone": "dark",
     "colours": [
      "black",
      "leaf green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.56,
     "by": "Martin Wessely",
     "page": "https://unsplash.com/photos/NYxxuBSQzp4"
    },
    {
     "id": 225,
     "alt": "Glass teapot of herbal tea beside yellow flowers on a table",
     "tone": "mid",
     "colours": [
      "amber yellow",
      "dark brown",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.53,
     "by": "Vee O",
     "page": "https://unsplash.com/photos/hGO27G5tZJ8"
    },
    {
     "id": 292,
     "alt": "Fresh vegetables, red onion and peppercorns on a wooden cutting board",
     "tone": "mid",
     "colours": [
      "green",
      "warm wood",
      "purple red"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.51,
     "by": "Webvilla",
     "page": "https://unsplash.com/photos/hv1MrBzGGNY"
    },
    {
     "id": 305,
     "alt": "Small table with a vase of branches on a railed balcony",
     "tone": "light",
     "colours": [
      "white",
      "soft green",
      "grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.51,
     "by": "Dominik Martin",
     "page": "https://unsplash.com/photos/B6uUPYt7wz4"
    },
    {
     "id": 311,
     "alt": "Wooden chair by a curtained window at dusk",
     "tone": "dark",
     "colours": [
      "dark brown",
      "dusty blue",
      "cream"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.78,
     "by": "Logan Adermatt",
     "page": "https://unsplash.com/photos/VkF8pVVoFg0"
    },
    {
     "id": 312,
     "alt": "Honey dipper dripping into a white ceramic bowl of dark honey",
     "tone": "light",
     "colours": [
      "white",
      "amber brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sonja Langford",
     "page": "https://unsplash.com/photos/RQHzRELE2Ss"
    },
    {
     "id": 429,
     "alt": "White cup filled with fresh raspberries on a pale wooden table",
     "tone": "light",
     "colours": [
      "raspberry red",
      "white",
      "pale wood"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.78,
     "by": "Glen Carrie",
     "page": "https://unsplash.com/photos/FjjUVn_KHLU"
    },
    {
     "id": 488,
     "alt": "Bowl of colourful salad on a wooden board, seen from above",
     "tone": "light",
     "colours": [
      "pale wood",
      "white",
      "red"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Monstruo Estudio",
     "page": "https://unsplash.com/photos/lQy6mHZ7fYk"
    },
    {
     "id": 490,
     "alt": "Dark overhead arrangement of a cutting board, pan and kitchen tools",
     "tone": "dark",
     "colours": [
      "dark brown",
      "black",
      "tan"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.51,
     "by": "Todd Quackenbush",
     "page": "https://unsplash.com/photos/x5SRhkFajrA"
    },
    {
     "id": 635,
     "alt": "Enamel mugs and a teapot on a wooden table in warm light",
     "tone": "mid",
     "colours": [
      "terracotta",
      "warm brown",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "André Freitas",
     "page": "https://unsplash.com/photos/uu5PfAzu0s4"
    },
    {
     "id": 691,
     "alt": "Green mug on a bedside table beside rumpled white bedding",
     "tone": "light",
     "colours": [
      "white",
      "teal green",
      "warm beige"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "David Mao",
     "page": "https://unsplash.com/photos/m0l5J8Lqnzo"
    },
    {
     "id": 834,
     "alt": "Three framed prints on a pale wall above a dark tufted bench",
     "tone": "mid",
     "colours": [
      "cream",
      "charcoal",
      "pale blue"
     ],
     "mono": false,
     "calm": "centre",
     "quality": 4,
     "ratio": 0.67,
     "by": "The Anchor",
     "page": "https://unsplash.com/photos/6hgsktgpfIE"
    },
    {
     "id": 889,
     "alt": "Close-up of frosted frozen berries in purple and red",
     "tone": "mid",
     "colours": [
      "frosted purple",
      "berry red",
      "pale lilac"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Devin Rajaram",
     "page": "https://unsplash.com/photos/X5ChCOu8CQg"
    },
    {
     "id": 999,
     "alt": "Bowls, nuts and a pastry arranged on a dark wooden table from above",
     "tone": "dark",
     "colours": [
      "charcoal",
      "dark brown",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Annie Spratt",
     "page": "https://unsplash.com/photos/R3LcfTvcGWY"
    },
    {
     "id": 1080,
     "alt": "Fresh red strawberries packed tightly, filling the frame",
     "tone": "mid",
     "colours": [
      "strawberry red",
      "green",
      "pink"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "veeterzy",
     "page": "https://unsplash.com/photos/OJJIaFZOeX4"
    }
   ]
  },
  "object-still": {
   "title": "Objects",
   "note": "single objects seen close: instruments, tools, cameras, books",
   "sectors": [
    "physical-product",
    "ecommerce",
    "personal-portfolio",
    "culture",
    "music",
    "education",
    "other"
   ],
   "keywords": [
    "object",
    "product",
    "camera",
    "tool",
    "instrument",
    "book",
    "craft",
    "vintage",
    "analog",
    "record",
    "watch"
   ],
   "photos": [
    {
     "id": 63,
     "alt": "Cup of coffee seen from above on a bright red surface",
     "tone": "mid",
     "colours": [
      "bright red",
      "white",
      "brown"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.78,
     "by": "Justin Leibow",
     "page": "https://unsplash.com/photos/ZJsseAxEcqM"
    },
    {
     "id": 23,
     "alt": "Close-up of metal fork tines against a dark blurred background",
     "tone": "dark",
     "colours": [
      "charcoal",
      "blue grey",
      "silver"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.79,
     "by": "Alejandro Escamilla",
     "page": "https://unsplash.com/photos/8yqds_91OLw"
    },
    {
     "id": 24,
     "alt": "Open book lying on a wooden table in soft light",
     "tone": "light",
     "colours": [
      "cream",
      "warm brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 2.69,
     "by": "Alejandro Escamilla",
     "page": "https://unsplash.com/photos/cZhUxIQjILg"
    },
    {
     "id": 39,
     "alt": "Close-up of a turntable tonearm resting on a vinyl record",
     "tone": "light",
     "colours": [
      "white",
      "black",
      "warm grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Luke Chesser",
     "page": "https://unsplash.com/photos/pFqrYbhIAXs"
    },
    {
     "id": 80,
     "alt": "Pine cones resting on weathered wood in black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sonja Langford",
     "page": "https://unsplash.com/photos/Y2PYfopoz-k"
    },
    {
     "id": 113,
     "alt": "Steam rising from a metal mug with blurred blue background",
     "tone": "mid",
     "colours": [
      "steel blue",
      "white",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.69,
     "by": "Zugr",
     "page": "https://unsplash.com/photos/yZf1quatKCA"
    },
    {
     "id": 175,
     "alt": "Close-up of an analogue clock face in black and white",
     "tone": "mid",
     "colours": [
      "white",
      "black",
      "grey"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.49,
     "by": "petradr",
     "page": "https://unsplash.com/photos/8hgm6mKK04U"
    },
    {
     "id": 486,
     "alt": "Black vintage typewriter with blank paper seen from above on white",
     "tone": "light",
     "colours": [
      "white",
      "black"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 0.68,
     "by": "Florian Klauer",
     "page": "https://unsplash.com/photos/mk7D-4UCfmg"
    },
    {
     "id": 535,
     "alt": "Colourful shirts on wooden hangers along a metal pipe rail",
     "tone": "dark",
     "colours": [
      "charcoal",
      "orange",
      "green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 0.75,
     "by": "Jeff Sheldon",
     "page": "https://unsplash.com/photos/Lj1S1_KD61k"
    },
    {
     "id": 687,
     "alt": "Coin-operated viewing binoculars facing a pale sea and sky",
     "tone": "light",
     "colours": [
      "white",
      "pale blue",
      "silver grey"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.51,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/3WbWLC6QHBg"
    }
   ]
  },
  "hands-and-making": {
   "title": "Hands and making",
   "note": "hands at work, craft and making",
   "sectors": [
    "physical-product",
    "restaurant",
    "education",
    "nonprofit",
    "personal-portfolio",
    "agency",
    "other"
   ],
   "keywords": [
    "hand",
    "craft",
    "make",
    "maker",
    "workshop",
    "handmade",
    "studio",
    "artisan",
    "pottery",
    "write",
    "draw",
    "ceramic",
    "glaze",
    "clay",
    "kiln",
    "leather",
    "knit",
    "sew",
    "bake",
    "brew",
    "pour",
    "potter"
   ],
   "primary": [
    "physical-product",
    "ecommerce"
   ],
   "photos": [
    {
     "id": 304,
     "alt": "Hand adjusting faders on an audio mixing console",
     "tone": "mid",
     "colours": [
      "warm white",
      "navy",
      "amber"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Drew Patrick Miller",
     "page": "https://unsplash.com/photos/_o6AAx9dl_Y"
    },
    {
     "id": 319,
     "alt": "Hands holding a vintage rangefinder camera against a denim jacket",
     "tone": "mid",
     "colours": [
      "denim blue",
      "mustard",
      "silver"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.97,
     "by": "Kristian Karlsson",
     "page": "https://unsplash.com/photos/3V6ZTpZS-ag"
    },
    {
     "id": 407,
     "alt": "Hands lighting a sparkler that throws bright sparks in the dark",
     "tone": "dark",
     "colours": [
      "charcoal",
      "warm white",
      "amber"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Kelley Bozarth",
     "page": "https://unsplash.com/photos/n6vS3xlnsCc"
    },
    {
     "id": 491,
     "alt": "Axe, hammer, pliers and leather gloves laid out on dark wood",
     "tone": "dark",
     "colours": [
      "dark brown",
      "tan",
      "steel grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.23,
     "by": "Todd Quackenbush",
     "page": "https://unsplash.com/photos/IClZBVw5W5A"
    },
    {
     "id": 674,
     "alt": "Cupped hands holding a heap of dark blue grapes",
     "tone": "mid",
     "colours": [
      "pale blue",
      "dark purple",
      "tan"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Maja Petric",
     "page": "https://unsplash.com/photos/vGQ49l9I4EE"
    },
    {
     "id": 785,
     "alt": "Two cupped hands holding a pile of green spruce tips",
     "tone": "mid",
     "colours": [
      "fresh green",
      "dark green",
      "warm skin"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jean Lakosnyk",
     "page": "https://unsplash.com/photos/N9yFjkDc0vA"
    },
    {
     "id": 1060,
     "alt": "Kettle pouring water over a pour-over coffee dripper on a dark counter",
     "tone": "dark",
     "colours": [
      "dark teal",
      "black",
      "warm brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Karl Fredrickson",
     "page": "https://unsplash.com/photos/TYIzeCiZ_60"
    }
   ]
  },
  "people": {
   "title": "People",
   "note": "figures, silhouettes and portraits, mostly unposed",
   "sectors": [
    "nonprofit",
    "education",
    "health",
    "consumer-app",
    "fashion",
    "music",
    "event",
    "culture",
    "personal-portfolio",
    "agency",
    "other"
   ],
   "keywords": [
    "people",
    "person",
    "community",
    "team",
    "friend",
    "human",
    "portrait",
    "member",
    "student",
    "family",
    "social"
   ],
   "primary": [
    "nonprofit",
    "education"
   ],
   "photos": [
    {
     "id": 177,
     "alt": "Hiker with a backpack looking over a hazy mountain valley",
     "tone": "mid",
     "colours": [
      "warm tan",
      "hazy gold",
      "olive"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.37,
     "by": "Danka & Peter",
     "page": "https://unsplash.com/photos/tvicgTdh7Fg"
    },
    {
     "id": 338,
     "alt": "Person in a hooded coat seen from behind facing a misty sea",
     "tone": "mid",
     "colours": [
      "slate blue",
      "grey",
      "charcoal"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Patryk Sobczak",
     "page": "https://unsplash.com/photos/9VPtNW84vGI"
    },
    {
     "id": 669,
     "alt": "Person in a mustard jacket and grey beanie seen from behind in fog",
     "tone": "light",
     "colours": [
      "mustard",
      "pale grey",
      "white"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.41,
     "by": "Luke Pamer",
     "page": "https://unsplash.com/photos/oUhr-qMTJoc"
    },
    {
     "id": 685,
     "alt": "Man sitting on a rock looking over a sea of clouds",
     "tone": "mid",
     "colours": [
      "pale gold",
      "grey",
      "dark brown"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Joshua Earle",
     "page": "https://unsplash.com/photos/Dwheufds6kQ"
    },
    {
     "id": 22,
     "alt": "Man in a dark suit walking across painted road markings",
     "tone": "mid",
     "colours": [
      "warm grey",
      "charcoal"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.19,
     "by": "Alejandro Escamilla",
     "page": "https://unsplash.com/photos/du_OrQAA4r0"
    },
    {
     "id": 91,
     "alt": "Person in dark jacket holding a vintage twin-lens camera, monochrome",
     "tone": "mid",
     "colours": [
      "grey",
      "black"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jennifer Trovato",
     "page": "https://unsplash.com/photos/baRYCsjO6z4"
    },
    {
     "id": 281,
     "alt": "Skateboarder in mid-air at a concrete skatepark with palm trees",
     "tone": "mid",
     "colours": [
      "warm grey",
      "teal",
      "tan"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.51,
     "by": "Victor Erixon",
     "page": "https://unsplash.com/photos/_poRbNNfcE8"
    },
    {
     "id": 334,
     "alt": "Man carrying a guitar walking along a foggy beach, monochrome",
     "tone": "light",
     "colours": [
      "light grey",
      "white",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Kimberly Richards",
     "page": "https://unsplash.com/photos/ERSauUNQ3ag"
    },
    {
     "id": 349,
     "alt": "Man sitting on a sea wall looking toward a hazy skyline",
     "tone": "mid",
     "colours": [
      "warm grey",
      "sepia",
      "charcoal"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Caleb George",
     "page": "https://unsplash.com/photos/FmMivfgHCiM"
    },
    {
     "id": 375,
     "alt": "Person in a hooded jacket sitting among white daisies at dusk",
     "tone": "dark",
     "colours": [
      "navy",
      "dark green",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Wellington Sanipe",
     "page": "https://unsplash.com/photos/3jUTmrmdNVg"
    },
    {
     "id": 446,
     "alt": "Swimmer diving underwater in deep blue water with rising bubbles",
     "tone": "dark",
     "colours": [
      "deep blue",
      "navy"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Jacob Walti",
     "page": "https://unsplash.com/photos/yxvKH7N8aso"
    },
    {
     "id": 447,
     "alt": "Man standing on a wall facing a blue lake and distant mountains",
     "tone": "mid",
     "colours": [
      "blue",
      "slate",
      "white"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Joshua Earle",
     "page": "https://unsplash.com/photos/YxJ5AfKFgFE"
    },
    {
     "id": 473,
     "alt": "Person in dark clothes standing on a jetty facing misty mountains",
     "tone": "mid",
     "colours": [
      "slate blue",
      "black",
      "pale grey"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Todd Quackenbush",
     "page": "https://unsplash.com/photos/Nk5rSNq13sM"
    },
    {
     "id": 497,
     "alt": "Back of a woman with windblown hair against a plain grey backdrop",
     "tone": "mid",
     "colours": [
      "pale grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "top",
     "quality": 4,
     "ratio": 1.77,
     "by": "Volkan Olmez",
     "page": "https://unsplash.com/photos/wESKMSgZJDo"
    },
    {
     "id": 550,
     "alt": "Woman seen from behind looking over a blurred city at dusk",
     "tone": "mid",
     "colours": [
      "dusty pink",
      "warm grey",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.67,
     "by": "Christopher Sardegna",
     "page": "https://unsplash.com/photos/CMOa3H1SXG0"
    },
    {
     "id": 660,
     "alt": "Woman in a knitted cardigan holding a lit sparkler in front of her",
     "tone": "dark",
     "colours": [
      "dark teal",
      "grey",
      "warm white"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Morgan Sessions",
     "page": "https://unsplash.com/photos/YIN4xUBaqnk"
    },
    {
     "id": 665,
     "alt": "Man sitting on a stone wall overlooking a hazy valley",
     "tone": "mid",
     "colours": [
      "pale teal",
      "cream",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.78,
     "by": "Joshua Earle",
     "page": "https://unsplash.com/photos/CND1MBxLA6M"
    },
    {
     "id": 726,
     "alt": "Person in a red jacket standing in a snowy, misty landscape",
     "tone": "light",
     "colours": [
      "white",
      "pale grey",
      "red"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Caleb George",
     "page": "https://unsplash.com/photos/BrXiun04yr4"
    },
    {
     "id": 768,
     "alt": "Person sitting alone on a bench facing sandy dunes",
     "tone": "light",
     "colours": [
      "sand beige",
      "pale blue",
      "soft white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Mikael Kristenson",
     "page": "https://unsplash.com/photos/3f4sQIums6k"
    },
    {
     "id": 821,
     "alt": "Woman seen from behind on a platform as a train blurs past",
     "tone": "light",
     "colours": [
      "white",
      "grey",
      "black"
     ],
     "mono": true,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Eutah Mizushima",
     "page": "https://unsplash.com/photos/2TlAsvhqiL0"
    },
    {
     "id": 832,
     "alt": "Woman in a pale blue shirt reading a book in soft window light",
     "tone": "dark",
     "colours": [
      "dark olive",
      "pale blue",
      "warm skin"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "hieu le",
     "page": "https://unsplash.com/photos/SrkuyPb3aUk"
    },
    {
     "id": 841,
     "alt": "Swimmer mid-stroke in a dark lake beneath moody mountains",
     "tone": "dark",
     "colours": [
      "slate grey",
      "dark blue",
      "charcoal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.98,
     "by": "Todd Quackenbush",
     "page": "https://unsplash.com/photos/E9PJO_vL3E8"
    },
    {
     "id": 1005,
     "alt": "Person in a scarf seen from behind, looking at the sea at dusk",
     "tone": "mid",
     "colours": [
      "warm sand",
      "dark brown",
      "pale peach"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/tBtuxtLvAZs"
    },
    {
     "id": 1006,
     "alt": "Person sitting on a rocky overlook above misty forested hills",
     "tone": "mid",
     "colours": [
      "dusky purple",
      "olive green",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Vladimir Kudinov",
     "page": "https://unsplash.com/photos/-wWRHIUklxM"
    },
    {
     "id": 1009,
     "alt": "Back of a person's head beneath a wide pale blue sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "soft white",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 0.67,
     "by": "Christopher Campbell",
     "page": "https://unsplash.com/photos/CMWRIzyMKZk"
    },
    {
     "id": 1011,
     "alt": "Woman paddling a canoe on a turquoise lake edged by forest",
     "tone": "mid",
     "colours": [
      "turquoise",
      "dark green",
      "rust red"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Roberto Nickson",
     "page": "https://unsplash.com/photos/7BjmDICVloE"
    },
    {
     "id": 1014,
     "alt": "Woman seen from behind facing a misty sea, a ribbon across her head",
     "tone": "light",
     "colours": [
      "pale teal",
      "soft grey",
      "black"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Oscar Keys",
     "page": "https://unsplash.com/photos/AmPRUnRb6N0"
    }
   ]
  },
  "animals": {
   "title": "Animals",
   "note": "wild and domestic animals",
   "sectors": [
    "nonprofit",
    "education",
    "consumer-app",
    "other",
    "health"
   ],
   "keywords": [
    "animal",
    "pet",
    "dog",
    "cat",
    "bird",
    "wildlife",
    "horse",
    "zoo",
    "vet",
    "safari"
   ],
   "photos": [
    {
     "id": 200,
     "alt": "Shaggy highland cow standing on a sandy hillside",
     "tone": "mid",
     "colours": [
      "rust brown",
      "sand beige",
      "pale grey"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Elias Carlsson",
     "page": "https://unsplash.com/photos/wSb8d-ke5-4"
    },
    {
     "id": 219,
     "alt": "Leopard walking along a sandy dirt road beside grass",
     "tone": "mid",
     "colours": [
      "sandy tan",
      "olive green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.5,
     "by": "Martyn Seddon",
     "page": "https://unsplash.com/photos/7iB4OZDlRok"
    },
    {
     "id": 237,
     "alt": "Black labrador puppy looking up from a wooden floor",
     "tone": "mid",
     "colours": [
      "black",
      "weathered brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 1.67,
     "by": "André Spieker",
     "page": "https://unsplash.com/photos/8wTPqxlnKM4"
    },
    {
     "id": 275,
     "alt": "Seagull standing on a ledge against a pale blue sea",
     "tone": "light",
     "colours": [
      "pale blue",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.51,
     "by": "davide ragusa",
     "page": "https://unsplash.com/photos/ATutAPqj4zw"
    },
    {
     "id": 881,
     "alt": "Jellyfish drifting in bright blue water beneath rays of light",
     "tone": "mid",
     "colours": [
      "electric blue",
      "cyan",
      "deep blue"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 5,
     "ratio": 1.5,
     "by": "贝莉儿 NG",
     "page": "https://unsplash.com/photos/bviex5lwf3s"
    },
    {
     "id": 943,
     "alt": "Deer silhouettes grazing in a golden misty meadow",
     "tone": "mid",
     "colours": [
      "amber",
      "golden yellow",
      "dark brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 1.5,
     "by": "Paul Earle",
     "page": "https://unsplash.com/photos/l98YXp1X8dA"
    },
    {
     "id": 1003,
     "alt": "Young spotted fawn looking back in warm, blurred woodland",
     "tone": "mid",
     "colours": [
      "warm brown",
      "olive",
      "amber"
     ],
     "mono": false,
     "calm": "top",
     "quality": 5,
     "ratio": 0.67,
     "by": "E+N Photographies",
     "page": "https://unsplash.com/photos/GYumuBnTqKc"
    },
    {
     "id": 1069,
     "alt": "Orange jellyfish with trailing tentacles against deep blue water",
     "tone": "mid",
     "colours": [
      "cobalt blue",
      "vivid orange"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Marat Gilyadzinov",
     "page": "https://unsplash.com/photos/wpTWYBll4_w"
    },
    {
     "id": 1074,
     "alt": "Lioness looking upward against a dark background",
     "tone": "dark",
     "colours": [
      "tawny brown",
      "black",
      "gold"
     ],
     "mono": false,
     "calm": "right",
     "quality": 5,
     "ratio": 1.5,
     "by": "Samuel Scrimshaw",
     "page": "https://unsplash.com/photos/sseiVD2XsOk"
    },
    {
     "id": 40,
     "alt": "Extreme close-up of a cat's nose, mouth and whiskers",
     "tone": "light",
     "colours": [
      "soft grey",
      "pink",
      "white"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.46,
     "by": "Ryan Mcguire",
     "page": "https://unsplash.com/photos/N-1XGL54pQg"
    },
    {
     "id": 169,
     "alt": "Two brown dachshunds sniffing through bright green grass",
     "tone": "mid",
     "colours": [
      "grass green",
      "chestnut brown"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Noel Lopez",
     "page": "https://unsplash.com/photos/BjelfpszQDw"
    },
    {
     "id": 244,
     "alt": "Pelican perched on a wooden post beside a weathered pier",
     "tone": "light",
     "colours": [
      "pale blue",
      "white",
      "grey brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Yair Hazout",
     "page": "https://unsplash.com/photos/Y-eIZ3g8_ko"
    },
    {
     "id": 258,
     "alt": "Flock of birds in flight against a plain teal sky",
     "tone": "mid",
     "colours": [
      "muted teal",
      "black"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Fré Sonneveld",
     "page": "https://unsplash.com/photos/liiqOto_Dw8"
    },
    {
     "id": 433,
     "alt": "Close portrait of a brown bear looking toward the camera",
     "tone": "mid",
     "colours": [
      "brown",
      "tan",
      "pale grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Thomas Lefebvre",
     "page": "https://unsplash.com/photos/aRXPJnXQ9lU"
    },
    {
     "id": 577,
     "alt": "Antlered deer standing in a shallow river beside green forest",
     "tone": "mid",
     "colours": [
      "dark green",
      "grey",
      "brown"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Thomas Lefebvre",
     "page": "https://unsplash.com/photos/34mvN5nt1ls"
    },
    {
     "id": 581,
     "alt": "Blue jellyfish drifting through bright blue water",
     "tone": "mid",
     "colours": [
      "bright blue",
      "pale blue",
      "white"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Lance Anderson",
     "page": "https://unsplash.com/photos/2Q8zDWkj0Yw"
    },
    {
     "id": 582,
     "alt": "Close view of a coyote lowering its head against a pale background",
     "tone": "light",
     "colours": [
      "tan",
      "cream",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Levi Saunders",
     "page": "https://unsplash.com/photos/NUMlxTPsznM"
    },
    {
     "id": 584,
     "alt": "Small group of horses in rough grassland under a white sky",
     "tone": "light",
     "colours": [
      "white",
      "olive green",
      "brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Bethany Legg",
     "page": "https://unsplash.com/photos/wJYricpDGM0"
    },
    {
     "id": 659,
     "alt": "Husky in a harness looking to the side against a dark background",
     "tone": "dark",
     "colours": [
      "charcoal",
      "white",
      "grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Alexander Dimitrov",
     "page": "https://unsplash.com/photos/KGy1GFeKBu4"
    },
    {
     "id": 718,
     "alt": "Coyote walking across open ground in front of a dark forest",
     "tone": "mid",
     "colours": [
      "grey",
      "tan",
      "dark brown"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.58,
     "by": "Josh Felise",
     "page": "https://unsplash.com/photos/mblYxasm0nk"
    },
    {
     "id": 783,
     "alt": "Close-up of a macaque's red face surrounded by thick grey fur",
     "tone": "mid",
     "colours": [
      "warm grey",
      "rose red"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Damir Kotoric",
     "page": "https://unsplash.com/photos/cSQw4uqF_Hc"
    },
    {
     "id": 790,
     "alt": "Deer standing among bare trees in soft golden morning haze",
     "tone": "light",
     "colours": [
      "pale gold",
      "sand",
      "soft brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "rebecca johnston",
     "page": "https://unsplash.com/photos/nuRYwOJryyk"
    },
    {
     "id": 935,
     "alt": "Frog covered in duckweed peeking out of a green pond",
     "tone": "mid",
     "colours": [
      "lime green",
      "moss green"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "delfi de la Rua",
     "page": "https://unsplash.com/photos/xorjaMB8W70"
    },
    {
     "id": 1020,
     "alt": "Brown bear on a rocky slope with snowy forest behind",
     "tone": "mid",
     "colours": [
      "grey brown",
      "pale grey",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.51,
     "by": "Adam Willoughby-Knox",
     "page": "https://unsplash.com/photos/_snqARKTgoc"
    },
    {
     "id": 1024,
     "alt": "Large bird of prey in flight with wings spread against a grey-blue sky",
     "tone": "mid",
     "colours": [
      "grey blue",
      "brown",
      "cream"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Niko Virtanen",
     "page": "https://nikovirtanen.com"
    },
    {
     "id": 1062,
     "alt": "Pug wrapped in a beige blanket lying on a bed",
     "tone": "light",
     "colours": [
      "warm beige",
      "cream",
      "soft brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Matthew Wiebe",
     "page": "https://unsplash.com/photos/2Ts5HnA67k8"
    }
   ]
  },
  "plants-and-flowers": {
   "title": "Plants and flowers",
   "note": "botanical close-ups, leaves and flowers",
   "sectors": [
    "health",
    "restaurant",
    "physical-product",
    "ecommerce",
    "fashion",
    "hospitality",
    "other"
   ],
   "keywords": [
    "plant",
    "flower",
    "botanic",
    "garden",
    "leaf",
    "floral",
    "skincare",
    "beauty",
    "herb",
    "natural",
    "bloom",
    "florist"
   ],
   "photos": [
    {
     "id": 306,
     "alt": "White water lily with yellow centre among dark lily pads",
     "tone": "dark",
     "colours": [
      "white",
      "dark green",
      "yellow"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 1.33,
     "by": "Schicka",
     "page": "https://unsplash.com/photos/6T7kfc3VitU"
    },
    {
     "id": 738,
     "alt": "Single white calla lily against a dark, almost black background",
     "tone": "dark",
     "colours": [
      "near black",
      "cream white",
      "soft green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 0.67,
     "by": "Marta Pawlik",
     "page": "https://unsplash.com/photos/yqmlajPxR3Y"
    },
    {
     "id": 798,
     "alt": "White daisy with a yellow and green centre on a white background",
     "tone": "light",
     "colours": [
      "white",
      "yellow",
      "green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 5,
     "ratio": 1.33,
     "by": "Aaron Burden",
     "page": "https://unsplash.com/photos/UvRMcIeXq9Y"
    },
    {
     "id": 940,
     "alt": "Blue-green succulent rosettes filling the frame from above",
     "tone": "mid",
     "colours": [
      "teal green",
      "dark green",
      "dusty purple"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 0.66,
     "by": "Annie Spratt",
     "page": "https://unsplash.com/photos/8mqOw4DBBSg"
    },
    {
     "id": 152,
     "alt": "Purple petunia flowers with dark green leaves, close-up",
     "tone": "dark",
     "colours": [
      "violet",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Steven Spassov",
     "page": "https://unsplash.com/photos/tVIqMgGlAG0"
    },
    {
     "id": 159,
     "alt": "Water drop hanging from the tip of a green leaf",
     "tone": "mid",
     "colours": [
      "leaf green",
      "soft green"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 2.03,
     "by": "Shyamanta Baruah",
     "page": "https://unsplash.com/photos/kxqvE41_07k"
    },
    {
     "id": 189,
     "alt": "Water droplets on a broad green leaf with fine veins",
     "tone": "mid",
     "colours": [
      "bright green",
      "lime green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.33,
     "by": "Buzo Jesús",
     "page": "https://unsplash.com/photos/pHM4a_RZSLE"
    },
    {
     "id": 239,
     "alt": "Hand holding a dandelion seed head against a dark background",
     "tone": "dark",
     "colours": [
      "black",
      "white",
      "dark green"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.25,
     "by": "Coley Christine",
     "page": "https://unsplash.com/photos/GyvMk5pPDXI"
    },
    {
     "id": 248,
     "alt": "Round cactus in a pot seen from above, softly blurred",
     "tone": "mid",
     "colours": [
      "sage green",
      "warm tan",
      "soft pink"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.49,
     "by": "Oliver Pacas",
     "page": "https://unsplash.com/photos/tZrrWkQT9MM"
    },
    {
     "id": 400,
     "alt": "Green plant with tight flower buds against a dark teal background",
     "tone": "dark",
     "colours": [
      "emerald green",
      "dark teal"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "John French",
     "page": "https://unsplash.com/photos/SNW4DWZEy8I"
    },
    {
     "id": 492,
     "alt": "Field of dandelion seed heads backlit beneath a bright sky",
     "tone": "mid",
     "colours": [
      "pale blue",
      "dark green",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.5,
     "by": "Jason Long",
     "page": "https://unsplash.com/photos/FOeDIUwYiSw"
    },
    {
     "id": 517,
     "alt": "Orange tree branches heavy with fruit against a clear blue sky",
     "tone": "light",
     "colours": [
      "sky blue",
      "orange",
      "green"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Brian Jimenez",
     "page": "https://unsplash.com/photos/ch-mPpglKjQ"
    },
    {
     "id": 631,
     "alt": "Water droplet resting at the centre of a vivid green leaf",
     "tone": "dark",
     "colours": [
      "vivid green",
      "black"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "贝莉儿 NG",
     "page": "https://unsplash.com/photos/C_-DQF-x-N0"
    },
    {
     "id": 844,
     "alt": "Close-up of green bamboo stalks in soft focus",
     "tone": "mid",
     "colours": [
      "bamboo green",
      "sage",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "kazuend",
     "page": "https://unsplash.com/photos/LVdiXyu2eTc"
    },
    {
     "id": 976,
     "alt": "Orange tulips blooming in a sunlit woodland",
     "tone": "mid",
     "colours": [
      "orange",
      "leaf green",
      "warm yellow"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.72,
     "by": "Ales Krivec",
     "page": "https://unsplash.com/photos/hLxqYJspAkE"
    },
    {
     "id": 977,
     "alt": "Small mushroom growing in moss with soft golden bokeh",
     "tone": "mid",
     "colours": [
      "moss green",
      "golden yellow",
      "cream"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "Manuel Barroso Parejo",
     "page": "https://unsplash.com/photos/M9F8VR0jEPM"
    }
   ]
  },
  "texture": {
   "title": "Texture",
   "note": "wood, stone, walls and abstract surfaces",
   "sectors": [
    "physical-product",
    "fashion",
    "agency",
    "studio-portfolio",
    "architecture",
    "other"
   ],
   "keywords": [
    "texture",
    "material",
    "wood",
    "stone",
    "surface",
    "wall",
    "paper",
    "fabric",
    "concrete",
    "glaze",
    "ceramic",
    "clay"
   ],
   "photos": [
    {
     "id": 41,
     "alt": "Water droplets beading on a smooth surface in shallow focus",
     "tone": "mid",
     "colours": [
      "blue grey",
      "warm brown"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.59,
     "by": "Nithya Ramanujam",
     "page": "https://unsplash.com/photos/fTKetYpEKNQ"
    },
    {
     "id": 115,
     "alt": "Raindrops scattered across a dark wet surface, close-up",
     "tone": "dark",
     "colours": [
      "charcoal",
      "blue grey"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Christian Hebell",
     "page": "https://unsplash.com/photos/A6S-q3D67Ss"
    },
    {
     "id": 210,
     "alt": "Old red brick wall with weathered mortar",
     "tone": "mid",
     "colours": [
      "brick red",
      "terracotta",
      "grey"
     ],
     "mono": false,
     "calm": "all",
     "quality": 4,
     "ratio": 1.5,
     "by": "Martin Wessely",
     "page": "https://unsplash.com/photos/sX4lxBWV0-A"
    },
    {
     "id": 564,
     "alt": "Curving orange sandstone walls of a narrow slot canyon",
     "tone": "dark",
     "colours": [
      "burnt orange",
      "rust",
      "dark brown"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Sebastian Boguszewicz",
     "page": "https://unsplash.com/photos/YG_Fxyqz9xg"
    },
    {
     "id": 609,
     "alt": "Smooth grey pebbles on a beach with a blurred wave behind",
     "tone": "mid",
     "colours": [
      "grey",
      "teal",
      "white"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.59,
     "by": "Jeremy Cai",
     "page": "https://unsplash.com/photos/mnF75FoPBWY"
    },
    {
     "id": 796,
     "alt": "Dark pebbles scattered across smooth, wind-rippled peach sand",
     "tone": "light",
     "colours": [
      "peach",
      "sand",
      "charcoal"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.85,
     "by": "Colton  Brown",
     "page": "https://unsplash.com/photos/aQoOdWdYpis"
    },
    {
     "id": 1002,
     "alt": "Satellite view of turquoise water swirling against golden land",
     "tone": "mid",
     "colours": [
      "turquoise",
      "gold",
      "deep navy"
     ],
     "mono": false,
     "calm": "right",
     "quality": 4,
     "ratio": 1.5,
     "by": "NASA",
     "page": "https://unsplash.com/photos/6-jTZysYY_U"
    },
    {
     "id": 766,
     "alt": "Roasted coffee beans filling the entire frame",
     "tone": "dark",
     "colours": [
      "dark brown",
      "espresso",
      "chestnut"
     ],
     "mono": false,
     "calm": "none",
     "quality": 3,
     "ratio": 1.5,
     "by": "Padurariu Alexandru",
     "page": "https://unsplash.com/photos/mqyMjCTWJyQ"
    }
   ]
  },
  "transport": {
   "title": "Transport",
   "note": "trains, boats, bicycles, cars and aircraft",
   "sectors": [
    "consumer-app",
    "event",
    "physical-product",
    "other",
    "hospitality"
   ],
   "keywords": [
    "train",
    "car",
    "bike",
    "bicycle",
    "boat",
    "plane",
    "transport",
    "mobility",
    "ride",
    "vehicle",
    "motor",
    "rail"
   ],
   "photos": [
    {
     "id": 133,
     "alt": "Two vintage cars parked before yellow garage doors on white wall",
     "tone": "light",
     "colours": [
      "bright yellow",
      "white",
      "silver grey"
     ],
     "mono": false,
     "calm": "none",
     "quality": 5,
     "ratio": 1.5,
     "by": "Dietmar Becker",
     "page": "https://unsplash.com/photos/8Zt0xOOK4nI"
    },
    {
     "id": 88,
     "alt": "Overhead view of cars on a multi-lane road in monochrome",
     "tone": "mid",
     "colours": [
      "grey",
      "charcoal"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 0.75,
     "by": "Barcelona",
     "page": "https://unsplash.com/photos/muC_6gTMLR4"
    },
    {
     "id": 328,
     "alt": "Cable car gondolas crossing above a misty snowy slope",
     "tone": "mid",
     "colours": [
      "teal blue",
      "white",
      "dark navy"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.33,
     "by": "Nathalie Gouzée",
     "page": "https://unsplash.com/photos/hSeq6sn2HeE"
    },
    {
     "id": 345,
     "alt": "Motion-blurred train passing through an underground station",
     "tone": "mid",
     "colours": [
      "black",
      "white",
      "warm grey"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.5,
     "by": "Mario Calvo",
     "page": "https://unsplash.com/photos/S_mEIfXRzIk"
    },
    {
     "id": 401,
     "alt": "Hot air balloon seen from below against a bright hazy sky",
     "tone": "light",
     "colours": [
      "pale blue",
      "white",
      "orange"
     ],
     "mono": false,
     "calm": "left",
     "quality": 4,
     "ratio": 1.5,
     "by": "Austin Ban",
     "page": "https://unsplash.com/photos/0fjGQmYCRW8"
    },
    {
     "id": 515,
     "alt": "Abandoned aircraft fuselage resting on a black sand plain",
     "tone": "mid",
     "colours": [
      "charcoal",
      "pale blue",
      "white"
     ],
     "mono": false,
     "calm": "bottom",
     "quality": 4,
     "ratio": 1.33,
     "by": "Jeff Sheldon",
     "page": "https://unsplash.com/photos/uWwN03Mg4Wg"
    },
    {
     "id": 655,
     "alt": "Vintage turquoise camper van parked in front of dark trees",
     "tone": "mid",
     "colours": [
      "turquoise",
      "white",
      "dark green"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.78,
     "by": "Kyle Szegedi",
     "page": "https://unsplash.com/photos/tRQfEwP5P_0"
    },
    {
     "id": 757,
     "alt": "View through a vintage car windscreen onto sunlit trees",
     "tone": "mid",
     "colours": [
      "warm tan",
      "leaf green",
      "cream"
     ],
     "mono": false,
     "calm": "none",
     "quality": 4,
     "ratio": 1.71,
     "by": "Josh Felise",
     "page": "https://unsplash.com/photos/11Q8ApbwkVI"
    },
    {
     "id": 1072,
     "alt": "Rusty vintage truck abandoned in a dry grassy field",
     "tone": "mid",
     "colours": [
      "rust",
      "straw",
      "grey"
     ],
     "mono": false,
     "calm": "top",
     "quality": 4,
     "ratio": 1.49,
     "by": "jamie mink",
     "page": "https://unsplash.com/photos/5Qwz2KyfIBE"
    },
    {
     "id": 1077,
     "alt": "Cyclists streaked by motion blur, black and white",
     "tone": "mid",
     "colours": [
      "grey",
      "black",
      "white"
     ],
     "mono": true,
     "calm": "none",
     "quality": 4,
     "ratio": 1.5,
     "by": "Maico Amorim",
     "page": "https://unsplash.com/photos/SJWPKMb9u-k"
    }
   ]
  }
 }
}
library/sections.json›
[
 {
  "id": "features-capabilities-at-poster-scale",
  "role": "features",
  "name": "Capabilities at poster scale",
  "shape": "list",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "hero-object",
   "loud-flat-poster",
   "borrowed-category",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "No images. The heading, two to four words, is the first line of the list itself, set at the same size as the items but in the soft ink, so no separate title sits above. Five to eight capability names follow, one per line, at clamp(40px, 5.2vw, 84px), medium to heavy weight, leading 1.0, tight tracking, flush left on the page margin across columns 1–8 of 12. Each name's one-sentence description is set at 16–18px in columns 9–12, its first baseline aligned with the name's baseline (CSS grid, align-items: first baseline). Rows are separated by 0.35em of space and nothing else. On hover one row keeps full ink while the others fall to 35% opacity. Leave 18–22vh of empty ground above and below; nothing else shares the section.",
  "mobile": "Names drop to 11vw and stay one per line; a name that will not fit breaks onto a second line with a hanging indent. Each description sits directly under its name at 16px, indented one margin width, with 28px before the next name. The soft-ink heading line stays as the first row. All rows at full ink.",
  "seenIn": [
   "aww-cominvi",
   "aww-cerebrium",
   "opl-specia1ne"
  ]
 },
 {
  "id": "features-pinned-heading-passing-rows",
  "role": "features",
  "name": "Pinned heading, passing rows",
  "shape": "pinned",
  "tags": [
   "colour-field",
   "type-led"
  ],
  "directions": [
   "pinned-poster",
   "loud-flat-poster",
   "colour-chapters",
   "split-stage",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "One flat saturated field, full-bleed, as tall as its content. Columns 1–5: the heading, two or three words, at clamp(56px, 6.6vw, 104px) in the heaviest weight, position: sticky; top: 32px, so it holds while the right side travels. Columns 7–12: four to six capability blocks stacked with 120px between them. Each block is a title at 28px bold, then a two-part row: a portrait photograph at 3:4, 180–220px wide with square corners, and beside it, top-aligned, a plain list of four to six short lines at 18px with 14px between lines. Photographs alternate between the left and the right of their list, so the column does not read as a ladder. One ink colour, chosen for contrast on the field. The heading releases when the bottom of the last block reaches it.",
  "mobile": "The heading becomes a sticky bar at the top of the section: one line at 9vw on the field colour with 12px padding, holding while blocks pass under it. Each block stacks its title, a full-width 4:3 photograph, then the list at 17px. 72px between blocks.",
  "seenIn": [
   "opl-protip-marketing",
   "aww-cerebrium",
   "aww-sharplink"
  ]
 },
 {
  "id": "features-one-scene-stepping-statements",
  "role": "features",
  "name": "One scene, stepping statements",
  "shape": "full-bleed",
  "tags": [
   "photo-led"
  ],
  "directions": [
   "place-carries-it",
   "pinned-poster",
   "dark-room",
   "hero-object",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "A wrapper 350–450vh tall holds a sticky full-bleed photograph (100vw by 100svh) with a calm, symmetrical subject and a clear upper third; a neutral dark scrim covers only that upper third. At top centre, five or six short statements of two to five words are stacked at 18–20px with 10px between them; the first of them is the section's heading. One statement is at full white and the rest at 45% opacity; the active line steps down the stack as the reader scrolls (IntersectionObserver on invisible 70vh markers). For each statement, two small photographs with square corners, 160–240px wide and of different ratios, fade in left and right of the centre line at different heights, then leave when the next pair arrives. No other text. A low-key image on a deep ground works equally well.",
  "mobile": "The photograph stays sticky at 100svh, cropped to its centre. The statement stack moves to the bottom third over a scrim, at 17px, left-aligned on the margin. Each statement brings one small photograph only, 44vw wide, placed in the top-right corner with a 16px margin.",
  "seenIn": [
   "aww-seasats",
   "opl-eagles-nest"
  ]
 },
 {
  "id": "features-stacking-colour-sheets",
  "role": "features",
  "name": "Stacking colour sheets",
  "shape": "stack",
  "tags": [
   "colour-field",
   "poster"
  ],
  "directions": [
   "colour-chapters",
   "page-is-the-object",
   "pinned-poster",
   "loud-flat-poster",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Three to five capabilities, each a full-width flat colour sheet about 86svh tall with only its top corners rounded at 28px. Every sheet is position: sticky, with top offsets rising by 72px from one sheet to the next, so each new sheet slides up over the last and leaves the previous sheet's title band showing, like tabbed folders in a drawer. Inside a sheet: the capability name top-left at clamp(28px, 3vw, 44px), sitting within that 72px band; a paragraph of up to 40ch at 18px bottom-left; and in columns 7–12 one photograph filling the sheet from below the band to the bottom edge, square corners, no inset. Sheet colours follow the palette in a fixed order, with ink chosen per sheet. The heading, two to four words at clamp(40px, 5vw, 80px), sits on the ground above the stack with 48px beneath and scrolls away normally.",
  "mobile": "Sheets stay sticky but the offset shrinks to 52px and the name drops to 22px so it fits the visible band. Inside each sheet the photograph comes first at 4:3 and full width, then the paragraph at 17px with 20px padding. Sheets are as tall as their content, 70svh at least.",
  "seenIn": [
   "aww-hilden-kaira",
   "opl-nia-creative",
   "opl-april-leihuanani-evangelista",
   "aww-mosbys-files"
  ]
 },
 {
  "id": "features-mosaic-of-unlike-tiles",
  "role": "features",
  "name": "Mosaic of unlike tiles",
  "shape": "mosaic",
  "tags": [
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "editorial-report",
   "colour-chapters",
   "specimen-catalogue",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A 12-column grid with 8px gaps and rows of 96px, filled with no empty cells and no outlines. Six to eight filled tiles in four sizes only: 6×5, 6×3, 3×3 and 3×2 (columns × rows). The rule that keeps it from being a card grid: no two neighbouring tiles hold the same kind of thing. One tile is the heading alone, two to four words at 4vw, top-left. One is a photograph covering its tile. One is a single figure at 6vw with a caption. One is a short list of four lines at 20px. The others are a single sentence each at 28–32px. Text sits top-left with 24px padding; tiles holding one sentence leave their lower half empty. Tile fills: the ground one tone darker, two palette colours, and photographs. One corner radius everywhere, either 0 or 8px.",
  "mobile": "Two columns with 6px gaps. The heading tile and the photograph tile span both columns; sentence tiles span both at their natural height; the figure and list tiles sit side by side as a pair of squares. Order: heading, photograph, pair, sentence, sentence.",
  "seenIn": [
   "opl-sand-studio",
   "opl-builders-table",
   "aww-cominvi",
   "opl-uglycash"
  ]
 },
 {
  "id": "features-statement-cell-dark-rows",
  "role": "features",
  "name": "Statement cell, dark rows",
  "shape": "split",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "dense-tiles",
   "editorial-report",
   "type-only-statement",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Two halves butted together, each 50vw wide and at least 90svh tall, with no gap. Left half, in a pale tone: the heading in three short lines at clamp(36px, 4.2vw, 68px), its middle line pushed flush right while the first and third stay flush left (text-align set per line), so the block zigzags inside the cell. A two-sentence paragraph of 30ch sits at the bottom-left and the single action at the bottom-right on the same baseline. Right half, in the darkest tone: four or five rows of equal height filling it, told apart by alternating two near-black tones, not by lines. Each row is a three-part grid: a title at 28px (5 parts), a short paragraph at 16px (4 parts) and a plain list of three terms at 15px (3 parts), all top-aligned with 28px padding. No images anywhere in the section.",
  "mobile": "The halves stack. The light cell comes first at 70svh with the zigzag heading at 10vw and the paragraph at its foot. The dark rows follow at full width; each row stacks its title and paragraph, and its three terms run inline as one sentence separated by commas. Rows keep the alternating tones.",
  "seenIn": [
   "aww-aspen-search"
  ]
 },
 {
  "id": "features-staggered-fragment-cells",
  "role": "features",
  "name": "Staggered fragment cells",
  "shape": "grid",
  "tags": [
   "ui-led",
   "dense"
  ],
  "directions": [
   "product-performs",
   "dense-tiles",
   "quiet-instrument"
  ],
  "needs": [
   "product-ui"
  ],
  "blueprint": "A three-column grid of six filled cells with 8px gaps, each cell 420–460px tall and filled one tone off the ground, no outlines. Every cell has two parts: a text part (a title at 17px medium and three lines at 15px in the soft ink, 24px padding) and a real fragment of the interface at true size (a control, a menu, a table slice, a swatch strip) docked flush against the opposite edge of the cell and cropped by it. The stagger is the idea: across the first row cells run text-top, fragment-top, text-top; the second row runs the reverse, so fragments form a checkerboard. Fragments differ in shape and size on purpose. The heading, two to four words at 32–40px, sits left-aligned above the grid with 56px beneath it and no supporting line.",
  "mobile": "One column of cells at their natural height with 6px gaps. Every cell runs text first, fragment second, but fragments alternate between docking to the left edge and the right edge, and they are allowed to run off the side of the cell at true size instead of shrinking.",
  "seenIn": [
   "opl-dabbit",
   "opl-woblo"
  ]
 },
 {
  "id": "features-small-proof-beside-text",
  "role": "features",
  "name": "Small proof beside text",
  "shape": "list",
  "tags": [
   "quiet",
   "ui-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "split-stage",
   "name-as-monument",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "One narrow module repeated four to six times down the page, never alternating sides. Each module is 640px wide at most and starts on the page's text margin (at about 22% of the viewport on an understated page). Left part: a flat panel 360px wide and 120 to 200px tall, one tone off the ground, radius 8px, holding one small exact demonstration: a cropped interface fragment at true size, or a tightly cropped photograph covering the panel. Right part, 20px away and top-aligned: a heading of one or two lines at 18px medium and two short paragraphs at 15px in the soft ink, in a column 250px wide. Panel heights vary with their content; text always hangs from the panel's top edge. 96–120px of empty ground between modules. The section heading is 24–28px, on the same left edge, 64px above the first module. In a column under 600px the text moves beneath its panel.",
  "mobile": "Each module stacks: the panel at full width and natural height, then heading and paragraphs 16px below on the same left edge, at 17px and 15px. 64px between modules. Panels keep their true-size contents and crop from the right instead of scaling down.",
  "seenIn": [
   "opl-rows"
  ]
 },
 {
  "id": "features-comparison-set-as-text",
  "role": "features",
  "name": "Comparison set as text",
  "shape": "table",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "type-only-statement",
   "editorial-report",
   "product-performs",
   "quiet-instrument",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "A comparison table with no lines and no tick marks. A CSS grid: capability names in columns 1–5 of 12 at 22–24px medium, then three answer columns of equal width (plans, methods, or this against the usual way). Every cell answers in words at 17px: 'Yes, unlimited', 'Up to five', 'Not yet', never a symbol. Seven to ten rows, each 64–72px tall, with every other row on a ground one tone darker so the eye tracks across; rows touch. The column that matters is one filled block in the accent or ink colour running the full height of the table behind its cells and rising 24px above the first row, with its text reversed. Column names sit in the top row at 17px bold. The heading, two to four words at 40–48px, hangs top-left above column 1 with 48px beneath.",
  "mobile": "The table turns on its side: one block per capability. The name at 20px bold, then three lines beneath, each giving a column name and its answer at 16px; the featured column's line is set on the filled colour at full width. Blocks alternate the two ground tones.",
  "seenIn": [
   "aww-pp-neue-montreal"
  ]
 },
 {
  "id": "features-one-size-name-columns",
  "role": "features",
  "name": "One-size name columns",
  "shape": "index",
  "tags": [
   "quiet",
   "type-led",
   "dense"
  ],
  "directions": [
   "contact-sheet",
   "quiet-instrument",
   "name-as-monument",
   "dark-room",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "Pure text at one size. Three columns start on columns 1, 4 and 7 of the 12-column grid, leaving columns 10–12 empty on purpose. Each column opens with a group name in full ink and continues with a tight stack of six to ten capability names in the soft ink, all at 16–17px, regular weight, line-height 1.25, no bullets and no symbols; one blank line separates group name from stack. Hovering a name brings it to full ink and shows a one-line description in a fixed spot: the top of the empty columns 10–12, at the same size. The heading is no larger than 28px; two or three words, alone on column 1, 96px above the columns. Give the section 20vh of empty ground above and below, so the smallness reads as decided. Works on light or deep grounds.",
  "mobile": "Columns stack with 40px between groups; text rises to 18px with line-height 1.4 for touch. Group names stay in full ink. Hover descriptions are dropped; each group ends instead with one sentence in the soft ink that sums it up. The heading is 24px, 48px above.",
  "seenIn": [
   "aww-revelatio-studio",
   "aww-pp-neue-montreal",
   "aww-lama-lama-2"
  ]
 },
 {
  "id": "features-specimens-on-colour-tiles",
  "role": "features",
  "name": "Specimens on colour tiles",
  "shape": "grid",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "specimen-catalogue",
   "hero-object",
   "one-motif",
   "colour-chapters",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Each capability is represented by one object photographed alone and shown on a flat colour tile. Columns 1–3 hold the heading (two to four words at 40–52px), top-aligned, with a 30ch sentence beneath and nothing else down the rest of that column. Columns 4–12 hold a grid of five or six tiles in two rows with 10px gaps: tiles are 4:5 except one, which is double width at 8:5 and placed second in the first row. Tile colours rotate through three palette fields. Each photograph is centred at about 70% of its tile's height; on light tiles use mix-blend-mode: multiply so a white studio background disappears. Under each tile, outside it and aligned to its left edge: the capability name at 20px medium and one fact at 16px in the soft ink. No text on the tiles and no hover lift.",
  "mobile": "The heading and its sentence sit on top at full width. Tiles become a two-column grid with 8px gaps; the double tile spans both columns at 8:5 and comes first. Names shrink to 17px and facts to 15px, still under each tile.",
  "seenIn": [
   "aww-noho",
   "aww-coffeetech-r",
   "opl-when-you-wish-upon-a-starboy"
  ]
 },
 {
  "id": "features-statements-on-colour-fields",
  "role": "features",
  "name": "Statements on colour fields",
  "shape": "statement",
  "tags": [
   "colour-field",
   "poster",
   "type-led"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "type-only-statement",
   "sentence-as-layout",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Three or four full-bleed flat colour fields, each 72–80svh tall, butted together with no gap. Each field holds one benefit and is almost empty. The benefit is a phrase of two to four words at clamp(56px, 9vw, 150px), heavy, leading 0.9, on two lines at most, hugging the bottom-left corner with 28px margins. Its explanation is three lines at 17–18px, 30ch wide, placed near the top at column 8, so the eye travels the diagonal from small text to big phrase. On alternate fields the arrangement mirrors: phrase bottom-right and right-aligned, paragraph top-left at column 2. Text on each field is ink or paper, whichever contrasts more. There is no separate section heading: the first field's phrase is the heading. Nothing else appears on the fields: no images, shapes or buttons.",
  "mobile": "Fields are 64svh tall. The paragraph sits top-left at 16px across 80% of the width; the phrase sits bottom-left at 13vw on two lines. Mirroring is dropped; alternate fields right-align the paragraph only, keeping the phrase on the left margin.",
  "seenIn": [
   "opl-anidachi",
   "aww-px-push"
  ]
 },
 {
  "id": "features-rows-with-margin-photographs",
  "role": "features",
  "name": "Rows with margin photographs",
  "shape": "list",
  "tags": [
   "type-led",
   "photo-led"
  ],
  "directions": [
   "editorial-report",
   "uneven-photographs",
   "place-carries-it",
   "sentence-as-layout",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "Everything hangs from one vertical axis at column 5, about 35% of the width. First the heading: a statement of up to five words at clamp(40px, 5vw, 80px) starting on the axis, vertically centred in an otherwise empty 70svh of ground. Then four or five benefit rows, each starting on the same axis: a title at clamp(28px, 3vw, 48px) and under it a paragraph of 34ch at 17px. Columns 1–4 stay empty. At the right margin each row carries one photograph, top-aligned with its title and deliberately unequal: 240px wide at 4:5 in one row, 320px at 3:2 in the next, 180px square after that. Rows are separated by 96–120px of space, not lines. On a saturated field, set all type in one pale ink and leave the photographs ungraded.",
  "mobile": "The axis collapses to the left margin. The statement sits at 9vw in 50svh of empty ground. Each row stacks its title at 26px, the paragraph, then its photograph at alternating widths: 100%, 70% flush right, 55% flush left. 64px between rows.",
  "seenIn": [
   "aww-px-push"
  ]
 },
 {
  "id": "features-paragraph-with-picture-words",
  "role": "features",
  "name": "Paragraph with picture words",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "sentence-as-layout",
   "one-motif",
   "product-performs",
   "editorial-report",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "One paragraph does the work of a feature grid. Three or four sentences, 35–50 words in total, set at clamp(30px, 3.6vw, 60px), leading 1.18, medium weight, across columns 1–11, left-aligned. Each sentence names one capability, and directly after that capability's key noun sits an inline slot: an inline-block frame 1em tall and 1.9em wide, radius 0.18em, dropped 0.12em below the baseline, holding a tight photograph crop (object-fit: cover) or a real interface control at readable size. Four slots at most, never two on one line. The nouns keep the weight and colour of the rest; the slots are the only emphasis. Mark it up as a paragraph and give the section a visually hidden heading of two or three words; nothing visible sits above. 72px lower, starting at column 7, one supporting sentence at 18px and the action.",
  "mobile": "The paragraph drops to 7vw with leading 1.22 and fills the width. Slots stay inline at 1em tall but narrow to 1.5em wide. If a slot lands alone at the start of a line, let it; do not shrink the type. The supporting sentence and the action follow at full width.",
  "seenIn": [
   "aww-monolog",
   "opl-uncle-rudy",
   "aww-cominvi",
   "opl-midnight-2"
  ]
 },
 {
  "id": "features-capabilities-as-a-menu",
  "role": "features",
  "name": "Capabilities as a menu",
  "shape": "table",
  "tags": [
   "object-led",
   "type-led"
  ],
  "directions": [
   "page-is-the-object",
   "split-stage",
   "borrowed-category",
   "type-only-statement",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "The capabilities are set as a printed menu or price list. One paper-coloured sheet, 520–600px wide and taller than it is wide, sits on a contrasting flat ground, centred or starting at column 2; its top and bottom edges are cut into shallow scallops or a zigzag with clip-path, its sides straight. On it, centred at the top, the heading: two or three words at 44–56px, then 40px of space. Below, items in two or three groups; each group opens with its name at 20px bold. Each item is a row: the name at 19px on the left, a short value on the right (a price, a time, a quantity, 'included'), and between them a leader of round 4px dots (border-bottom: 4px dotted, sitting on the baseline). A description at 15px in the soft ink sits under the name, at most 34ch. 18px between items, 44px between groups.",
  "mobile": "The sheet fills the width minus 12px margins and keeps its cut top and bottom edges. Names stay left and values right on one line with the dot leader between; descriptions wrap beneath at 15px. Group names drop to 18px. Padding inside the sheet is 20px.",
  "seenIn": [
   "opl-the-content-architecture",
   "aww-pensatori-irrazionali"
  ]
 },
 {
  "id": "features-shape-marked-small-points",
  "role": "features",
  "name": "Shape-marked small points",
  "shape": "split",
  "tags": [
   "object-led",
   "quiet"
  ],
  "directions": [
   "one-motif",
   "hero-object",
   "product-performs",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "For the many minor points that do not deserve a row each. Columns 1–5: one photograph inside a single large shape taken from the subject's own geometry (a circle, an arch, a chamfered block), made with clip-path or border-radius, about 78svh tall and pushed 12% past the section's left edge so the viewport crops it. Columns 7–12: the heading, two to four words at 40–48px, then 56px lower a grid of eight to twelve points in three columns with 40px column gaps and 44px row gaps, no cells and no fills. Each point is a title at 17px medium preceded by the same shape drawn at cap height (0.7em, flat fill, inline SVG or a styled span), then two lines at 15px in the soft ink. The small shapes take a different palette colour per column, which is the only colour on the right side.",
  "mobile": "The shaped photograph comes first, 82vw wide, cropped by the left edge and no taller than 60svh. The heading follows at 30px. Points become one column with 28px between them; the small shape hangs in the margin to the left of each title, so titles and text share one edge.",
  "seenIn": [
   "opl-dabbit",
   "aww-sstr-friction-reduction"
  ]
 },
 {
  "id": "showcase-two-giant-words-compared",
  "role": "showcase",
  "name": "Two giant words compared",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "editorial-report",
   "loud-flat-poster",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "No photographs: the thing shown is a pair of words, characters or figures that make the point by comparison (before and after, input and output, ours and the usual). At the top of the section, on one row: the heading, two to four words at 28–36px, at the left margin, and a paragraph of up to 60 words at 17px starting at column 7. Then 12vh of space. Below, two specimens side by side, one starting on column 1 and one on column 7, each set at clamp(180px, 34vw, 520px) with leading 0.8, so each fills about half the viewport's width. The section has a fixed height and overflow: hidden, so its bottom edge cuts both specimens at roughly 60% of their height. One short caption at 16px sits above the top-left of each specimen, naming it. Both specimens share one ink; the difference is in the letterforms or values, not in colour.",
  "mobile": "Heading first, then the paragraph, at full width. The two specimens stack, each at 62vw type size in its own 38svh box with overflow hidden so the lower part is cut. Captions sit above each box at 15px. The second box takes the inverse colours of the first.",
  "seenIn": [
   "aww-pp-neue-montreal"
  ]
 },
 {
  "id": "showcase-two-objects-two-fields",
  "role": "showcase",
  "name": "Two objects, two fields",
  "shape": "split",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "specimen-catalogue",
   "hero-object",
   "colour-chapters",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Two panels side by side, each 50vw wide and 92svh tall, in two different flat tones from the palette, meeting on a clean vertical edge with no gap. Each panel is dominated by one cut-out object photograph scaled to 130–150% of the panel's width and positioned so the panel's top edge crops it: the reader sees the lower two-thirds of the object, very close. On light panels use mix-blend-mode: multiply to lose a white studio background. Along the bottom of each panel, 28px in from the edges, one row: the name at 20px medium and a price or fact at 17px on the left; on the right, two small rectangular actions, one filled with ink and one with paper. Nothing sits in the middle of a panel. The heading, two to four words at 32–40px, is set at the top-left of the first panel only, in the empty corner beside the object.",
  "mobile": "Panels stack, each 78svh tall, the object scaled to 150% of the width and cut by the top and right edges. The bottom row becomes two lines: name and fact, then the two actions side by side at full width. The heading sits top-left on the first panel at 26px.",
  "seenIn": [
   "aww-noho"
  ]
 },
 {
  "id": "showcase-object-in-front-of-type",
  "role": "showcase",
  "name": "Object in front of type",
  "shape": "statement",
  "tags": [
   "object-led",
   "poster"
  ],
  "directions": [
   "hero-object",
   "borrowed-category",
   "loud-flat-poster",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "Type and object share one space instead of sitting side by side. Three or four lines of display type, one or two words each, are stacked flush left at clamp(72px, 9vw, 150px) with leading 0.88, starting on the left margin and allowed to leave the right edge (white-space: nowrap; overflow: hidden on the section). The lines step through tones of one ink: one at 45%, one at full strength, the rest at 18%. The object photograph, a cut-out or a studio shot blended with multiply, is absolutely positioned over the upper right of the stack at 40–46vw wide, rotated no more than 6 degrees and above the type in the stacking order, so it hides parts of three or four letters. Under the stack, in columns 8–12, a two-paragraph block at 20px gives the facts. The full-strength line is the heading; nothing sits above it.",
  "mobile": "Type drops to 15vw and still runs off the right edge. The object sits at 78vw wide, overlapping the last two lines from below instead of from the right, and is cropped by the right edge. The facts block follows at full width and 17px.",
  "seenIn": [
   "aww-the-watch",
   "opl-aevion",
   "aww-coffeetech-r"
  ]
 },
 {
  "id": "showcase-band-of-unequal-panels",
  "role": "showcase",
  "name": "Band of unequal panels",
  "shape": "strip",
  "tags": [
   "photo-led"
  ],
  "directions": [
   "place-carries-it",
   "uneven-photographs",
   "name-as-monument",
   "editorial-report",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A modest heading, then one wide band of pictures. The heading, two to four words at 36–44px, starts at column 3, left of centre, with 64px beneath it. The band spans the full viewport width edge to edge and is 68svh tall: four photographs standing side by side with no gaps and deliberately unequal widths, a flex row with flex-grow values of 5, 2, 3 and 2. Each panel is cropped with object-fit: cover to a tall slice, so the band reads as one folded view. The first panel carries a two-line title at 28px in paper colour at its top-left, 24px in, over a neutral scrim that covers only that corner. Hovering a panel raises its flex-grow to 6 over 500ms while the others give way; without hover the widths simply stay. One caption line at 16px sits under the band at the left margin.",
  "mobile": "The heading sits on the margin at 28px. The band becomes a two-by-two block with no gaps, 84svh tall in total: the first row split 64/36 and the second 36/64, so the slices stay unequal. The title stays on the first panel. No hover behaviour.",
  "seenIn": [
   "aww-white-desert",
   "aww-tengile-malamala-collection"
  ]
 },
 {
  "id": "showcase-close-up-with-column-card",
  "role": "showcase",
  "name": "Close-up with column card",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "object-led"
  ],
  "directions": [
   "hero-object",
   "place-carries-it",
   "dark-room",
   "borrowed-category",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "A full-bleed close-up photograph, 100vw by 100svh, with nothing over most of it. A four-column grid lies on top. One solid card in the ink colour, exactly one grid column wide (about 22vw) and 52svh tall, sits in the second column, vertically centred. Inside it, centred: the item's name at 30–36px, a variant line at 17px, the price or key fact at 17px, a small 3:2 photograph of the whole object (the card shows the whole, the background shows the detail), and at the foot a square-cornered action filling the card's width. The card is opaque, radius 0–6px, with no blur. When the section presents two or three items, each gets its own full-bleed frame and its card moves one column to the right each time, so position carries the sequence. The first card's name is the section heading.",
  "mobile": "The photograph fills 100svh, cropped to its most detailed area. The card becomes a full-width block docked to the bottom, 46svh tall, left-aligned inside with 20px padding; the small whole-object photograph sits to the right of the name at 96px wide.",
  "seenIn": [
   "aww-longbow"
  ]
 },
 {
  "id": "showcase-live-demo-with-controls",
  "role": "showcase",
  "name": "Live demo with controls",
  "shape": "split",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "product-performs",
   "quiet-instrument",
   "dense-tiles"
  ],
  "needs": [
   "product-ui"
  ],
  "blueprint": "One filled panel, 680–760px wide and about 640px tall, radius 12px, in a tone clearly darker or lighter than the ground, placed on the text column's left edge or centred, with at least 15vh of empty ground above and below. The panel is split 62/38 into two flat areas that differ by one tone. Left area: the product doing its job on real content at true size. Right area: a narrow column of working controls, six to nine stacked rows 44px tall, each with a name at 14px on the left and a slider, toggle or value on the right; changing one changes the demonstration at once. A text link sits in the panel's bottom-left corner. The heading, two to four words at 24–32px, sits above the panel on its left edge with one sentence at 16px; under the panel, one line of start instruction.",
  "mobile": "The panel fills the width. The demonstration takes the top at 4:3; the controls follow beneath as full-width rows 52px tall, only the four most telling ones, with a text link that reveals the rest. Heading and sentence sit above at 24px and 16px.",
  "seenIn": [
   "opl-dialkit",
   "opl-textmode-overlay"
  ]
 },
 {
  "id": "showcase-one-frame-three-distances",
  "role": "showcase",
  "name": "One frame, three distances",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "pinned-poster",
   "hero-object",
   "dark-room",
   "sentence-as-layout",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A wrapper 300vh tall holds a sticky stage of 100svh. On the stage one frame is centred: 30vw wide at 4:5, square corners, overflow hidden. Inside it the same photograph is shown at three distances as the reader scrolls: the whole subject, then scaled to 180% toward one detail, then to 320% on a second detail (transform: scale with a transform-origin per stage, 700ms ease, switched by IntersectionObserver on three invisible markers). The sentence is delivered in halves: its first two or three words sit above the frame at clamp(56px, 8vw, 130px), centred; the last words arrive below the frame at the second stage, overlapping the frame's bottom edge by 0.3em. At the third stage one caption of 28ch at 17px appears to the right of the frame, top-aligned. The two halves together are the heading and stay within two lines.",
  "mobile": "The frame grows to 70vw at 4:5 and stays sticky at centre. The first half of the sentence sits above it at 13vw, the second half below it at the same size. The third-stage caption appears under the sentence at 16px, centred.",
  "seenIn": [
   "aww-vero-new-york",
   "aww-illoca",
   "opl-screentune"
  ]
 },
 {
  "id": "showcase-one-image-across-tiles",
  "role": "showcase",
  "name": "One image across tiles",
  "shape": "mosaic",
  "tags": [
   "photo-led",
   "dense"
  ],
  "directions": [
   "dense-tiles",
   "contact-sheet",
   "editorial-report",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "One photograph is cut across a board of tiles. A grid of six columns and four rows with 8px gaps spans the full width between 12px margins; every tile is square (aspect-ratio: 1). Each tile shows its own piece of the same image: set the photograph as every tile's background at background-size: 600% 400% and step background-position by column and row, so the picture reads whole through the gaps. Five of the twenty-four tiles are swapped for flat colour tiles from the palette, never two adjacent: one holds the heading (two to four words at 2.6vw, top-left, 16px padding), three hold one fact each at 18px, one holds the action. On hover a tile's piece scales to 108% inside its own clip. Choose a photograph with one clear subject, so the board is legible at a glance.",
  "mobile": "Three columns by six rows with 6px gaps, the photograph re-cut at background-size 300% 600% from a portrait crop. Four colour tiles only; the heading tile spans two columns in the top row with the heading at 6vw. Facts drop to 15px.",
  "seenIn": [
   "aww-produx-design",
   "aww-ai-in-design-report-2026"
  ]
 },
 {
  "id": "showcase-frame-run-one-lifted",
  "role": "showcase",
  "name": "Frame run, one lifted",
  "shape": "strip",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "contact-sheet",
   "uneven-photographs",
   "specimen-catalogue",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "Columns 1–3 are a nearly empty column: the heading (two to four words at 24–28px) at the top, a link under it, and one or two short notes at 15px much further down, each level with the frame it describes. Columns 4–12 hold a run of six to eight frames of the same subject at one fixed height of 30vh, with natural widths and 6px gaps: the whole view, a tight crop of the same thing, a detail, the reverse side. The run is allowed to leave the right edge. One frame is lifted out of the row: shown at 2.2 times the row height with its bottom aligned to the row's bottom (flex row, align-items: end), so it stands above the others. Under each frame, at 15px: a name on the left and a single fact on the right. Square corners, no captions on the images.",
  "mobile": "The heading and link sit on top. The lifted frame comes first at full width. The rest of the run scrolls sideways beneath it at a fixed height of 34vw with 4px gaps and scroll-snap, bleeding off the right edge. Notes are listed under the run at 15px.",
  "seenIn": [
   "aww-serotoninn",
   "aww-butter",
   "aww-no-art"
  ]
 },
 {
  "id": "showcase-output-slips-along-the-edge",
  "role": "showcase",
  "name": "Output slips along the edge",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "object-led"
  ],
  "directions": [
   "page-is-the-object",
   "borrowed-category",
   "place-carries-it",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "For subjects whose result is a piece of text or paper: a receipt, a note, a ticket, a written line. A full-bleed photograph, 100vw by 96svh, sets the scene. In the upper centre, left-aligned in a column starting at 18% of the width, two short paragraphs in paper colour at 28–32px, the first opening with the heading's two to four words; a neutral scrim sits behind that column only. Along the bottom, six paper-coloured slips stand side by side across the full width with 12px gaps, each a filled rectangle of a different height (between 26 and 44svh), their lower ends cut off by the section's bottom edge. Two or three of the slips, not all, are tilted by 1–2 degrees. Each slip carries a sample of real output in the text face at 15px: a bold first line, a divider made of typed characters, then four to eight lines.",
  "mobile": "The photograph is 100svh. The paragraphs sit in the top third at 22px. Only three slips show, each 30vw wide, standing along the bottom at different heights, and the row scrolls sideways to reveal the rest. Slip text stays at 14px; do not scale it down.",
  "seenIn": [
   "opl-poetry-camera"
  ]
 },
 {
  "id": "showcase-object-on-a-disc",
  "role": "showcase",
  "name": "Object on a disc",
  "shape": "index",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "one-motif",
   "specimen-catalogue",
   "hero-object",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "One section about a viewport tall on a plain ground. A flat filled disc 64vh across, in one palette colour, sits with its centre at 58% of the width; swap the disc for the subject's own shape if it has one. A cut-out object photograph stands in front of it, centred on it, about 78% of the disc's height and breaking the disc's outline at the top. Bottom-left, starting on the margin and ending level with the disc's lower edge: a vertical list of four to six item names at clamp(28px, 2.9vw, 44px), leading 1.15. The selected name is in full ink and the rest at 25%; choosing a name swaps the object (300ms crossfade) and the disc's colour. The heading, two to four words at the same size as the names but in the soft ink, is the first line of that list. One fact about the selected item sits at the disc's right edge at 17px.",
  "mobile": "The disc comes first, 86vw across and centred, with the object in front. The names follow as a single sideways-scrolling line at 26px with 24px gaps, the selected one in full ink. The fact sits under the names at 16px.",
  "seenIn": [
   "aww-cominvi",
   "opl-tara"
  ]
 },
 {
  "id": "showcase-square-plate-spare-text",
  "role": "showcase",
  "name": "Square plate, spare text",
  "shape": "split",
  "tags": [
   "colour-field",
   "photo-led"
  ],
  "directions": [
   "split-stage",
   "page-is-the-object",
   "colour-chapters",
   "editorial-report",
   "one-motif",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One flat field split 50/50, at least 88svh tall. Left half: a single square photograph filling the half's width minus 40px margins, vertically centred, square corners. A large simple shape may be laid over it to turn the photograph into the object it stands for: a ring 86% of its width with a 6px stroke and a small solid dot at the centre makes a record; a notch cut by clip-path makes a ticket stub. Right half, a flex column with space-between: at the top a row of three plain text tabs at 18px (editions, years, variants), the active one in full ink and the others at 40%; in the middle the title, two to four words at 32–40px bold, with a 34ch description 48px below; along the bottom two filled rectangular cells side by side, each half the column wide and 72px tall, acting as the two actions, one in ink and one a tone off the field.",
  "mobile": "The square photograph comes first at full width with 16px margins. The tabs sit under it as one row at 17px. Then the title at 28px, the description, and the two action cells stacked, each full width and 60px tall.",
  "seenIn": [
   "aww-paul-kalkbrenner"
  ]
 },
 {
  "id": "how-it-works-lines-that-step-across",
  "role": "how-it-works",
  "name": "Lines that step across",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "sentence-as-layout",
   "loud-flat-poster",
   "editorial-report",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The sequence is written, not counted. Four lines of display type, one stage per line, each a complete short clause that begins with its order word: 'First you send it.' 'Then we take it apart.' 'You choose one.' 'It ships on Friday.' Set at clamp(36px, 4.6vw, 76px), leading 1.0, one line each. The first line starts on column 1 and each following line starts two columns further right (grid-column-start 1, 3, 5, 7), so the block descends as a staircase and the eye follows the diagonal. Under each line, on that line's own left edge and 12px below, a sentence of up to 34ch at 17px in the soft ink. 56px between stages. The last line may take the accent colour as the single emphasis. The first line serves as the heading; there is no title above. Leave the columns to the left of the final stage empty.",
  "mobile": "Lines drop to 8.4vw and may wrap onto a second line. The staircase becomes a smaller indent of 6vw per stage, so the fourth line starts 18vw in. Each sentence sits under its line at 16px on the same indent. 40px between stages.",
  "seenIn": [
   "opl-rendez-vous-franceclat"
  ]
 },
 {
  "id": "how-it-works-photographs-down-a-diagonal",
  "role": "how-it-works",
  "name": "Photographs down a diagonal",
  "shape": "stack",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "uneven-photographs",
   "place-carries-it",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "Three stages, three photographs of different sizes placed along a falling diagonal on the 12-column grid. Stage one: a 3:4 portrait in columns 1–4. Stage two: a 1:1 square in columns 6–8, its top edge 220px lower than the first photograph's top. Stage three: a 16:10 landscape in columns 8–12, starting 180px below the bottom of the second. Each stage's text sits in the gap the stagger leaves: a paragraph of up to 36ch at 17px, top-aligned with its photograph and placed in the columns to its right (to its left for the third). Each paragraph opens with a bold run-in phrase that carries the order: 'On the first day', 'A week later', 'By the end'. Square corners; no arrows or connecting marks. The heading, two to four words at clamp(36px, 4vw, 64px) in a light weight, sits alone in columns 7–12 at the very top, level with the first photograph's top edge.",
  "mobile": "The diagonal becomes alternating widths: the first photograph at 100%, the second at 62% flush right, the third at 78% flush left. Each paragraph follows its photograph on that photograph's left edge at 16px. 56px between stages. The heading sits above at 30px.",
  "seenIn": [
   "opl-fynza",
   "opl-the-queue"
  ]
 },
 {
  "id": "how-it-works-drag-row-of-moments",
  "role": "how-it-works",
  "name": "Drag row of moments",
  "shape": "carousel",
  "tags": [
   "photo-led"
  ],
  "directions": [
   "contact-sheet",
   "place-carries-it",
   "uneven-photographs",
   "page-is-the-object",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A story told left to right. Top row: the heading, two to four words at 28–36px, at the left margin; at the right margin two solid square buttons, 48px each, for back and forward. Directly beneath, a solid bar 6px tall spans the full width in a soft tone, with a full-ink segment showing how far the row has been scrolled. Then the row itself: six to nine columns, each 280px wide with 32px gaps, in a horizontally scrolling flex container with scroll-snap that bleeds off both viewport edges. Every column reads top to bottom: a one-line caption at 18px bold that carries the order in words ('We met', 'Two winters on', 'The move'), three lines at 15px in the soft ink, then 80–140px of empty space that varies per column, then a 4:5 portrait photograph. Because the spaces vary, the photographs' top edges rise and fall along the row.",
  "mobile": "Columns are 72vw wide with 16px gaps, so the next one always shows at the right edge. The buttons are dropped; the 6px progress bar stays under the heading. The empty space inside columns shrinks to 32–64px but still varies from column to column.",
  "seenIn": [
   "opl-sarah-matt-wedding",
   "aww-no-art"
  ]
 },
 {
  "id": "how-it-works-pinned-fragment-changing-state",
  "role": "how-it-works",
  "name": "Pinned fragment, changing state",
  "shape": "pinned",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "product-performs",
   "quiet-instrument",
   "split-stage",
   "pinned-poster"
  ],
  "needs": [
   "product-ui"
  ],
  "blueprint": "Two columns. Right, columns 7–12: a stage that is position: sticky; top: 14vh, holding one fragment of the real interface at true size on a single flat stage colour, about 520px square. Left, columns 1–5: three statements, each in its own block 80vh tall, so one is in view at a time. A statement is a two-line sentence at 28–32px that begins with its order word ('Paste a link.', 'Then pick the parts you want.', 'Share it when it looks right.') plus one supporting line at 16px. The statement nearest the viewport's centre is in full ink; the others sit at 30%. As each statement becomes active the fragment changes to the matching state: an empty field, a filled list with two rows selected, a finished result with its confirmation. Build the states in HTML and switch a class; do not swap screenshots. The heading, two to four words at 24–28px, sits above the first statement.",
  "mobile": "The stage is sticky at the top, 46svh tall, the fragment fitted by cropping to its active region, not by shrinking its text. Statements scroll beneath it in blocks 60svh tall at 22px. The stage releases after the third statement.",
  "seenIn": [
   "opl-juggle",
   "aww-cerebrium",
   "opl-vessa"
  ]
 },
 {
  "id": "how-it-works-itinerary-on-a-slip",
  "role": "how-it-works",
  "name": "Itinerary on a slip",
  "shape": "table",
  "tags": [
   "object-led",
   "type-led"
  ],
  "directions": [
   "page-is-the-object",
   "type-only-statement",
   "split-stage",
   "quiet-instrument",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The process is printed as an itinerary or receipt. One paper-coloured slip 420–480px wide stands on a contrasting flat ground at columns 7–11; its bottom edge is a zigzag cut with clip-path and its top edge is straight. The heading, two to four words at clamp(40px, 4.4vw, 68px), sits in columns 1–5 with a 30ch paragraph, top-aligned with the slip. On the slip, in the text face at 16px with tabular figures: stages grouped under time headings that carry the order ('Monday', 'Midweek', 'Friday'), each heading in bold with 28px above it. Under a heading, two to four rows; each row has what happens on the left, a duration or time on the right, and a leader of round 4px dots between. The final row is inverted, paper-coloured text on a full-width ink bar 44px tall, and states the result: what the reader holds at the end and the total time.",
  "mobile": "The heading and paragraph come first. The slip follows at full width minus 16px margins, keeping its zigzag bottom edge. Rows keep left text, dots and right value on one line; long descriptions wrap under the left text. The inverted final row stays full width.",
  "seenIn": [
   "opl-the-content-architecture",
   "opl-lennys-newsletter-product-pass"
  ]
 },
 {
  "id": "how-it-works-accordion-of-tinted-panels",
  "role": "how-it-works",
  "name": "Accordion of tinted panels",
  "shape": "strip",
  "tags": [
   "colour-field",
   "poster"
  ],
  "directions": [
   "colour-chapters",
   "one-motif",
   "loud-flat-poster",
   "dense-tiles",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "A two-column introduction, then one horizontal accordion. Introduction: the heading, two to four words at clamp(36px, 4vw, 60px), in columns 1–5; one paragraph at 18px in columns 7–11. 64px below, a flex row across columns 1–12, 520px tall, of four to six panels with no gaps. The open stage is a panel taking about 58% of the width; the rest are vertical slivers sharing what remains. Order is carried by colour: the first stage is the darkest tone of one hue and each later stage is a lighter flat tint of it, so the row reads dark to light from left to right. A sliver shows only its stage name, rotated 90 degrees and reading upward, at 20px bold, anchored to its bottom-left. The open panel shows the name at 40px top-left, a 36ch paragraph at 18px bottom-left and, optionally, one photograph in its right half. Clicking a sliver opens it (flex-basis transition, 450ms).",
  "mobile": "The accordion turns vertical: stages stack at full width as bars 64px tall in the same dark-to-light order, names set horizontally at 18px. The open stage expands to its content height with the paragraph at 17px. The first stage is open by default.",
  "seenIn": [
   "aww-white-desert"
  ]
 },
 {
  "id": "how-it-works-tiles-that-grow",
  "role": "how-it-works",
  "name": "Tiles that grow",
  "shape": "grid",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "specimen-catalogue",
   "hero-object",
   "colour-chapters",
   "one-motif",
   "dense-tiles"
  ],
  "needs": [],
  "blueprint": "Order is carried by size. Three flat colour tiles stand in one row, bottom-aligned (align-items: end) with 10px gaps, each larger than the one before: columns 1–3 at 30vh tall, columns 4–7 at 44vh, columns 8–12 at 60vh. Each tile holds one photograph of the subject at that stage (raw, in progress, finished), centred and scaled so the object grows from tile to tile; on light tiles mix-blend-mode: multiply removes a white studio background. One palette colour per tile. Text sits under the tiles, outside them, aligned to each tile's left edge: a stage name at 20px medium whose wording carries the order ('Picked', 'Pressed', 'Poured') and two lines at 16px in the soft ink. The heading, two to four words at 40–52px, occupies the empty ground above the two smaller tiles, top-aligned with the tallest tile. A fourth stage is possible with spans of 2, 3, 3 and 4 columns.",
  "mobile": "Tiles stack in order and grow in height instead: 34vw, 46vw and 62vw tall, each at full width. The name and text sit under each tile at 18px and 16px. The heading comes first at 30px. 28px between stages.",
  "seenIn": [
   "aww-aspen-search",
   "aww-noho"
  ]
 },
 {
  "id": "how-it-works-run-in-column",
  "role": "how-it-works",
  "name": "Run-in column",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "name-as-monument",
   "editorial-report",
   "contact-sheet",
   "split-stage",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "The quietest sequence: one reading column and good typography. A single column 520–600px wide starts at about 30% of the viewport width (or sits on the page's text axis). The heading is a two-to-five-word question or statement at 28–32px, 48px above the first paragraph. Three to five paragraphs follow at 17–18px with line-height 1.6. Each opens with a bold run-in phrase in full ink that names its moment ('To begin,', 'Within a day,', 'After that,', 'From then on,') while the rest of the paragraph runs in the soft ink; there are no subheads, bullets or markers. After each paragraph, 20px lower, comes one small piece of evidence exactly as wide as the column: a cropped interface fragment, a photograph at 16:9, or a row of three small frames 96px tall. 72px separates stages. Nothing in the section is wider than the column, and the space to its left stays empty.",
  "mobile": "The column fills the width between 20px margins. Type stays at 17px. Evidence pieces keep the column width; the row of three frames becomes a single sideways-scrolling row 88px tall. 56px between stages. The heading drops to 24px.",
  "seenIn": [
   "opl-the-queue",
   "opl-rows",
   "opl-dialkit"
  ]
 },
 {
  "id": "how-it-works-many-into-one",
  "role": "how-it-works",
  "name": "Many into one",
  "shape": "band",
  "tags": [
   "colour-field",
   "dense"
  ],
  "directions": [
   "one-motif",
   "dense-tiles",
   "editorial-report",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "A diagram drawn with one repeated shape instead of arrows. The heading (two to four words on two lines at clamp(36px, 4vw, 60px)) and a short paragraph at 17px sit in columns 1–4. Beneath, a wide inline SVG spans columns 1–12 at about 4.5:1. It is built from 120–160 small filled squares (or the page's own motif), each 10–14px. In the left third they are scattered loosely in two pale tones. In the middle third they are pulled into a tight horizontal ribbon three squares high. In the right third they fan out again into neat, evenly spaced rows in the accent colour. The eye reads disorder, compression, order, from left to right. Under the diagram, three text columns aligned to its thirds: a bold one-word name at 20px and two lines at 16px each; the words carry the sequence ('Gather', 'Reduce', 'Release'). No connecting marks.",
  "mobile": "The diagram turns to run top to bottom: a 3:4 SVG with the scatter at the top, the ribbon as a vertical band in the middle and the ordered rows at the bottom. The three names and their text sit to its right in a 38vw column, each level with its zone.",
  "seenIn": [
   "opl-wild-as",
   "opl-shelf"
  ]
 },
 {
  "id": "how-it-works-one-verb-at-a-time",
  "role": "how-it-works",
  "name": "One verb at a time",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "pinned-poster",
   "loud-flat-poster",
   "dark-room",
   "hero-object",
   "sentence-as-layout",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "Three to five full-bleed panels, each 100svh tall, stacked with no gap; each is one stage and holds one photograph covering it. In every panel a single word, the verb for that stage ('Cut', 'Fold', 'Stitch', 'Press'), is set at clamp(96px, 17vw, 300px), heavy and centred, inside a wrapper that is position: sticky; top: 50% and shifted up by half its own height. Because each word is sticky only within its own panel, it holds the centre of the screen while its photograph passes behind it, and is then pushed out by the next panel's verb. Use mix-blend-mode: difference in white, or one solid colour tested on every image. One sentence at 18px sits in the bottom-left corner of each panel over a neutral scrim limited to that corner. There is no separate heading: the verbs, read in order, are both the heading and the sequence.",
  "mobile": "Panels stay 100svh, each photograph cropped to a vertical slice. The verb is 24vw, still sticky at the centre. The sentence sits along the bottom over a scrim at 16px, full width with 16px margins.",
  "seenIn": [
   "opl-eagles-nest",
   "aww-seasats",
   "aww-vero-new-york"
  ]
 },
 {
  "id": "stats-one-figure-owns-the-screen",
  "role": "stats",
  "name": "One figure owns the screen",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "editorial-report",
   "colour-chapters",
   "loud-flat-poster",
   "borrowed-category",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "One number, one screen. A flat field at least 90svh tall. At the top-left, starting on the page's main axis, a one-line claim of two to five words at 36–44px: this is the heading. Then a long gap of 22–28vh. At the bottom-left on the same edge, a single figure at clamp(180px, 23vw, 380px), leading 0.8, tight tracking, with its unit (a percent sign, 'x', 'hrs') set as a superscript one third of the figure's size and aligned to the figure's cap height. Directly beneath, flush with the figure's left edge, a caption of three lines at 16–17px, 32ch wide, saying exactly what was measured and when. The right half of the field stays empty; resist filling it. The figure is static text in lining numerals and does not animate. A source line, if needed, sits at the bottom-right at 15px.",
  "mobile": "The claim sits at the top at 28px. The figure is 46vw, flush left, with its superscript unit; if it has four characters or more, drop it to 34vw so it stays on one line. The caption follows at 16px across 80% of the width. Minimum height 80svh.",
  "seenIn": [
   "opl-the-state-of-software-delivery-in-health",
   "opl-the-content-architecture"
  ]
 },
 {
  "id": "stats-figures-inside-a-sentence",
  "role": "stats",
  "name": "Figures inside a sentence",
  "shape": "statement",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "sentence-as-layout",
   "type-only-statement",
   "dark-room",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "The numbers are written into prose and the prose is set at display size. One paragraph of two or three sentences, 22–34 words, containing three or four true figures, in the pattern 'Since 2014 we have rebuilt 340 roofs in 9 towns, and 312 of them have never leaked.' Set at clamp(32px, 4.4vw, 72px), leading 1.12, in columns 2–11, left-aligned and vertically centred in a section of 80–90svh with nothing else in it. Words are in the soft ink at regular weight; the figures alone are in full ink (or the accent) at the same size, in lining numerals, with non-breaking spaces binding each figure to its unit. No boxes, underlines or colour chips behind the figures. A visually hidden two-word heading names the section for structure; to the eye the paragraph stands alone. On a deep ground use a light weight and a soft light ink, with the figures in the brightest ink.",
  "mobile": "The paragraph is 7.4vw with leading 1.2, at full width between 20px margins, vertically centred in 70svh. Keep each figure and its unit together on one line with non-breaking spaces. Nothing else appears in the section.",
  "seenIn": [
   "aww-made-with-gsap-1",
   "aww-united-carriers"
  ]
 },
 {
  "id": "stats-column-aligned-on-the-decimal",
  "role": "stats",
  "name": "Column aligned on the decimal",
  "shape": "table",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "editorial-report",
   "type-only-statement",
   "page-is-the-object",
   "contact-sheet",
   "product-performs",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "A tall column of figures set like a ledger. Left, columns 1–5: the heading as a statement of up to five words on two lines at clamp(32px, 3.2vw, 52px), its first line in the soft ink and its second in full ink, placed at mid-height. Right, columns 7–12: four to six figures stacked, each at clamp(64px, 7vw, 112px), leading 1.0, in tabular lining numerals. They align on the decimal point: each row is a three-part grid in which the whole-number part is right-aligned in the first track, the point and decimals are left-aligned in the second, and a caption of one or two lines at 16px sits in the third, on the figure's baseline. Whole numbers simply leave the second track empty. Rows are separated by 20px of space only. Units are written in the captions, not attached to the figures, so the numerals form one clean vertical shape.",
  "mobile": "The heading comes first at 28px. Figures drop to 15vw and keep the decimal alignment, with the split at 52% of the width. Each caption moves under its figure at 15px, starting from the left margin. 28px between rows.",
  "seenIn": [
   "aww-united-carriers",
   "aww-moto-finance"
  ]
 },
 {
  "id": "stats-photograph-with-figure-tiles",
  "role": "stats",
  "name": "Photograph with figure tiles",
  "shape": "mosaic",
  "tags": [
   "colour-field",
   "photo-led"
  ],
  "directions": [
   "editorial-report",
   "colour-chapters",
   "dense-tiles",
   "split-stage",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "A mosaic without gutters spanning the full width, about 66vw tall in total. A single photograph fills the left two-thirds at roughly 1:1 with object-fit: cover and square corners. The right third is split into two stacked flat colour tiles of equal height, in two palette colours that differ clearly in lightness as well as hue. Each tile carries one figure at clamp(56px, 5.6vw, 96px) in its top-left corner, 28px in from the edges, with a two-line caption at 17px directly beneath it; the rest of the tile is empty colour, which is the point. Photograph and tiles touch, with no gaps and no rounding. The heading, two to four words at 32–40px, sits above the mosaic's left edge with 40px beneath, or is left out when the section follows a text page whose title already introduces it. Never add a third tile; with three figures choose another layout.",
  "mobile": "The photograph comes first, full-bleed at 4:3. The two colour tiles follow side by side as squares, each 50vw, with the figure at 13vw top-left and the caption at 15px beneath. No gaps between any of the three pieces.",
  "seenIn": [
   "aww-ai-in-design-report-2026"
  ]
 },
 {
  "id": "stats-stepped-figure-tiles",
  "role": "stats",
  "name": "Stepped figure tiles",
  "shape": "grid",
  "tags": [
   "colour-field",
   "dense"
  ],
  "directions": [
   "dense-tiles",
   "one-motif",
   "loud-flat-poster",
   "colour-chapters",
   "specimen-catalogue",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Three figures on three tiles that refuse to line up. A tall tile in the accent colour fills columns 1–6 and is 70vh high; it holds one figure at clamp(56px, 5.4vw, 92px) in its top-left corner, 28px in, and its caption at 17px in its bottom-left corner, with empty colour between. On the right, an ink-coloured tile in columns 7–9 starts 14vh lower and is 44vh high: a paper-coloured figure top-left, a caption along its bottom edge. A third tile in a mid tone takes columns 10–12, starts 26vh down and is 34vh high, with the same internal layout. Gaps are 8px. The unequal heights leave notches of bare ground above and below the right-hand tiles; the heading, two to four words at 28–36px, sits in the upper notch on column 7. If the page has a motif, cut one corner of each tile to its geometry with clip-path.",
  "mobile": "The tall tile comes first, at full width and 56vw high, with its figure at 15vw. The two smaller tiles follow side by side with a 6px gap, the right one starting 32px lower than the left, each with its figure at 11vw and its caption at the bottom. The heading sits above at 26px.",
  "seenIn": [
   "aww-aspen-search"
  ]
 },
 {
  "id": "stats-total-and-its-receipt",
  "role": "stats",
  "name": "Total and its receipt",
  "shape": "split",
  "tags": [
   "object-led",
   "type-led"
  ],
  "directions": [
   "page-is-the-object",
   "product-performs",
   "borrowed-category",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "A total at giant size, and beside it the slip that adds up to it. Left, columns 1–6: at the top the heading, two to five words at 40–44px, with a two-line note at 16px; at the bottom-left, anchored to the section's lower edge, the total at clamp(160px, 18vw, 300px) with leading 0.8, and one caption line at 17px under it. The space between heading and total is left empty. Right, columns 8–11: a paper-coloured slip 380–440px wide on the section's contrasting ground, top-aligned with the heading, its bottom edge a zigzag made with clip-path. The slip lists eight to fourteen rows in the text face at 15–16px with tabular numerals, grouped under bold group names; each row has a description on the left, a right-aligned value, and a leader of round 4px dots between. The last row is inverted on a solid ink bar and repeats the total. The slip's values must truly sum to the figure.",
  "mobile": "Heading and note come first. The total follows at 40vw, flush left, with its caption. The slip comes last at full width minus 16px margins with its zigzag edge, rows at 15px, and the inverted total row closing it.",
  "seenIn": [
   "opl-the-content-architecture"
  ]
 },
 {
  "id": "stats-figure-on-the-photographs-edge",
  "role": "stats",
  "name": "Figure on the photograph's edge",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "place-carries-it",
   "dark-room",
   "hero-object",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "One full-bleed photograph, 100vw by 88svh, and one figure set on it the way a film title sits on a frame. The figure, with its unit, is at clamp(120px, 17vw, 280px), leading 0.8, in paper colour, hugging the bottom-left corner with 24px margins, its baseline nearly on the photograph's lower edge. A neutral dark scrim covers only the bottom 35% of the image for legibility. On the same baseline at the right margin, right-aligned, a sentence of up to 26ch at 18px explains the figure. At the top-left, small at 20–24px, sits the heading of two to four words; the middle of the photograph stays clear. Two smaller supporting figures may follow below the photograph, on the ground: each at 40px with a one-line caption beside it, starting at columns 1 and 7, 40px under the image. Choose a figure that belongs to the picture: a height, a distance, a temperature, a year.",
  "mobile": "The photograph is 80svh, cropped to its subject. The figure sits bottom-left at 30vw; the sentence moves above it at 16px, left-aligned. Supporting figures stack under the photograph at 32px, each with its caption on the line beneath.",
  "seenIn": [
   "aww-the-state-of-the-gallery"
  ]
 },
 {
  "id": "stats-quiet-figure-lines",
  "role": "stats",
  "name": "Quiet figure lines",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "uneven-photographs",
   "split-stage",
   "contact-sheet",
   "name-as-monument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "Numbers stated without raising the voice. In columns 2–7, a definition-list layout: three to five rows, each a two-track grid. The first track is 132px wide and holds the figure, right-aligned, at 28–32px in medium weight and full ink with tabular numerals. The second track, after a 24px gap, holds one sentence at 16–17px in the soft ink, at most 40ch, whose first baseline matches the figure's baseline. Sentences share one left edge and figures one right edge, so a clean channel of space runs down between them. 28px between rows. The heading, two to four words at 24–28px, sits 56px above on the sentences' left edge, not the figures'. Optionally, a single small square photograph occupies columns 9–11, its top 80px lower than the first row, with no caption. Leave at least 18vh of empty ground above and below the block.",
  "mobile": "The grid stays two-track, with the figure track narrowed to 88px and figures at 24px; sentences at 16px wrap beside them. If the photograph is used it comes after the rows at 56% width, flush right. The heading is 22px on the sentences' left edge.",
  "seenIn": [
   "aww-cominvi"
  ]
 },
 {
  "id": "testimonial-name-block-quote-block",
  "role": "testimonial",
  "name": "Name block, quote block",
  "shape": "split",
  "tags": [
   "colour-field",
   "type-led"
  ],
  "directions": [
   "editorial-report",
   "colour-chapters",
   "dense-tiles",
   "loud-flat-poster",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "Two flat blocks butt together across the full width with no gap, about 70svh tall, built as a two-column grid (5fr 7fr) whose cells stretch to equal height. The left block, columns 1–5, is in the page's ink colour and holds only the speaker: name at 20px medium and role at 16px in a softer tone, pinned to the bottom-left corner with 32px padding; the rest of the block is empty. The right block, columns 6–12, is in one palette colour and carries the quotation at clamp(28px, 3vw, 44px), leading 1.15, top-left with 48px padding and a measure of about 24ch, so the lower third of the colour stays empty. Curly quotation marks are part of the text, with the opening mark hung outside the left edge by a negative `text-indent`. There is no heading; the quotation does that job.",
  "mobile": "The blocks stack with the colour block first: the quotation at 26px with 24px padding and a clear band of empty colour beneath it. The dark block follows directly, only about 96px tall, with name and role on one line each at the bottom-left. No gap between the two.",
  "seenIn": [
   "aww-ai-in-design-report-2026"
  ]
 },
 {
  "id": "testimonial-lead-quote-corner-quotes",
  "role": "testimonial",
  "name": "Lead quote, corner quotes",
  "shape": "statement",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "dark-room",
   "name-as-monument",
   "borrowed-category",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "A single field one viewport tall (min-height 100svh), usually the darkest tone of the palette. The lead quotation sits in the upper third: two lines at about 4vw in a light weight, centred, no wider than 60% of the viewport, with nothing near it. Two further short quotations are pushed into the bottom-left and bottom-right corners, each a block three columns wide set at 16px with leading 1.5, left-aligned, with the speaker's name and role on the two lines beneath in the softer ink. The bottom centre, about six columns wide, is deliberately empty. Use a three-row grid (auto 1fr auto) so the corner blocks hold a bottom margin of 32px while the lead quotation keeps its place at any height. There is no heading and there are no portraits; the contrast between one large voice and two small ones is the composition.",
  "mobile": "The lead quotation moves to the top at 8vw over three or four lines, left-aligned, with 96px of empty field under it. The two small quotations then stack at 16px, the first full width, the second indented by a quarter of the screen so they do not read as a list.",
  "seenIn": [
   "aww-hilden-kaira"
  ]
 },
 {
  "id": "testimonial-paper-slips",
  "role": "testimonial",
  "name": "Quotes on paper slips",
  "shape": "strip",
  "tags": [
   "object-led"
  ],
  "directions": [
   "page-is-the-object",
   "specimen-catalogue",
   "borrowed-category",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A heading of two to four words at about 3vw sits top-left in columns 1–6. Beneath it three paper slips fan across columns 2–11 on a contrasting flat ground: each is a filled paper-coloured panel about 300px wide, of three different lengths (440, 520 and 600px), rotated −4, 2 and 5 degrees and overlapping its neighbour by roughly 40px. The top and bottom edges of each slip are zigzag tears cut with `clip-path: polygon()`. On a slip, from the top: the speaker's name centred at 18px bold, the role on one line, a square greyscale photograph of the speaker 120px wide, then the quotation at 16px in the text face, left-aligned with 24px padding. The longest slip runs past the bottom of the section, which clips it with `overflow: hidden`, so the slips look freshly printed rather than laid out as cards.",
  "mobile": "One slip per row, each 78% of the screen wide and kept rotated by two or three degrees in alternating directions, the first hugging the left margin, the second the right, each overlapping the one above by 32px. The heading sits above at 8vw on two lines.",
  "seenIn": [
   "opl-poetry-camera"
  ]
 },
 {
  "id": "testimonial-quote-over-photograph",
  "role": "testimonial",
  "name": "Quotation over a photograph",
  "shape": "full-bleed",
  "tags": [
   "photo-led"
  ],
  "directions": [
   "place-carries-it",
   "dark-room",
   "hero-object",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "One photograph fills the section edge to edge at 100vw by 90svh with `object-fit: cover`; choose a frame with a calm lower half. The quotation is set directly on it in the paper colour: two or three lines at clamp(36px, 5vw, 80px), leading 1.0, flush left in columns 1–9, its last baseline 64px above the bottom edge. The speaker's name and role sit at 16px on that same baseline, right-aligned in columns 10–12. For legibility, darken the whole image evenly with `filter: brightness(0.7)` instead of placing a panel behind the words; the photograph must still read as a photograph. There is no heading, no frame and no oversized quotation-mark ornament, only plain curly quotes in the text. The section butts directly against its neighbours with no margin, so the image edge is the section edge.",
  "mobile": "The photograph becomes 100vw by 80svh, cropped to its strongest vertical slice. The quotation sits in the lower half at 9vw over four lines at most, flush left with 20px margins; the name and role follow under it on one line at 15px.",
  "seenIn": [
   "opl-berd"
  ]
 },
 {
  "id": "testimonial-gutter-quotes-pinned",
  "role": "testimonial",
  "name": "Quotes in the gutters",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "pinned-poster",
   "hero-object",
   "colour-chapters",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A sticky stage (`position: sticky; top: 0; height: 100svh`) inside a wrapper three or four viewports tall. The section's title, two or three words at 10–12vw, stays pinned across the top of the stage. In the centre a portrait photograph, 4:5 and about 30vw wide, changes at each scroll step. Each step also brings one quotation, placed in an outer gutter in a column 280px wide and at a different height each time: right gutter in the upper third, then left gutter at mid-height, then right gutter in the lower third. Quotations are bold at 22px with tight leading; the speaker's name follows at 15px in the softer ink. Only one quotation shows at a time. Each step may switch the stage to its own flat ground colour. Drive the steps with an IntersectionObserver on spacer blocks; never hijack the scroll.",
  "mobile": "No pinning. The title sits once at 16vw. Each speaker then gets a block: the portrait at 72% width, flush left and flush right in turn, the quotation at 20px beneath it on the same edge, and the name under that. Leave 72px between blocks.",
  "seenIn": [
   "opl-city-of-angels"
  ]
 },
 {
  "id": "testimonial-portrait-offset-quote",
  "role": "testimonial",
  "name": "Portrait and offset quotation",
  "shape": "split",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "split-stage",
   "uneven-photographs",
   "place-carries-it",
   "one-motif",
   "editorial-report",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "A 12-column row on the page ground with wide margins. A portrait of the speaker, 3:4, fills columns 2–5 and is tall, about 70svh; it takes whatever mask the page gives its photographs (square corners, or the page's one motif shape). The quotation occupies columns 7–12 and starts 120px lower than the top of the portrait: four to six lines at clamp(26px, 2.6vw, 40px) in a light or regular weight, leading 1.2, measure about 26ch. The name and role sit 32px beneath the quotation at 16px, on its left edge. A second, small photograph (1:1, columns 10–11) hangs 96px below the attribution and shows the place or the thing the speaker is talking about. There is no heading. Nothing is centred; every edge lands on a column line, and the two vertical offsets are the design.",
  "mobile": "The portrait runs at 72% width from the left margin, 3:4. The quotation follows at 24px, full width, starting 32px beneath. Name and role sit under it. The small second photograph sits at 44% width against the right margin, 48px lower, overlapping nothing.",
  "seenIn": []
 },
 {
  "id": "testimonial-words-spread-wide",
  "role": "testimonial",
  "name": "Quotation spread edge to edge",
  "shape": "band",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "sentence-as-layout",
   "type-only-statement",
   "name-as-monument",
   "loud-flat-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A full-width field with no image. One short quotation, six to ten words, is set at about 9vw in the display face and broken by hand into two or three lines. Each line is a flex row with `justify-content: space-between`, so its first word sits hard against the left margin, its last word hard against the right, and any middle word floats between them. Lines are 0.6em apart, far looser than a headline, which lets the gaps read as pauses. The attribution is spaced the same way on a final row at 18px: name at the left margin, role centred, organisation at the right. Opening and closing quotation marks belong to the first and last words. There is no separate heading. Leave at least 140px of empty field above and below, and give the field its own flat colour when the palette allows.",
  "mobile": "Lines break after every two words and keep the spread: one word at each margin, at 13vw. A single-word line alternates between the left and right margins. The attribution stacks as three short lines at 15px, aligned left, centre and right in turn.",
  "seenIn": [
   "opl-poetry-camera",
   "opl-rsquad"
  ]
 },
 {
  "id": "testimonial-letter-reading-column",
  "role": "testimonial",
  "name": "One long letter",
  "shape": "stack",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "split-stage",
   "editorial-report",
   "type-only-statement",
   "page-is-the-object"
  ],
  "needs": [],
  "blueprint": "A single testimonial given room to be read, set like a letter. One column 34–38ch wide begins at column 5 of 12, or shares the page's own reading column. The first sentence of the quotation is pulled up as the opening line at 28–32px in medium weight, two lines at most. The rest follows as two or three ordinary paragraphs at 18–19px with leading 1.55 and 20px between paragraphs, in the full ink, not grey. It closes the way a letter does: 40px of space, then the name on one line and the role on the next at 16px. In columns 1–3, top-aligned with the first line, sits either nothing or one small square photograph of the writer, 120px wide. There are no quotation marks at all; the letter form makes them unnecessary. Space above and below is 140px.",
  "mobile": "The column takes the full width with 20px margins. If there is a photograph it goes first at 96px square, left-aligned. Opening sentence at 24px, body at 17px, sign-off beneath. Nothing else changes; this section is already a phone composition.",
  "seenIn": []
 },
 {
  "id": "testimonial-shallow-portrait-bands",
  "role": "testimonial",
  "name": "Shallow portraits, small quotes",
  "shape": "grid",
  "tags": [
   "dense",
   "photo-led"
  ],
  "directions": [
   "contact-sheet",
   "dense-tiles",
   "quiet-instrument",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "Speakers are shown as wide, shallow crops rather than headshots. A two-word heading at 15px sits in columns 1–2, top-aligned with the frames, not above them. In columns 3–12 three frames sit side by side with 8px gaps at unequal widths (4, 3 and 3 columns), all exactly 110px tall: each is a photograph of the person cropped with `object-fit: cover` to a band across the eyes and shoulders. Directly under each frame, on its left edge, come the name in the brighter ink and the role in the softer ink on one line at 15px, then the quotation at the same 15px over three to five lines, no wider than its frame. Everything shares one type size; hierarchy comes from ink alone. If interview clips exist, each frame can be the poster of its clip, but the section must work with stills.",
  "mobile": "Frames go full width one after another, each 84px tall, with its name, role and quotation at 15px beneath and 40px before the next. The heading leads as a plain line at the same size, in the brighter ink, 24px above the first frame.",
  "seenIn": [
   "aww-oimachi"
  ]
 },
 {
  "id": "proof-names-run-on-sentence",
  "role": "proof",
  "name": "Names as one sentence",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "sentence-as-layout",
   "name-as-monument",
   "dark-room",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Client, press or partner names are written out as one run-on sentence and set large: clamp(40px, 5vw, 76px), leading 1.0, flush left across all twelve columns, wrapping naturally over five to seven lines. Names are separated by commas and the list ends with a full stop. All names are set in a dimmed ink (still at least 3:1 against the ground), so the block reads as texture; one name at a time is lit in the full ink, and the lit name moves through the list every two seconds (a class toggled by a short script, with a 400ms colour transition). The sentence opens with two or three plain words in the full ink that act as the heading: the phrase that introduces the names. Nothing else is in the section: no logos, no columns, and 120px of space above and below.",
  "mobile": "The same sentence at 9vw, wrapping over ten to fourteen lines; cut the list to the twelve strongest names so it stays within one and a half screens. The lit name still cycles. Margins are 20px and there are no other elements.",
  "seenIn": [
   "aww-revelatio-studio"
  ]
 },
 {
  "id": "proof-plain-columns",
  "role": "proof",
  "name": "Plain names in columns",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "contact-sheet",
   "editorial-report",
   "split-stage",
   "uneven-photographs",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A typographic list with no logos. The heading, two or three words at 28–32px, sits in columns 1–3 and stays top-aligned with the first row of names. Columns 5–12 hold the names in three or four text columns (`columns: 4; column-gap: 32px`), each name on its own line at 20–22px with leading 1.5, sentence case, in the full ink. Where names fall into kinds (clients, press, partners), each kind starts a new column and its first line names the kind in the softer ink at the same size, so no small labels are needed. Columns are of unequal length and are not balanced; ragged bottoms are intended. Twelve to thirty names work best. There are no boxes, bullets or separators: the column edges and the shared baseline do the organising. Leave 120px above and below.",
  "mobile": "The heading leads at 26px. Names follow in two columns at 17px with a 20px gap; each kind begins after a 32px break with its name in the softer ink on the first line. Long names wrap inside their column rather than shrinking.",
  "seenIn": [
   "opl-ayush-halder-portfolio"
  ]
 },
 {
  "id": "proof-single-logo-row",
  "role": "proof",
  "name": "One quiet logo row",
  "shape": "strip",
  "tags": [
   "quiet"
  ],
  "directions": [
   "product-performs",
   "quiet-instrument",
   "hero-object",
   "dark-room",
   "place-carries-it",
   "split-stage",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "Only for real logos supplied by the project; if none are supplied, set the names in the text face at 22px in the same row and never draw stand-ins. The row reads as a sentence: it opens at the left margin with two to four plain words at 18px (the phrase that introduces the names), vertically centred on the logos, and five to seven logos follow on the same line, spread to the right margin with `justify-content: space-between`. Every logo is normalised to one optical height (28–32px for wordmarks, 36px for compact marks) and one colour, the page ink, using single-colour SVGs with `fill: currentColor`. The band is tall and almost empty: 120–150px of ground above and below the row, no box, no background change and no separate heading. Logos stay still; nothing scrolls or fades at the edges.",
  "mobile": "The opening words sit alone on the first line at 17px. Logos follow in a grid of two per row, each 26px tall and left-aligned in its cell, with 36px between rows. Show six at most; there is no sideways scrolling.",
  "seenIn": [
   "aww-ai-in-design-report-2026",
   "aww-lama-lama-2",
   "opl-mammut-studios"
  ]
 },
 {
  "id": "proof-recognition-table",
  "role": "proof",
  "name": "Recognition as a table",
  "shape": "table",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "editorial-report",
   "type-only-statement",
   "contact-sheet",
   "borrowed-category",
   "name-as-monument",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "Awards, press mentions or credits set as a four-column table with no lines. The heading is two or three words at about 5vw, stepped over two lines with the second line indented by two columns, and occupies columns 1–7. The table begins 72px below and spans columns 1–12 on a fixed grid: year in columns 1–2, project or title in 3–6, awarding body or publication in 7–10, the award itself in 11–12, right-aligned. Rows are set at 16–17px with 14px of padding above and below; every second row carries a background tone one step from the ground, running the full table width, which replaces rules. Year groups are separated by one blank row, and the year is printed only on the first row of its group. Use a real `<table>` with `border-collapse: collapse` and no borders.",
  "mobile": "The heading drops to 11vw. Each table row becomes a two-line block: title on the first line in the full ink, then body and award on the second in the softer ink. The year stands once as a 22px figure above its group. Alternate blocks keep the background tone.",
  "seenIn": [
   "aww-bleibtgleich26"
  ]
 },
 {
  "id": "proof-large-marks-on-tiles",
  "role": "proof",
  "name": "Large marks on tiles",
  "shape": "grid",
  "tags": [
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "specimen-catalogue",
   "colour-chapters",
   "one-motif",
   "product-performs",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A board of filled tiles, four across and two or three rows deep, with 8px gaps, spanning the full content width. Each tile is about 3:2, one step lighter or darker than the page ground, with no border. A tile holds one supplied logo, shown far larger than a usual logo strip: about 72px tall, centred, in the page ink as a single-colour SVG. When no logo files exist, the tile holds the name instead, set at 28–32px bold, top-left with 24px padding; never draw a mark. One tile, twice the width of the others and placed first, is in the accent colour and carries the heading: two to four words at about 3vw, top-left. Tile corners follow the page's one radius, or the page's motif shape if it has one. Tiles sit still; there is no hover lift.",
  "mobile": "Two tiles per row with 6px gaps, logos at 44px tall. The heading tile runs full width first, about 120px tall. Show eight tiles at most so the board stays under one and a half screens. Names set in type drop to 20px.",
  "seenIn": [
   "aww-monolog"
  ]
 },
 {
  "id": "proof-printed-guest-list",
  "role": "proof",
  "name": "The printed guest list",
  "shape": "stack",
  "tags": [
   "object-led",
   "type-led"
  ],
  "directions": [
   "page-is-the-object",
   "borrowed-category",
   "specimen-catalogue",
   "place-carries-it",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Names are printed on a card, the way a programme lists its patrons or an invitation its hosts. On a contrasting flat ground, a paper-coloured panel about 56% of the viewport wide with 5:7 proportions sits centred and rotated by one or two degrees; the section is shorter than the card and clips it with `overflow: hidden`, so the card is cut by the bottom edge rather than framed whole. Inside, 56px padding. At the top, a centred heading of two to four words at 28px. Below it the names run as centred lines at 20–22px with leading 1.7, several names to a line, separated by middots with a non-breaking space either side. One photograph may be tipped in between the heading and the names: 16:9, the full inner width, square corners. The card is a plain filled shape with no outline.",
  "mobile": "The card becomes 92% of the screen wide, keeps a one-degree tilt and is still cut off by the section's bottom edge. Heading at 22px, names at 17px with two names to a line. Padding drops to 28px, and the tipped-in photograph keeps the full inner width.",
  "seenIn": [
   "opl-hebbia"
  ]
 },
 {
  "id": "proof-names-off-both-edges",
  "role": "proof",
  "name": "Names running off the edges",
  "shape": "band",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "pinned-poster",
   "loud-flat-poster",
   "name-as-monument",
   "borrowed-category",
   "hero-object",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A full-bleed band in a flat colour, about 34vh tall, holding a single line of names that is cropped by both viewport edges. The names are set at 8vw in the display weight, `white-space: nowrap`, separated by a solid round dot one third of the cap height, and the line moves slowly to the left (a CSS `translateX` animation over 40 seconds on a duplicated track, paused under `prefers-reduced-motion`). The band has no heading. Its meaning comes from one sentence at 18px set directly below the band at the left margin on the page ground, stating plainly who these names are. Supplied logos may replace names if all are available as single-colour SVGs at 0.8em height; do not mix logos and set names in one line. The band butts against the sections above and below with no gap.",
  "mobile": "The band is 22vh tall with names at 15vw, still cropped at both edges and still moving. The explaining sentence sits under it at 16px with 20px margins. Do not add a second line running the opposite way; one line is the idea.",
  "seenIn": [
   "aww-made-with-gsap-1"
  ]
 },
 {
  "id": "pricing-price-as-display-figure",
  "role": "pricing",
  "name": "The price, very large",
  "shape": "split",
  "tags": [
   "type-led",
   "object-led"
  ],
  "directions": [
   "product-performs",
   "hero-object",
   "name-as-monument",
   "one-motif",
   "dark-room",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "One plan, no card. Columns 1–4 hold a heading of two to four words at 28–36px and one paragraph at 17px, top-aligned. Columns 6–12 hold the price as a display figure: numerals at clamp(120px, 16vw, 240px), tight tracking, leading 0.85, with the currency sign at half that size raised to the cap line and the period (per month, once) at 18px beside the last numeral on the baseline. One thing from the page overlaps the numerals by 10–15% of their height: the product photograph, the interface fragment or the motif shape, placed over the top-right of the figure. Beneath the figure, on its left edge, four plain lines at 18px state what is included, one per line, without ticks or bullets, then a single rectangular button. The section is at least 80svh tall with the figure vertically centred.",
  "mobile": "The heading and paragraph come first, full width. The figure follows at 34vw, flush left, with the overlapping object tucked at its top-right corner at a third of the screen wide. Included lines at 17px and a full-width button close the block.",
  "seenIn": [
   "opl-dabbit"
  ]
 },
 {
  "id": "pricing-large-typographic-table",
  "role": "pricing",
  "name": "Plans as a large table",
  "shape": "table",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "type-only-statement",
   "editorial-report",
   "dense-tiles",
   "product-performs",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "Two to four plans compared in a table whose type is far larger than a table usually gets. The first column (columns 1–4) lists what is compared, in words, at 22px. Each plan takes an equal share of columns 5–12. The header row gives each plan's name at about 3vw bold, left-aligned, with its price directly beneath at the same size in the regular weight and the period at 16px. Body rows are 22px with 20px padding; cells answer in words or numbers (“Unlimited”, “3 seats”, “Not included”), never with ticks. Every second row has a background tone one step from the ground, spanning all columns. The recommended plan is marked only by its whole column sitting on the accent tone from header to last row. The final row holds one rectangular button per plan. A two-to-four-word heading sits in the top-left cell.",
  "mobile": "Plans stack as separate blocks. Each opens with the plan name and price at 9vw, then its rows as pairs: the compared item in the softer ink, the answer beneath in the full ink, both at 17px. The recommended plan's block sits on the accent tone. A full-width button ends each block.",
  "seenIn": []
 },
 {
  "id": "pricing-one-sentence-price",
  "role": "pricing",
  "name": "One plan as a sentence",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "sentence-as-layout",
   "type-only-statement",
   "loud-flat-poster",
   "name-as-monument",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The whole offer is one sentence of five to seven short words that contains the price, set at 8–9vw across the full width on two lines, leading 0.95, flush left; it replaces any heading. The price is not styled as a different word. Instead it sits on a filled block of the accent colour exactly one line-height tall with 0.15em of padding at each side, so the figure reads as an object in the line. Below, on one shared baseline 64px under the sentence: a supporting sentence at 20px in columns 1–6 saying what the price includes, and one rectangular button right-aligned in columns 10–12. If a second option exists (annual, team), it is a plain text link under the button, not a second panel. The section is one flat colour field at least 80svh tall.",
  "mobile": "The sentence is set at 11vw on two lines, with the price block on a line of its own between them, as wide as its figure. The supporting sentence follows at 17px, then a full-width button, then the text link for the second option.",
  "seenIn": []
 },
 {
  "id": "pricing-two-plans-two-fields",
  "role": "pricing",
  "name": "Two plans, two fields",
  "shape": "split",
  "tags": [
   "colour-field",
   "poster"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "borrowed-category",
   "one-motif",
   "editorial-report",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "Two plans stand side by side as two full-height colour fields that meet at a hard vertical edge with no gap: the left field spans columns 1–5, the right 6–12, both 90svh tall and bleeding to the viewport edges. Each field has its own flat colour and its assigned text colour. Inside each, on a shared grid: the plan name at about 5vw top-left with 40px padding; one sentence at 20px beneath it, no wider than 26ch, saying who the plan is for; then, anchored to the bottom-left, the price at about 10vw with the period at 18px beside it; and a rectangular button anchored bottom-right on the price's baseline. What is included is written as a short paragraph, not a tick list. The unequal widths say which plan matters more without any badge. No heading spans the two fields.",
  "mobile": "The fields stack, each 70svh tall, the more important plan first. Name at 12vw top-left, sentence beneath, price at 24vw at the bottom-left, and the button full width along the bottom edge inside 20px padding. The hard colour edge between the two is kept.",
  "seenIn": []
 },
 {
  "id": "pricing-menu-list",
  "role": "pricing",
  "name": "Prices like a menu",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "page-is-the-object",
   "place-carries-it",
   "split-stage",
   "uneven-photographs",
   "quiet-instrument",
   "specimen-catalogue",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "Prices set the way a good menu sets them. A heading of two to three words at 32–40px sits in columns 1–3. Columns 5–12 hold the list in two text columns with a 64px gap. Items are grouped under plain group names at 24px in medium weight with 48px above each group. Each item is a row: the name at 19px on the left, the price at the same size on the right on the same baseline, with tabular figures and the currency sign shown only once per group; beneath the name, one line of description at 16px in the softer ink, no wider than 36ch. Rows are 22px apart. Name and price are joined only by empty space (flex with `justify-content: space-between`), with no dot leaders and no lines. One small photograph (4:5, three columns wide) may sit under the heading in the left zone.",
  "mobile": "One column. The heading at 28px, then the photograph at 60% width if used. Each item keeps name left and price right on one line at 17px, with its description beneath in the softer ink. Group names at 20px with 40px above.",
  "seenIn": []
 },
 {
  "id": "pricing-ticket-with-stub",
  "role": "pricing",
  "name": "The ticket",
  "shape": "strip",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "page-is-the-object",
   "borrowed-category",
   "loud-flat-poster",
   "pinned-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The price is printed on a ticket. On a flat contrasting ground, one filled panel about 64vw wide with 2.3:1 proportions sits centred and straight, built as a two-part grid (7fr 3fr). A half-round notch 28px across is bitten out of the top and bottom edges where the two parts meet (two absolutely positioned circles in the ground colour), and a column of round dots 6px across in the ground colour runs between the notches as the perforation. The main part holds, top-left with 40px padding, what is being bought in two to four words at about 4vw, and along its bottom three or four short lines at 17px (dates, what is included, terms) in two tidy columns. The stub holds the price at about 6vw, centred, and under it the action as a solid bar the full width of the stub, 56px tall.",
  "mobile": "The ticket turns upright: full width less 20px margins, main part on top, stub beneath, the notches now on the left and right edges and the dot perforation running across. Title at 9vw, price at 16vw, the action bar full width inside the stub.",
  "seenIn": []
 },
 {
  "id": "pricing-itemised-receipt",
  "role": "pricing",
  "name": "The itemised receipt",
  "shape": "stack",
  "tags": [
   "object-led",
   "quiet"
  ],
  "directions": [
   "page-is-the-object",
   "hero-object",
   "specimen-catalogue",
   "borrowed-category",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "For offers made of parts: a base price plus options, a kit, a bundle. A tall paper-coloured slip 380px wide sits in columns 8–11 on a contrasting ground, its bottom edge cut into a zigzag with `clip-path: polygon()`, its top edge running off the top of the section. On the slip, with 28px padding: the name centred at 20px bold, then each item as a row with the description left and the amount right at 16px with tabular figures, 14px between rows; a thick 4px bar in the ink colour; then the total as the largest thing on the slip at 44px, right-aligned. Columns 1–6 carry the heading (two to four words at about 4vw), one paragraph at 18px and the button, starting 96px from the top. If options are selectable, ticking one in the left column adds its row to the slip and updates the total.",
  "mobile": "Heading, paragraph and any options come first. The slip follows at 86% width, centred, keeping its zigzag foot, with rows at 16px and the total at 36px. The button sits under the slip at full width, 52px tall.",
  "seenIn": [
   "opl-poetry-camera"
  ]
 },
 {
  "id": "pricing-plan-index-live-figure",
  "role": "pricing",
  "name": "Plan names, one live figure",
  "shape": "index",
  "tags": [
   "type-led",
   "ui-led"
  ],
  "directions": [
   "pinned-poster",
   "product-performs",
   "contact-sheet",
   "name-as-monument",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "Plans are a list of names; the price shown belongs to whichever name is selected. Columns 1–6 hold the plan names stacked at about 5vw, leading 1.1, each a `<button>`; the selected one is in the full ink, the others in the softer ink, and the selected name carries a solid dot 0.3em across at its left. Columns 8–12 hold one stage that stays in place while its contents change: the selected plan's price at about 9vw at the top, the period beside it at 18px, a paragraph of three or four lines at 18px saying what that plan includes, and one rectangular button whose label names the plan. Changing plan swaps the stage contents with a 200ms crossfade; the figure's position never moves. Selection follows click and keyboard focus. The middle plan is selected on load. There is no heading: the names are the heading.",
  "mobile": "Plan names stack at 10vw, each on its own line. Tapping one opens its stage directly beneath that name (price at 20vw, paragraph at 17px, full-width button) and closes the others, like a large accordion without lines. The middle plan starts open.",
  "seenIn": []
 },
 {
  "id": "pricing-stepped-colour-tiles",
  "role": "pricing",
  "name": "Plans that step up",
  "shape": "grid",
  "tags": [
   "colour-field",
   "dense"
  ],
  "directions": [
   "dense-tiles",
   "colour-chapters",
   "specimen-catalogue",
   "loud-flat-poster",
   "one-motif",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Three plans as flat colour tiles whose heights step like a bar chart, so the bigger plan is literally bigger. The tiles sit in one row with 8px gaps, bottoms on a common base line, each 4 columns wide: the first 360px tall, the second 480px, the third 600px. Each has its own palette colour, no border and one shared radius. Inside, top-left with 28px padding: the plan name at 28px bold, the price at 56px beneath it, then what is included as plain lines at 17px (the taller tiles simply have room for more lines). The bottom 64px of every tile is a strip in a darker flat tone of that tile's colour, and the whole strip is the button with its label left-aligned. A heading of two to four words at about 4vw occupies the empty space above the two shorter tiles, top-left.",
  "mobile": "Tiles stack full width in rising order, each as tall as its content, keeping its colour and its darker button strip along the bottom. Gaps of 8px. The heading leads at 9vw on two lines, and prices drop to 44px.",
  "seenIn": []
 },
 {
  "id": "faq-large-questions-answers-beside",
  "role": "faq",
  "name": "Large questions, answers beside",
  "shape": "table",
  "tags": [
   "type-led"
  ],
  "directions": [
   "editorial-report",
   "type-only-statement",
   "sentence-as-layout",
   "name-as-monument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "Every answer is open; nothing collapses. Each question-and-answer pair is a row on the 12-column grid: the question in columns 1–6 at clamp(26px, 2.6vw, 40px), medium weight, leading 1.1, no more than two lines; its answer in columns 8–12 at 17–18px with leading 1.55 and a measure near 40ch, its first baseline level with the question's first baseline. Rows are separated by 72–88px of empty space and nothing else. Five to seven rows. The section heading, two or three words, is set at the same size as the questions but in the softer ink and sits as the first row's left cell with an empty right cell, so it reads as the top of the same table rather than a title above it. Because questions are large and answers small, the left column works as a scannable index.",
  "mobile": "Each pair stacks: question at 24px, answer 12px beneath at 17px, then 56px of space before the next. The heading leads at the same 24px in the softer ink. Answers stay open; there is no accordion on the phone either.",
  "seenIn": []
 },
 {
  "id": "faq-alternating-tone-bands",
  "role": "faq",
  "name": "A band per question",
  "shape": "band",
  "tags": [
   "colour-field",
   "type-led"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "borrowed-category",
   "dense-tiles",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "Each question owns a full-bleed band, and the bands alternate between two flat tones (ground and one step away, or two palette colours with their assigned inks) and butt together with no gap. Inside every band the same grid applies: the question in columns 1–5 at 28–32px bold, top-aligned with 56px of padding above; the answer in columns 7–12 at 18px with leading 1.5, the same padding, and 56px below. Bands are as tall as their content, so their heights differ, which gives the stack its rhythm. Four to seven bands. The heading is the first band: two to four words at about 7vw on the accent colour, flush left, taller than the rest at about 40svh with the words anchored to its bottom-left. All answers are visible; there are no toggles.",
  "mobile": "Bands stay full-bleed and alternate. Inside each, the question at 22px then the answer at 17px, with 32px padding top and bottom and 20px at the sides. The heading band is 30svh tall with its words at 13vw at the bottom-left.",
  "seenIn": []
 },
 {
  "id": "faq-filled-tile-accordion",
  "role": "faq",
  "name": "Accordion of filled tiles",
  "shape": "stack",
  "tags": [
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "one-motif",
   "product-performs",
   "specimen-catalogue",
   "loud-flat-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A two-part row. Columns 1–4 hold the heading, two to four words at about 4vw, `position: sticky; top: 96px` so it stays while the list scrolls past. Columns 5–12 hold the accordion: each question is a filled tile one step from the page ground, with 8px between tiles, no border and the page's one radius (or its motif's corner). A closed tile is 72px tall with the question at 20px medium on the left, 24px padding, and on the right a plus sign drawn from two 3px bars 16px long. The open tile switches to the accent colour with its assigned ink, the plus becomes a minus by rotating one bar, and the answer appears at 17px, no wider than 52ch. Build each tile as `<details>` with a shared `name` so one opens at a time. Six to nine tiles.",
  "mobile": "The heading sits above at 9vw, not sticky. Tiles run full width with 6px gaps, questions at 18px wrapping to two lines, closed height at least 64px. The open tile keeps the accent colour, and answers are set at 16px with 20px padding.",
  "seenIn": [
   "opl-locker-land"
  ]
 },
 {
  "id": "faq-open-two-column-reading",
  "role": "faq",
  "name": "Answers as reading text",
  "shape": "grid",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "editorial-report",
   "type-only-statement",
   "page-is-the-object",
   "contact-sheet",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "The questions and answers are set as continuous reading text, like the notes at the back of a programme. A heading of two or three words at 28–32px sits in columns 1–3, top-aligned. Columns 5–12 hold the text in two columns (`columns: 2; column-gap: 56px`), each about 36ch wide, at 16–17px with leading 1.55. Each item is one paragraph: the question is the opening run-in, set in the medium weight in the full ink and ending with its question mark, and the answer continues on the same line in the regular weight. Paragraphs are separated by 20px and carry `break-inside: avoid` so none splits across columns. No toggles, no icons, no boxes. Eight to fourteen items fit on one screen, which is the point: everything is answered at a glance. Where the page's reading column is narrow, use one column.",
  "mobile": "One column at 16px with 20px margins. The heading leads at 24px. Each paragraph keeps its run-in question in the medium weight, with 18px between paragraphs. The section may run to two screens; do not collapse it into an accordion.",
  "seenIn": []
 },
 {
  "id": "faq-accordion-beside-tall-photo",
  "role": "faq",
  "name": "Questions beside a tall photograph",
  "shape": "split",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "place-carries-it",
   "split-stage",
   "uneven-photographs",
   "dark-room",
   "hero-object",
   "page-is-the-object"
  ],
  "needs": [],
  "blueprint": "One field in a deep or pale flat tone, split 5 to 7. Left, columns 1–5: a heading of two to four words at 36–44px; under it the three or four practical facts people ask first (address, times, how to arrive) as plain lines at 18px; then five or six questions as an accordion with no lines between rows, only 28px of space. Each question is a `<summary>` at 20px with a small chevron drawn from two 2px strokes at its right; the open answer sits beneath at 17px in the softer ink, on the same left edge. One filled button and one text link close the column. Right, columns 7–12: a tall photograph (3:4) that bleeds off the top edge of the section and stops 80px short of the bottom, with a second, square photograph a third of its width overlapping its lower-left corner by 40px.",
  "mobile": "The tall photograph leads at full width, 4:5, with the square one overlapping its bottom-right corner at 38% width. Heading at 30px, facts at 17px, then the accordion at full width with 24px between questions and 44px touch targets. Button full width at the end.",
  "seenIn": [
   "opl-sarah-matt-wedding"
  ]
 },
 {
  "id": "faq-wide-heading-indented-list",
  "role": "faq",
  "name": "Wide heading, indented questions",
  "shape": "list",
  "tags": [
   "type-led"
  ],
  "directions": [
   "name-as-monument",
   "type-only-statement",
   "uneven-photographs",
   "hero-object",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "A heading of three to five words at about 5.5vw runs from the left margin on one or two lines and is the only large thing in the section. 80px beneath it the questions begin, indented to start at column 5 and running to column 12, so the left third under the heading stays empty. Each question is a row at 22–24px in the regular weight with a chevron of two 2px strokes at the far right; rows are separated by 36px of space and no rules. Opening a row lays a tile of the one-step tone behind that row and its answer (24px padding, extending 24px beyond the text on both sides) and shows the answer at 17px with a 48ch measure. Six questions. Nothing else shares the screen; the indent against the wide heading is the composition. Build with `<details>`.",
  "mobile": "The heading sits at 10vw on two lines. Questions lose the indent and run full width at 19px with 28px between rows; the open row keeps its tone tile, now bleeding to both screen edges, with the answer at 16px inside it.",
  "seenIn": [
   "opl-neuropelvic-surgery"
  ]
 },
 {
  "id": "faq-question-index-answer-pane",
  "role": "faq",
  "name": "Question index, one answer",
  "shape": "index",
  "tags": [
   "quiet",
   "ui-led"
  ],
  "directions": [
   "contact-sheet",
   "product-performs",
   "quiet-instrument",
   "pinned-poster",
   "dense-tiles"
  ],
  "needs": [],
  "blueprint": "Questions on the left, one answer on the right, and the answer changes as the reader moves through the questions. Columns 1–5 list all questions at one size, 18px, with 18px between them; each is preceded by a solid dot 8px across, in the softer ink when idle and in the accent colour when selected. The selected question is in the full ink, the rest in the softer ink. Columns 7–12 hold the answer pane, `position: sticky; top: 120px`: the selected answer at 24–28px in the regular weight, leading 1.3, measure about 30ch, so the answer, not the question, is the large type. Selection follows hover, click and keyboard focus; the pane crossfades in 150ms. The first question is selected on load. A two-word heading in the softer ink is the first line of the list, at the same 18px.",
  "mobile": "The list runs full width at 18px. Tapping a question colours its dot and opens its answer directly beneath at 19px, indented to line up with the question text; other answers close. There is no pane and no sticky element.",
  "seenIn": [
   "opl-weeksync"
  ]
 },
 {
  "id": "faq-tabbed-index-cards",
  "role": "faq",
  "name": "Tabbed index cards",
  "shape": "stack",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "page-is-the-object",
   "specimen-catalogue",
   "borrowed-category",
   "loud-flat-poster",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "Questions are filed like cards in a drawer. A heading of two to four words at about 4vw sits in columns 3–10. Beneath it, in the same columns, five or six filled panels are stacked so that each overlaps the one above, leaving 96px of every card showing. Each card has a tab on its top edge, 200px wide and 40px tall, stepping right by 150px per card, cut with `clip-path: polygon()` so card and tab are one filled shape. The cards rotate through two or three palette colours. The tab carries the topic in one or two words at 16px; the visible strip under it shows the question at 20px medium. Choosing a card expands it to show the answer at 17px over at most 50ch and pushes the cards below it down. One card is open at a time; the first is open on load.",
  "mobile": "Cards run full width with the same overlap, 88px of each showing. Tabs shrink to 120px wide and step right by 48px per card. The open card expands in place with its answer at 16px and 20px padding, and the heading leads at 9vw.",
  "seenIn": []
 },
 {
  "id": "cta-idea-restated-inverted",
  "role": "cta",
  "name": "The idea again, inverted",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster",
   "colour-field"
  ],
  "directions": [
   "type-only-statement",
   "name-as-monument",
   "sentence-as-layout",
   "editorial-report",
   "loud-flat-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The close flips the page's colours: ink becomes the ground and the ground becomes the ink, across a full-bleed field at least 90svh tall. The page's opening idea returns as a headline of three to five words at 9–11vw on two lines, leading 0.9, flush left, positioned in the upper two-thirds; it should echo the hero's wording, not repeat it. Along the bottom, on one shared baseline 40px above the edge: one supporting sentence at 20px in columns 1–5 and one rectangular button in the accent colour, right-aligned in columns 10–12, about 56px tall. Nothing else: no image, no second button, no logo. The field's top edge is a hard horizontal line where it meets the section above. If the footer follows on the same inverted field, leave 160px before it so the close still reads as its own screen.",
  "mobile": "The same inverted field at 80svh. Headline at 15vw on two lines at the top. The sentence sits lower at 17px, and the button spans the full width at the bottom, 56px tall, with 20px margins. Nothing else is added.",
  "seenIn": [
   "opl-textmode-overlay",
   "aww-made-with-gsap-1"
  ]
 },
 {
  "id": "cta-giant-word-is-the-button",
  "role": "cta",
  "name": "One word is the button",
  "shape": "band",
  "tags": [
   "poster",
   "colour-field"
  ],
  "directions": [
   "loud-flat-poster",
   "colour-chapters",
   "borrowed-category",
   "name-as-monument",
   "type-only-statement",
   "pinned-poster",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "The entire band is one link. A full-bleed field in the accent colour, about 60svh tall, contains a single verb of four to eight letters set at 24–30vw in the heaviest weight, tight tracking, sized with `clamp()` so it spans the content width on one line with its baseline 24px above the bottom edge. The `<a>` wraps the whole band; on hover and focus the field and the word swap colours instantly. In the top-left corner, at 20px, one sentence says what happens next; in the top-right, an arrow drawn as a simple inline SVG, 48px, points up and right. There is no other button anywhere in the section. In a page with a motif, the band's top corners take the motif's shape (a large radius, or a chamfer cut with `clip-path`). Keep the word honest: the action itself, not a slogan.",
  "mobile": "The band is 46svh tall. The word is set at whatever size fills the width on one line; choose a shorter verb rather than going below 22vw. Sentence top-left at 17px across two lines, arrow top-right at 32px. The whole band stays tappable.",
  "seenIn": [
   "aww-lacoste-ace-breaker",
   "aww-made-with-gsap-1"
  ]
 },
 {
  "id": "cta-object-returns",
  "role": "cta",
  "name": "The object returns",
  "shape": "split",
  "tags": [
   "object-led",
   "photo-led"
  ],
  "directions": [
   "hero-object",
   "specimen-catalogue",
   "dark-room",
   "one-motif",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The thing shown in the hero comes back once, from another angle, to close the page. On one flat field 90svh tall, the object's photograph occupies columns 1–8, scaled to about 130% of that area and anchored so the section's bottom and left edges crop it (`overflow: hidden` on the section; on light fields `mix-blend-mode: multiply` removes a white studio background). The text sits small in columns 10–12, vertically centred: a closing line of three to five words at 32–40px, one sentence at 17px, the price or key fact on its own line at 22px, and one rectangular button. Nothing overlaps the object's focal area. Where the page's subject is a shape rather than a photograph, the page's motif takes the object's place at the same scale as a flat filled shape. The closing line is the only heading.",
  "mobile": "The text block comes first: closing line at 28px, sentence, fact, full-width button. The object follows at 130% of the screen width, shifted so the right and bottom edges crop it, filling about 55svh on the same flat field.",
  "seenIn": [
   "opl-dabbit",
   "opl-monte"
  ]
 },
 {
  "id": "cta-plain-sentence-one-action",
  "role": "cta",
  "name": "A plain sentence, one action",
  "shape": "stack",
  "tags": [
   "quiet"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "contact-sheet",
   "uneven-photographs",
   "split-stage",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "The page ends on an almost empty screen. On the page ground, with no band and no panel, one sentence of eight to fourteen words is set at 18–22px in the regular weight, in the full ink, no wider than 34ch, on the same left edge as the page's text column (or centred only if the whole page is centred). 24px beneath it sits one small filled button, about 40px tall with 18px side padding, and optionally one text link beside it on the same baseline. That is all. Above the sentence leave at least 30svh of empty ground and below it at least 20svh before the footer line, so the emptiness reads as decided. The sentence is written as something a person would say, and it names the action plainly. No headline-sized type appears anywhere in this section.",
  "mobile": "Identical in spirit: the sentence at 18px with 20px margins, the button beneath at its natural width (not full width), 44px tall. Keep 20svh of empty ground above the sentence and 14svh below the button, before the footer line.",
  "seenIn": [
   "opl-compile",
   "opl-rows"
  ]
 },
 {
  "id": "cta-photograph-text-one-half",
  "role": "cta",
  "name": "Last photograph, text beside",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "place-carries-it",
   "uneven-photographs",
   "dark-room",
   "split-stage",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "A final full-bleed photograph, 100vw by 90svh, chosen so its subject sits in the left half and its right half is calm. All text is confined to the right half and never crosses the centre line: in columns 8–12, anchored to the lower third, a closing line of three to five words at about 3.4vw in a light weight, a paragraph of two or three lines at 17px, and one filled button in the paper colour with ink text, about 52px tall. If the image behind the text is not calm enough, darken the whole photograph slightly with `filter: brightness(0.8)` rather than laying a panel behind the words. The section butts directly against a footer in one solid colour, so the photograph's bottom edge is a hard line. No logo, no second action.",
  "mobile": "The photograph is 100vw by 70svh, cropped to keep its subject. The text leaves the image and sits beneath it on the footer's solid colour: closing line at 28px, paragraph at 17px, full-width button. Image and colour meet at a hard edge.",
  "seenIn": [
   "aww-tengile-malamala-collection",
   "opl-neuropelvic-surgery"
  ]
 },
 {
  "id": "cta-photo-strip-then-field",
  "role": "cta",
  "name": "Three photographs, then a field",
  "shape": "strip",
  "tags": [
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "contact-sheet",
   "editorial-report",
   "uneven-photographs",
   "place-carries-it",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "Two parts with no gap between them. First, a strip of three photographs butted edge to edge across the full viewport, each one third wide and 40svh tall, `object-fit: cover`, no gutters and no captions; give all three the same treatment. Directly beneath, a band in one flat palette colour, about 44svh tall. In the band, columns 1–7: the closing statement at 26–30px over three lines in the band's assigned ink, with exactly one phrase underlined by a 4px bar (`text-decoration-thickness: 4px; text-underline-offset: 6px`), and that phrase is the link; two lines at 16px follow 32px lower. In the lower-right corner, columns 11–12, a small portrait photograph (4:5, about 150px wide) sits 40px from the band's edges: the person who will answer. There is no button; the underlined phrase is the action.",
  "mobile": "The strip becomes one full-width photograph at 4:3 with the other two side by side beneath it at half width each, still gutterless. The band follows: statement at 22px, the two small lines, and the portrait at 96px wide in the bottom-right corner.",
  "seenIn": [
   "opl-nia-creative"
  ]
 },
 {
  "id": "cta-reply-card",
  "role": "cta",
  "name": "The reply card",
  "shape": "split",
  "tags": [
   "object-led"
  ],
  "directions": [
   "page-is-the-object",
   "borrowed-category",
   "dark-room",
   "specimen-catalogue",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "The invitation to act is a printed card lying on the page. On a dark or strongly coloured flat field about 80svh tall, a paper-coloured panel 44vw wide with 7:5 proportions sits in columns 6–12, rotated two degrees, with its lower edge hanging 60px past the section's bottom so the section crops it. On the card, with 48px padding, everything is centred the way stationery is: a heading of two to four words at 32px, one or two lines at 17px, then the action as a solid bar across the card's full inner width, 56px tall, in the ink colour with paper-coloured text. Columns 1–4 hold only a short paragraph at 20–24px in the field's light ink, bottom-aligned 64px above the edge. The card is a plain filled rectangle with square corners and no outline; its rotation and crop make it an object.",
  "mobile": "The paragraph comes first at 20px. The card follows at 90% width, tilted one degree, centred, its bottom edge still cropped by the section. Heading at 24px, lines at 16px, and the action bar 52px tall across the full inner width of the card.",
  "seenIn": [
   "opl-hebbia",
   "opl-monte",
   "opl-specia1ne"
  ]
 },
 {
  "id": "cta-blank-in-the-sentence",
  "role": "cta",
  "name": "A sentence with a blank",
  "shape": "statement",
  "tags": [
   "type-led",
   "ui-led"
  ],
  "directions": [
   "sentence-as-layout",
   "product-performs",
   "type-only-statement",
   "one-motif",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The closing sentence has a gap in it, and the gap is the form. A sentence of five to eight words is set at about 6vw on two lines, flush left across columns 1–11; there is no separate heading. Where the reader's email (or name, or city) belongs, an `<input>` sits inline on the baseline: a filled block of the one-step tone, exactly one line-height tall and about 7em wide, with the placeholder in the same face and size in the softer ink, no border, and the page's radius or motif corner. The sentence's last word is the submit `<button>`, set in the same type on a block of the accent colour of the same height. Field and button use `em` units so they scale with the sentence. One line at 16px under the sentence, in the softer ink, says what happens after sending. Nothing else is in the section.",
  "mobile": "The sentence is set at 10vw on two lines; the input drops to a line of its own between them at full width and one line-height tall, and the final word, as the button, ends the second line. The explaining line follows at 15px.",
  "seenIn": [
   "opl-shelf"
  ]
 },
 {
  "id": "cta-action-tile-row",
  "role": "cta",
  "name": "The action tile",
  "shape": "grid",
  "tags": [
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "colour-chapters",
   "specimen-catalogue",
   "one-motif",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "The close is one row of the board. Three filled tiles sit side by side with 8–14px gaps, all 360px tall and sharing one radius: a wide centre tile spanning columns 3–10 in the accent colour, flanked by two narrow tiles (columns 1–2 and 11–12) that run off the left and right viewport edges, as if the row had been cropped from a longer one. The centre tile holds, left-aligned and vertically centred with 40px padding: a heading of three to five words at 40px, two lines at 17px, and the action. The action is either one rectangular button or an email field 420px wide in the paper colour with the submit button nested inside its right end. The flanking tiles hold no text: one is a photograph covering the tile, the other a flat palette colour. No shadows, no outlines.",
  "mobile": "The accent tile runs full width, about 300px tall, heading at 28px, field and button stacked at full width. The two flanking tiles follow beneath as a pair, each half width and 120px tall, with the same gap and the same radius.",
  "seenIn": [
   "opl-shelf"
  ]
 },
 {
  "id": "footer-columns-then-wordmark",
  "role": "footer",
  "name": "Columns above, wordmark below",
  "shape": "stack",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "name-as-monument",
   "place-carries-it",
   "uneven-photographs",
   "dark-room",
   "editorial-report",
   "type-only-statement",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The footer inverts ground and ink and fills a whole viewport (min-height 100svh), arranged as three rows in a grid (auto 1fr auto). Top row, 40px from the edge: four small columns on the 12-column grid (address, navigation, social, hours or contact), each a plain list at 15–16px with its first line in the full ink and the rest in the softer ink. Middle row: empty. Bottom row: the wordmark alone, sized to run from the left margin to the right margin on one line. Set it as inline SVG text with a `viewBox` fitted to the word and `width: 100%`, or tune `font-size` in `vw` to the letter count, so it never wraps; its baseline sits 20px above a single line of legal text at 13px. With `position: sticky; bottom: 0` and a lower `z-index` than the page, the last section slides away to uncover it.",
  "mobile": "Columns become two by two at 15px with 32px between rows, at the top. A gap of about 20svh follows. The wordmark spans the full width at the bottom; a name longer than ten letters stacks on two lines, each fitted to the width. Legal line beneath at 12px.",
  "seenIn": [
   "aww-kononenko-architectural-bureau",
   "aww-zeroz-brand-site",
   "opl-eagles-nest",
   "aww-white-desert",
   "opl-lovably"
  ]
 },
 {
  "id": "footer-wordmark-first-places-below",
  "role": "footer",
  "name": "Name first, places under it",
  "shape": "list",
  "tags": [
   "type-led"
  ],
  "directions": [
   "name-as-monument",
   "editorial-report",
   "type-only-statement",
   "colour-chapters",
   "pinned-poster",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "The wordmark opens the footer instead of closing it. On a full-bleed field in the page's inverse or final chapter colour, the name spans the full content width as the first thing, its cap line pushed 8% above the footer's top edge so the tops of the letters are sliced off by the section (`overflow: hidden` and a negative top margin in `em`). 72px beneath, a two-part row: columns 1–5 list the places or ways to reach the studio (cities, or email and telephone), one per line at 32–36px in the accent ink, each a link; columns 7–12 hold three columns of small links at 15px, top-aligned with the first place. A final row 96px lower carries the legal line at the left and a back-to-top text link at the right, both 13px. The field is about 90svh tall, and nothing sits between the wordmark and the row but space.",
  "mobile": "The sliced wordmark stays at the top, fitted to the width. Places follow as a list at 26px, then the link columns two across at 15px. Legal line and back-to-top link share the last row at 12px, at opposite margins.",
  "seenIn": [
   "aww-alethia",
   "aww-hilden-kaira",
   "aww-px-push",
   "aww-pensatori-irrazionali"
  ]
 },
 {
  "id": "footer-invitation-over-cropped-wordmark",
  "role": "footer",
  "name": "Invitation over a cropped name",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "loud-flat-poster",
   "name-as-monument",
   "borrowed-category",
   "pinned-poster",
   "type-only-statement",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "Close and footer in one full-viewport field of the inverse colour. Upper third, columns 1–7: a two-line invitation at about 5vw, three to five words, with one filled rectangular button beneath it. At columns 10–12, top-aligned with the invitation, three or four social or contact links stacked at 16px. The wordmark fills the bottom of the screen at 26–30vw on one line, `white-space: nowrap`, heavier and larger than the viewport can hold: it is pushed down with `transform: translateY(30%)` so the bottom edge cuts through the letters, and a long name also runs off the right edge. `overflow: hidden` on the footer does the cropping. One row of credits and legal links at 13px sits directly above the wordmark's cap line, spread left and right. The cropped name must be recognisable from its upper two-thirds alone; test that it is.",
  "mobile": "Invitation at 11vw on two lines at the top with a full-width button. Links follow as one row of words at 15px. The wordmark sits at 34vw at the bottom, cropped by both the bottom and the right edge, with the legal row above it at 12px.",
  "seenIn": [
   "aww-nothin",
   "opl-goodgoodgreat",
   "opl-marina-rachello",
   "aww-butter"
  ]
 },
 {
  "id": "footer-wordmark-on-last-photograph",
  "role": "footer",
  "name": "Name across the last photograph",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "place-carries-it",
   "uneven-photographs",
   "dark-room",
   "hero-object",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "The footer is the page's last photograph, full-bleed at 100vw by 100svh, darkened evenly with `filter: brightness(0.62)` so paper-coloured type reads everywhere on it. Three layers of text sit on the image. Top row, 32px from the edge: a short link list at 16px at the left margin and contact details at the right margin, each no wider than three columns. Mid-height is left to the photograph. Along the bottom, the wordmark runs the full width in the paper colour, letters about 12–20vw tall depending on the name's length, baseline 56px above the edge; under it one line of legal text at 13px. Choose an image whose interest is in its middle band, since the top and bottom carry text. No panel, no box and no colour block separates the footer from the image: the photograph is the footer.",
  "mobile": "The photograph is 100vw by 88svh, cropped to its strongest vertical slice. Links stack at the top-left at 16px, contact beneath them. The wordmark is fitted to the width along the bottom, with the legal line under it at 12px.",
  "seenIn": [
   "aww-seasats",
   "aww-alethia",
   "opl-vestris"
  ]
 },
 {
  "id": "footer-mark-built-from-shapes",
  "role": "footer",
  "name": "The mark, built large",
  "shape": "band",
  "tags": [
   "colour-field",
   "object-led"
  ],
  "directions": [
   "one-motif",
   "specimen-catalogue",
   "colour-chapters",
   "loud-flat-poster",
   "dense-tiles",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "On a flat colour field, the brand closes the page as a construction rather than as set type. Across the full content width and about 34svh tall, the wordmark is built from blocks: each letter is knocked out of its own solid rectangle in a palette colour, the rectangles differ in height (60–100% of the band) and butt together with 4px gaps, bottoms on one line. Where the project has a simple geometric mark or motif, enlarge that instead, drawn as inline SVG from flat rectangles, circles and quarter-arcs with the same 4px gaps. Below, 56px lower: four columns of plain links at 15px across columns 1–8, and two addresses or contact lines in columns 9–12. A last row carries the copyright at the left and one small filled button at the right. Space and the block construction do all the separating.",
  "mobile": "The block wordmark spans the full width at about 22svh tall; a long name breaks into two rows of blocks. Link columns go two across at 15px, contact lines beneath at full width, then copyright and the small button on one row.",
  "seenIn": [
   "aww-house-of-honey-1",
   "opl-woblo",
   "aww-2xa-studio",
   "opl-uglycash"
  ]
 },
 {
  "id": "footer-single-quiet-line",
  "role": "footer",
  "name": "A single quiet line",
  "shape": "strip",
  "tags": [
   "quiet"
  ],
  "directions": [
   "quiet-instrument",
   "contact-sheet",
   "product-performs",
   "split-stage",
   "dark-room",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "No footer block at all. After at least 30svh of empty page ground, one baseline 30px above the bottom edge carries three items in the page's ordinary small text size (14–16px), the same size and weight as the navigation: at the left margin, what this is in three or four words; centred, two to five links separated by 24px of space; at the right margin, the year and a privacy link. Use a three-column grid (1fr auto 1fr) so the centre group is truly centred whatever the side lengths. No logo, no background change, no line above it, no columns. Links are in the full ink; the rest in the softer ink. If the page has a narrow text column, the three items may instead sit on that column's left edge as one wrapped line. The restraint only works if the page above has already made its invitation.",
  "mobile": "Three short lines at 14px on the left margin with 8px between them: the description, the links on one wrapped line, then year and privacy. Links stay in the full ink. Leave 20svh of empty ground above and 24px below.",
  "seenIn": [
   "aww-studio-k95-3",
   "aww-gil-huybrecht",
   "opl-rows",
   "opl-compile",
   "opl-tiny-computer-co"
  ]
 },
 {
  "id": "footer-contact-set-large",
  "role": "footer",
  "name": "Contact as the headline",
  "shape": "statement",
  "tags": [
   "colour-field",
   "type-led",
   "poster"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "type-only-statement",
   "borrowed-category",
   "sentence-as-layout",
   "editorial-report",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A full-bleed field in one saturated palette colour, at least 90svh tall. Top row, 40px from the edge: two or three short notes at 15px in columns 1–7 (address, hours, a one-line description) and a back-to-top text link at the right margin. Then a tall empty gap. In the lower half, the way to get in touch is the display line: the telephone number or the email address itself, set at 8–9vw, flush left, hard against the margin, as one link. An email address may break once, before the @, giving two lines. Size it with `clamp()` so the longest line fills at least 80% of the width. Under it, one row of small text at 14px spread across three positions: links at the left, credits in the centre, city and year at the right. The wordmark appears only small, in the top row.",
  "mobile": "Notes stack at the top at 15px. The contact line sits in the lower half at 12vw; a telephone number stays on one line, an email breaks before the @ and is sized so each half fits the width. The meta row becomes three stacked lines at 13px.",
  "seenIn": [
   "aww-era-residence",
   "aww-coffeetech-r",
   "opl-ohhmydesign",
   "opl-mammut-studios",
   "opl-belgrade-architecture"
  ]
 },
 {
  "id": "footer-back-of-the-card",
  "role": "footer",
  "name": "The back of the card",
  "shape": "stack",
  "tags": [
   "object-led",
   "quiet"
  ],
  "directions": [
   "page-is-the-object",
   "specimen-catalogue",
   "borrowed-category",
   "hero-object",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "The footer is the reverse side of the page's artefact, laid out as the back of a postcard. A paper-coloured panel spans columns 2–11 on the contrasting ground, about 420px tall, its top edge cut into a row of twelve semicircular scallops (an inline SVG path or `clip-path`, each scallop about 100px wide). The panel is divided by space into two halves. Left half: a short sign-off of two to four words at 32px, then two or three lines at 16px, written like a message. Right half: the address block, four left-aligned lines at 18px with 12px between them, each line a link (email, telephone, street, social); above it in the top-right corner, a stamp: one small photograph, 3:4 and 88px wide, with a 6px paper-coloured margin. Along the panel's bottom edge, a single centred line at 12px gives the copyright like a printer's imprint.",
  "mobile": "The panel runs full width with six scallops across its top edge. The stamp sits top-right at 64px wide; the sign-off and message come first, then the address lines at 17px beneath them, then the imprint line centred at 12px.",
  "seenIn": [
   "opl-zara-webb",
   "opl-britt-taylor",
   "opl-sarah-matt-wedding",
   "opl-transport"
  ]
 },
 {
  "id": "footer-row-of-tiles",
  "role": "footer",
  "name": "A footer of tiles",
  "shape": "grid",
  "tags": [
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "colour-chapters",
   "product-performs",
   "contact-sheet",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "The footer is the last two rows of a board: filled tiles with 8px gaps and 8–12px outer margins, every cell used. Upper row, each tile 220px tall: a statement tile in columns 1–4 with one sentence at 24px top-left and the legal text at 12px bottom-left; a navigation tile in columns 5–7 with a plain list at 16px; a tile in columns 8–9 giving the city and local time as text; an email tile in columns 10–12 with the address at 20px as a link, bottom-left. Lower row, 320px tall: a tile in the ink colour spanning columns 1–8 with the wordmark set large (about 9vw) and centred, and a tile in the accent colour in columns 9–12 with a two-to-four-word invitation top-left at 28px and an arrow, drawn as inline SVG with 4px strokes, in its bottom-right corner; the whole accent tile is a link.",
  "mobile": "Two columns of tiles with 6px gaps: the wordmark tile full width first at 160px tall, the accent tile full width next, then navigation and email side by side, then time and statement. Every tile keeps 20px padding and top-left text.",
  "seenIn": [
   "aww-aspen-search",
   "aww-oimachi"
  ]
 },
 {
  "id": "footer-photograph-and-large-menu",
  "role": "footer",
  "name": "Photograph half, menu half",
  "shape": "split",
  "tags": [
   "photo-led",
   "type-led"
  ],
  "directions": [
   "split-stage",
   "loud-flat-poster",
   "hero-object",
   "uneven-photographs",
   "pinned-poster",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A split footer one viewport tall. The left half is a single photograph filling columns 1–6 edge to edge, cropped hard by the left and bottom viewport edges: a close crop of the page's main subject, larger than it appeared anywhere above. The right half, on a flat field, is the site's menu set large: five or six section names stacked as links at about 2.6vw bold, 28px apart, starting at column 8 and 80px from the top. Rows are separated by space only; on hover a row's label shifts right by 12px and the others drop to the softer ink. Under the menu, 64px lower, two short blocks at 15px side by side: contact and social. The legal line runs along the very bottom of the right half at 12px. The wordmark sits small at the top of the right half, on the menu's left edge.",
  "mobile": "The photograph leads at full width and 50svh, cropped tight. The menu follows on the flat field at 8vw per item with 20px between items, then contact and social stacked at 15px, then the legal line at 12px. The wordmark sits small above the menu.",
  "seenIn": [
   "aww-trevor-noah",
   "aww-monolog"
  ]
 },
 {
  "id": "intro-right-half-paragraph",
  "role": "intro",
  "name": "Right-half paragraph, lone photo",
  "shape": "split",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "uneven-photographs",
   "dark-room",
   "place-carries-it",
   "editorial-report",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "No heading. On a 12-column grid with 5vw margins, one statement paragraph of 45–70 words occupies columns 7–12 only, set at clamp(24px, 2.2vw, 34px) with leading 1.25 and a first-line indent of 3em. Its top edge sits 18vh below the section top. Columns 1–6 stay empty except for a single square photograph, 20vw wide, in columns 1–3, placed so its top aligns with the paragraph's last line and it hangs below the text by most of its height. A five-line note at 14px, 180px wide, sits in columns 1–2, 96px beneath the photograph. Section padding is 160px top and bottom. Nothing is centred: the paragraph's left edge is the midpoint of the page, and later text blocks on the page may reuse that line. Holds one paragraph, one photograph and one note.",
  "mobile": "The paragraph comes first at 22px, full width with 20px margins and no indent. The photograph follows at 62% width, pushed to the right margin, in a 4:5 crop instead of square. The small note sits to its left, bottom-aligned with the photograph, in the remaining 30% of the width.",
  "seenIn": [
   "aww-tengile-malamala-collection",
   "aww-normal-is-boring",
   "aww-px-push",
   "aww-monolog"
  ]
 },
 {
  "id": "intro-edge-to-edge-paragraph",
  "role": "intro",
  "name": "Edge-to-edge heavy paragraph",
  "shape": "band",
  "tags": [
   "type-led",
   "colour-field",
   "poster"
  ],
  "directions": [
   "loud-flat-poster",
   "colour-chapters",
   "type-only-statement",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "A full-bleed flat colour field, at least 90svh tall, filled by one paragraph and nothing else. The paragraph is 35–55 words in the heaviest weight at clamp(36px, 5vw, 76px), leading 0.98, left-aligned, running from a 16px left margin to a 16px right margin so its lines reach both edges; use `text-wrap: pretty` and no hyphenation. No heading: the paragraph is the section. In two or three places a word-sized gap (an inline-block spacer 2.5em wide) opens inside a line. One small photograph, 9vw wide at 4:5 with square corners, is absolutely placed over the first two lines, rotated 4 degrees, covering parts of two words; a second chip at 6vw may sit in one of the gaps. Text takes the field's assigned ink at full contrast. The field butts against the section above with no space between them. On a page without images, drop the chips and close the gaps.",
  "mobile": "The same field and weight at 9.5vw with leading 1.0 and 14px margins, so each line holds two or three words and the paragraph runs about fourteen lines. Remove the inline gaps. The photograph chip grows to 26vw and sits over the right end of lines three and four, still tilted.",
  "seenIn": [
   "aww-warm-fuzzy",
   "opl-belgrade-architecture"
  ]
 },
 {
  "id": "intro-inline-thumbnails",
  "role": "intro",
  "name": "Statement with inline thumbnails",
  "shape": "statement",
  "tags": [
   "type-led",
   "object-led"
  ],
  "directions": [
   "sentence-as-layout",
   "specimen-catalogue",
   "one-motif",
   "product-performs",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "A centred statement of 18–28 words, four lines at clamp(32px, 4.2vw, 62px), medium or bold weight, leading 1.12, in a block 60–66% of the viewport wide with 22vh of empty ground above and below. Three to five small frames sit inline between words: each is an `inline-block` 0.85em tall and 0.85–1.3em wide, `vertical-align: -0.1em`, radius 0.14em, holding a tightly cropped photograph (or a crop of real interface) of the thing the neighbouring word names. Frames are part of the line, never floating over it, with 0.12em of space either side. No heading above and no paragraph below; the statement is set as a `p`. Directly under the section the page may split into two flat colour halves or begin the first product row, so the thumbnails read as a preview of what follows. On a motif-driven page every frame takes the motif's shape.",
  "mobile": "Left-aligned instead of centred, at 8vw with leading 1.15, about seven lines inside 20px margins. Keep at most three inline frames, each exactly one cap-height tall, and make sure none lands at the start of a line. Vertical padding drops to 96px.",
  "seenIn": [
   "aww-noho",
   "aww-butter",
   "opl-sand-studio"
  ]
 },
 {
  "id": "intro-narrow-column",
  "role": "intro",
  "name": "Narrow column, empty field",
  "shape": "stack",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "split-stage",
   "hero-object",
   "name-as-monument",
   "contact-sheet",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "A single text column 300–340px wide, its left edge at 15% of the viewport width, starting 64px below the section top. It holds four or five short paragraphs at 15–16px with leading 1.5 and one blank line between them; the first paragraph is in medium weight and full ink, the rest in regular weight and the soft ink. No heading, no image, no button; one underlined text link may end the last paragraph. The remaining three-quarters of the 1440 screen is untouched ground, and the section is only as tall as the text plus 160px of padding below. The column's left edge must match a line used elsewhere on the page (the wordmark's left edge or a later column). Where the page has a large object or a fixed photograph, this column is the small text beside it. Holds 90–150 words comfortably.",
  "mobile": "The column becomes 78% of the width, left-aligned at the 20px margin, so a strip of empty ground remains on the right as a deliberate margin. Text stays at 15–16px with the first paragraph in medium weight. Padding is 72px above and 96px below.",
  "seenIn": [
   "opl-when-you-wish-upon-a-starboy",
   "opl-deeo-studio-2",
   "aww-vero-new-york"
  ]
 },
 {
  "id": "intro-title-note-facts",
  "role": "intro",
  "name": "Title, note, facts row",
  "shape": "split",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "page-is-the-object",
   "editorial-report",
   "name-as-monument",
   "uneven-photographs",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "First row: a heading of two to four words in a light weight at clamp(44px, 5vw, 76px) sits on the left margin in columns 1–6; a three-line paragraph at 16px, 260px wide, sits in columns 10–12 with its last line on the heading's baseline. Columns 7–9 stay empty. 120px lower, a second row sets three or four facts (a date, a time, a place, a price) as large light text at clamp(28px, 3vw, 44px), spread across the 12 columns with `justify-content: space-between`. Each fact has a one-line explanation at 14px directly under it in the soft ink, left-aligned to the fact. Facts are separated only by the space between them, so the row reads like the line of times on a printed programme. A photograph may start under the right half of the facts row, 5 columns wide at 4:3. Padding 140px top, 120px bottom.",
  "mobile": "Heading at 11vw on the left margin; the note follows directly under it at 16px, full width. The facts become a two-column grid with a 32px row gap, each fact at 26px with its explanation beneath. Any photograph goes last, full width.",
  "seenIn": [
   "opl-sarah-matt-wedding",
   "aww-no-art"
  ]
 },
 {
  "id": "intro-staircase-statement",
  "role": "intro",
  "name": "Staircase statement",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster",
   "colour-field"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "type-only-statement",
   "dark-room",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "On a flat dark or saturated field one sentence of 12–18 words is broken into five short lines at clamp(36px, 4.4vw, 64px), bold, leading 1.02, marked up as one paragraph (a statement, so it may run past two lines at this size). Each line starts further right than the one above: indents of 0, 6, 12, 18 and 24vw from a 4vw left margin, set with `padding-left` per line, so the block descends like stairs and ends near the 75% mark. Each line takes its own flat colour from the palette, or lines alternate between two inks. The block is vertically centred in a section 100svh tall. The right quarter (columns 10–12) holds two stacked photographs, each 3:2 and 20vw wide with a 16px gap, top-aligned with the first line. No heading; a two-line note at 15px sits at the bottom-left margin, on the first line's left edge. On a page without images, leave the right quarter empty.",
  "mobile": "Five lines at 9vw (35px on a 390px screen) with indents of 0, 5, 10, 15 and 20vw, so the stair still reads on a 390px screen. The two photographs follow below as one row of two equal 3:2 frames. The note sits last, left-aligned.",
  "seenIn": [
   "aww-pp-neue-montreal",
   "aww-trevor-noah",
   "aww-izanami"
  ]
 },
 {
  "id": "intro-heading-captions-panorama",
  "role": "intro",
  "name": "Heading captions a panorama",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "type-led"
  ],
  "directions": [
   "place-carries-it",
   "hero-object",
   "uneven-photographs",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "The section is exactly 100svh. Its top third holds a two-line heading of three to six words at clamp(48px, 6.5vw, 96px) with leading 0.95, either centred or flush left on the page margin. 28px beneath the heading sit two small captions side by side, each two lines at 13px and 150px wide with a 48px gap, aligned to the heading (centred as a pair, or both starting at its left edge). 40px below them a full-bleed photograph begins and runs off the bottom of the viewport: it is 100vw wide at 21:9 or wider, and only its top 55–60svh is visible before the fold, the rest arriving with scroll. The heading therefore reads as a large caption to the picture. On an object-led page the image is the object on a flat dark panel inset by the page margin instead of bleeding to the edges.",
  "mobile": "Heading at 12vw on two lines, left-aligned, taking the top 30% of the screen. The two captions merge into one four-line block at 14px. The photograph begins at 45% of the screen height in a 4:5 crop, full-bleed, and runs off the bottom.",
  "seenIn": [
   "aww-kononenko-architectural-bureau",
   "aww-nothin",
   "opl-kereliott"
  ]
 },
 {
  "id": "intro-statement-tiles",
  "role": "intro",
  "name": "Statement tiles",
  "shape": "mosaic",
  "tags": [
   "dense",
   "colour-field",
   "type-led"
  ],
  "directions": [
   "dense-tiles",
   "editorial-report",
   "colour-chapters",
   "contact-sheet",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A board of filled tiles with 8px gaps and 8px outer margins: 12 columns by two rows of 38svh each. Tile A, columns 1–8 across both rows, is the darkest tile and carries a slogan of two to four words in capitals at clamp(64px, 9vw, 132px), leading 0.86, on two lines, set hard against the tile's 20px left padding and bottom-aligned. Tile B, columns 9–12 of row one, is a pale tile with one word or figure at the same size, bottom-aligned, cropped by `overflow: hidden` if it runs long. Tile C, columns 9–12 of row two, is a photograph covering the tile. Below the board a fourth tile spans all 12 columns in the page ground colour: its left half is empty and a text of 90–130 words at 22–24px with leading 1.35 runs in columns 7–12 with 40px padding. No separate heading: the slogan in tile A is the heading.",
  "mobile": "A two-column board with 6px gaps. The slogan tile spans both columns at 46svh with type at 17vw on two lines; the word tile and the photograph tile sit side by side as squares; the long text tile spans both columns with 20px padding at 18px.",
  "seenIn": [
   "aww-aspen-search"
  ]
 },
 {
  "id": "intro-lead-over-four-columns",
  "role": "intro",
  "name": "Lead line over four columns",
  "shape": "grid",
  "tags": [
   "type-led",
   "dense",
   "quiet"
  ],
  "directions": [
   "contact-sheet",
   "quiet-instrument",
   "editorial-report",
   "type-only-statement",
   "product-performs",
   "dense-tiles"
  ],
  "needs": [],
  "blueprint": "Two parts on one 12-column grid with 20px margins. First, a lead paragraph of 30–40 words spans all 12 columns at clamp(22px, 2.2vw, 32px), regular weight, leading 1.2: three lines that read as the opening of a text, with no heading before it. 72px below, four equal columns (three grid columns each, 24px gutters) carry small text at 14–15px with leading 1.45. Each column is one paragraph of 40–70 words that opens with a bold run-in phrase of two or three words in full ink, the rest continuing on the same line in the soft ink. Columns top-align and end ragged at different depths. No cards, icons or dividers; the bold run-ins are the only markers. The four columns answer four plain questions: who, what, where, how to reach them. Padding is modest, 96px top and bottom, so a sheet of images can start straight after.",
  "mobile": "The lead paragraph at 20px, full width. The four columns become a two-by-two grid with a 20px gutter and 40px row gap at 14px; if any paragraph exceeds 60 words, switch to one column with 32px between paragraphs. Run-in phrases stay bold.",
  "seenIn": [
   "opl-april-leihuanani-evangelista",
   "aww-2xa-studio",
   "opl-wild-as"
  ]
 },
 {
  "id": "intro-pinned-sentence-photos-pass",
  "role": "intro",
  "name": "Pinned sentence, photographs pass",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "type-led"
  ],
  "directions": [
   "pinned-poster",
   "uneven-photographs",
   "dark-room",
   "sentence-as-layout",
   "place-carries-it"
  ],
  "needs": [],
  "blueprint": "A wrapper 300svh tall. Inside it a `position: sticky; top: 0` layer of 100svh holds one sentence of 8–14 words, centred, three lines at clamp(36px, 4.6vw, 68px), leading 1.1, in a block 62% of the viewport wide. The sentence is a paragraph, not a heading; it is white with `mix-blend-mode: difference` (or a solid ink that reads over every photograph) and has a higher `z-index` than the images. Behind it, three photographs sit in normal flow at different sizes and sides and scroll past the fixed sentence: a 4:5 portrait 24vw wide with its left edge at 6vw; 70svh lower a 16:10 landscape 38vw wide touching the right margin; 70svh lower a 1:1 frame 18vw wide starting at 30vw. Each crosses the sentence once. When the wrapper ends, the sentence releases and scrolls away with the last photograph. No scroll hijacking.",
  "mobile": "Wrapper 260svh. The sentence pins at 40% of the screen height at 8.4vw, four or five lines, with 20px margins. Photographs alternate: 64% wide on the left margin, 78% wide on the right margin, then 52% on the left, with 50svh between them.",
  "seenIn": [
   "opl-the-deep-end",
   "aww-revelatio-studio"
  ]
 },
 {
  "id": "about-portrait-drop-cap",
  "role": "about",
  "name": "Portrait as drop cap",
  "shape": "statement",
  "tags": [
   "type-led",
   "photo-led"
  ],
  "directions": [
   "sentence-as-layout",
   "name-as-monument",
   "dark-room",
   "editorial-report",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "No heading. One biography paragraph of 50–80 words is set large at clamp(24px, 2.5vw, 38px), regular weight, leading 1.2, across columns 3–12 of the grid. A square portrait is floated at the paragraph's start with `float: left`: its side equals three lines of text (3.6em), with 0.5em of space to its right, so the first three lines wrap beside it and the fourth returns to the paragraph's left edge, like a drop capital. Square corners. The person's name, the first words of the paragraph, is in full ink; the rest may be in a softer ink with linked names in full ink and underlined. A second paragraph follows after one blank line in the soft ink at the same size. Columns 1–2 hold three short facts (place, since when, how to reach) at 14px, top-aligned with the portrait. Section padding 140px.",
  "mobile": "Text at 22px with leading 1.25, full width. The portrait keeps its float but grows to four lines tall, about 110px square. The three facts move below the second paragraph as a single row of three small blocks at 13px.",
  "seenIn": [
   "aww-haoqi-design",
   "aww-coffeetech-r",
   "aww-monolog"
  ]
 },
 {
  "id": "about-side-facts-wide-paragraph",
  "role": "about",
  "name": "Side facts, wide paragraph",
  "shape": "table",
  "tags": [
   "type-led",
   "quiet"
  ],
  "directions": [
   "editorial-report",
   "name-as-monument",
   "type-only-statement",
   "quiet-instrument",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "Two rows share one vertical line at 34% of the viewport width. Row one: in a 160px column at the left margin, three label and value pairs stack with 20px between them, each a label in the soft ink with its value beneath in full ink, all at 13px (founded, based in, team size). From the 34% line a five-line paragraph at clamp(20px, 1.7vw, 26px), leading 1.35, runs to 90% of the width. Row two starts 120px lower and repeats the logic for the record of the practice: one plain word at 13px at the far left (awards, press, clients), then from the 34% line two list columns, each 260px wide with a 64px gutter, at 13px with 10px between lines: name at the left, year at the right in the soft ink. No heading; the paragraph opens the section. Each list comfortably holds 6–12 lines. No photographs are needed.",
  "mobile": "Everything aligns to the left margin. The paragraph comes first at 19px. The three label and value pairs follow as a two-column grid at 14px. The two record lists stack with 40px between them, each opened by its plain word in full ink.",
  "seenIn": [
   "aww-kononenko-architectural-bureau",
   "opl-brikkenco"
  ]
 },
 {
  "id": "about-team-colour-tiles",
  "role": "about",
  "name": "Team on colour tiles",
  "shape": "grid",
  "tags": [
   "photo-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "specimen-catalogue",
   "contact-sheet",
   "colour-chapters",
   "dense-tiles",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "A one-line heading of two or three words at 28–36px sits top-left; nothing sits to its right. 40px below, a grid of portrait tiles: six columns with 12px gaps across the full content width for 12 or more people, four columns for 4–8. Every tile is a 4:5 photograph treated identically so mixed-quality headshots look like one set: `filter: grayscale(1) contrast(1.15)` with `mix-blend-mode: multiply` over a flat tint, the tints rotating through four palette colours in a fixed order. Square corners or one small radius. Under each tile, left-aligned to its edge with 10px of space: the name at 15px in full ink and the role at 13px in the soft ink, up to two lines. Rows are 36px apart. A leftover tile in the last row stays at the left; it is not centred. No hover lift and no social icons.",
  "mobile": "Two columns with 8px gaps; tiles stay 4:5 with names at 14px beneath. For more than ten people, switch to three columns and show the name only. The heading sits above at 24px. Tints keep their rotation, so the grid reads as a checkerboard of colours.",
  "seenIn": [
   "opl-compile",
   "opl-select26",
   "opl-alterg-resources"
  ]
 },
 {
  "id": "about-highlighted-columns",
  "role": "about",
  "name": "Two columns, highlighted words",
  "shape": "statement",
  "tags": [
   "type-led",
   "colour-field"
  ],
  "directions": [
   "type-only-statement",
   "loud-flat-poster",
   "colour-chapters",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The story is set as text far larger than anything else on the page: 110–160 words at clamp(22px, 2.1vw, 32px), tight leading 1.18, in the soft ink, flowing through two columns that fill the full width between 24px margins (`columns: 2; column-gap: 32px`, justified, with hyphenation on). No heading. The name at the start is in full ink. Six or seven single words or short phrases carry a flat highlighter background in three or four palette colours, built with an inline `background-color`, `box-decoration-break: clone` and 0.1em of horizontal padding; they mark the facts a scanning reader wants (a place, a year, a client, a skill). The final short sentence is highlighted entire in the strongest colour and is the link to contact. Padding 120px top and bottom. No image and no portrait.",
  "mobile": "One column at 20px, left-aligned and unjustified, leading 1.25, with 18px margins. Keep four or five highlights so no line carries two. The final highlighted sentence sits on its own line with 24px above it and works as a full-width tap target.",
  "seenIn": [
   "opl-adam-ho"
  ]
 },
 {
  "id": "about-founders-letter",
  "role": "about",
  "name": "Founder's letter",
  "shape": "stack",
  "tags": [
   "quiet",
   "type-led",
   "object-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "page-is-the-object",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "A letter in a centred column 520px wide with at least 160px of empty ground above and below. At the column's top-left, a round portrait 44px across. 28px below it, four or five short paragraphs at 16–17px with leading 1.55, each two to four lines and separated by one blank line, written in the first person. No heading. The sign-off is the writer's name at 22px in the text face's italic, with the role at 14px in the soft ink beneath it. One phrase in the letter may carry a flat marker-colour background. Variant for a page built as an object: the letter sits on a filled paper-colour sheet 640px wide with 60px padding on a contrasting ground, square corners, its bottom edge cut into small scallops with `clip-path`, and below a row of 5px round dots a tear-off strip 72px tall that carries the email address.",
  "mobile": "The column takes the full width with 24px margins; text at 16px, portrait 40px. In the sheet variant the sheet spans edge to edge with 24px padding and keeps its shaped bottom edge; the tear-off strip stacks the email above a full-width button.",
  "seenIn": [
   "opl-mat",
   "opl-heyclicky",
   "opl-rafael-derolez-2026"
  ]
 },
 {
  "id": "about-pinned-title-portraits",
  "role": "about",
  "name": "Pinned title, portraits pass",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "poster",
   "type-led"
  ],
  "directions": [
   "pinned-poster",
   "name-as-monument",
   "loud-flat-poster",
   "dark-room",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "A wrapper tall enough for its portraits: about 90svh per person, up to six people. A `position: sticky; top: 0` layer of 100svh holds the heading, two words at clamp(88px, 12vw, 180px), leading 0.85, centred at mid-height, in white with `mix-blend-mode: difference` so it reads over ground and photographs alike. Beneath it in the stacking order, portraits scroll past in normal flow: each a 4:5 photograph 26–30vw wide, alternately starting at column 2 and ending at column 11, 60svh apart, so each one passes behind a different half of the heading. Under each portrait's left corner: the name at 18px, the role at 14px in the soft ink, and one sentence of up to 30 words in a 280px measure. The last portrait is followed by 40svh of empty ground before the heading releases. Square corners. Nothing else is on screen.",
  "mobile": "The heading shrinks to 17vw and pins 16px from the top instead of at mid-height. Portraits become 72% wide, alternating between the left and right margins, 48px apart, each with name, role and sentence beneath at the portrait's width.",
  "seenIn": [
   "aww-glitch-grit",
   "opl-the-deep-end"
  ]
 },
 {
  "id": "about-photo-tile-facts",
  "role": "about",
  "name": "Wide photo, colour tile, facts",
  "shape": "mosaic",
  "tags": [
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "place-carries-it",
   "uneven-photographs",
   "dense-tiles",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "Top: a heading of two to four words at clamp(40px, 4vw, 60px) on the left margin, then 24px below it a three-line lead paragraph at 21–22px across columns 1–6. 72px lower, two tiles sit side by side with a 16px gap. The left tile spans columns 1–8: a landscape photograph at 16:10 with one radius (0 or 10px). A short paragraph of up to 35 words at 16px is set in a solid paper-colour block 320px wide with 20px padding, placed over the photograph's top-right corner and flush with both edges. The right tile spans columns 9–12 and is a flat palette colour, 60% of the photograph's height: it holds a simple inline SVG outline of the region or the floor plan as one filled shape, with one solid dot marking the location. Under the colour tile, four label and value pairs at 14px in a two-by-two grid: altitude, distance, season, local time.",
  "mobile": "Heading at 9vw, lead at 18px. The photograph goes full width at 4:3 and its overlaid paragraph moves beneath it as ordinary text. The colour tile follows at full width and 3:2, and the four label and value pairs sit under it in two columns.",
  "seenIn": [
   "opl-eagles-nest",
   "opl-le-petit-bleu"
  ]
 },
 {
  "id": "about-shaped-portrait-panel",
  "role": "about",
  "name": "Inset panel, shaped portrait",
  "shape": "split",
  "tags": [
   "colour-field",
   "photo-led",
   "object-led"
  ],
  "directions": [
   "one-motif",
   "hero-object",
   "borrowed-category",
   "colour-chapters",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A deep-toned panel inset 20px from the page edges with a 12px radius, about 110svh tall, `overflow: hidden`. Left half: a two-line statement of four to seven words at clamp(40px, 4.4vw, 64px) in a pale ink, top-left with 56px padding; it is the heading and has nothing above it. Below it a photograph of the person or the workshop, masked with `clip-path` or an SVG `mask` to one bold shape taken from the subject (a four-lobed flower, a circle, an arch, a chamfered block), 38vw across, positioned so the panel's bottom edge crops its lowest fifth. Right side, starting at 63% of the width and 56px from the top: two text blocks 300px wide and 64px apart, each a subhead at 22px over a four-line paragraph at 15px; then one solid button in the accent colour. Between the statement and the text blocks the panel is empty. One photograph only.",
  "mobile": "The panel is inset 10px and stacks: the statement at 8.5vw, then the two text blocks at full width, the button, and last the shaped photograph at 86vw across, centred and cropped by the panel's bottom edge by a quarter of its height.",
  "seenIn": [
   "opl-zara-webb"
  ]
 },
 {
  "id": "work-big-names-fixed-preview",
  "role": "work",
  "name": "Big names, fixed preview",
  "shape": "index",
  "tags": [
   "type-led",
   "photo-led"
  ],
  "directions": [
   "name-as-monument",
   "borrowed-category",
   "loud-flat-poster",
   "dark-room",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "Project names are the index. In columns 1–8, five to nine names stack one per line at clamp(44px, 5.4vw, 80px), leading 1.0, each row a link with 10px of vertical padding. All names rest in the ink at 35% opacity except the active one, which is full ink; the first is active by default, and hover or keyboard focus moves the state. To the right of each name, on its baseline at 14px: the discipline and the year. In columns 9–12 a `position: sticky; top: 15vh` frame, 4:5 and 26vw wide with square corners, shows the active project's photograph; all images are stacked in the frame and swapped by opacity over 200ms. A two-word heading at 28px sits top-left, 48px above the first name. Nothing is drawn between rows: the size of the type and the dimming do the separating.",
  "mobile": "Names at 9.5vw, all in full ink, one per line; discipline and year sit beneath each name at 13px. There is no sticky frame: each name is preceded by its own photograph at 16:10, full width, so the list becomes image then name, repeated, with 40px between projects.",
  "seenIn": [
   "opl-belen-jones",
   "opl-specia1ne",
   "aww-artem-shcherbakov",
   "opl-neuropelvic-surgery"
  ]
 },
 {
  "id": "work-one-strip-per-project",
  "role": "work",
  "name": "One strip per project",
  "shape": "strip",
  "tags": [
   "dense",
   "photo-led"
  ],
  "directions": [
   "contact-sheet",
   "dark-room",
   "dense-tiles",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "The left 13% of the width is a `position: sticky` column holding the section name at 28px and, beneath it, two text filters at 15px, the active one in full ink. To its right each project is one horizontal strip of four to six images, all exactly 190px tall at their natural widths (`height: 190px; width: auto`), 8px apart, the last one cut off by the right viewport edge with `overflow: hidden`. Under each strip, one line at 13px spread across the strip's width: project name flush left, a three-word descriptor at the centre, the year flush right. 45px separate one strip from the next, so four projects fit on a 900px screen and ten make a page. Square corners; no captions on individual frames. Hovering a strip dims the other strips to 40% opacity. There is no heading beyond the section name in the rail.",
  "mobile": "The rail becomes a single line above the list: section name left, filters right. Strips are 120px tall and each scrolls sideways on its own with `overflow-x: auto` and scroll snapping. The caption line keeps the name left and the year right; the descriptor is dropped.",
  "seenIn": [
   "aww-boc-studio",
   "aww-gil-huybrecht",
   "aww-lama-lama-2"
  ]
 },
 {
  "id": "work-giant-title-uneven-wall",
  "role": "work",
  "name": "Giant title, uneven wall",
  "shape": "mosaic",
  "tags": [
   "poster",
   "photo-led",
   "dense"
  ],
  "directions": [
   "loud-flat-poster",
   "dense-tiles",
   "name-as-monument",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A one- or two-word title in capitals at clamp(72px, 9vw, 136px), leading 0.9, sits flush left at the 16px margin; on its baseline at the far right, one underlined link at 15px to the full archive. 32px below, thumbnails pack into an uneven wall with 8px gaps: a flex-wrap layout in which each row has a different fixed height (cycle 240, 360, 200, 300px) and each tile's width follows its image ratio, so widths range from one-sixth to one-half of the page and tile edges do not line up between rows. Rows are justified to both margins with `flex-grow`. Under each image, one caption line at 12px: project name left, year in the soft ink right. Square corners; no hover overlay beyond a slight dimming of the other tiles. Holds 9–16 projects. The section ground is one flat colour, dark or saturated, edge to edge.",
  "mobile": "Title at 17vw on one line. The wall becomes rows of alternating composition: one full-width tile, then two tiles splitting the width 62/38, then 38/62, with 6px gaps and fixed row heights of 220 and 150px. Captions stay as one line under each tile.",
  "seenIn": [
   "aww-warm-fuzzy",
   "aww-oimachi"
  ]
 },
 {
  "id": "work-loose-scatter",
  "role": "work",
  "name": "Loose scatter with pauses",
  "shape": "stack",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "uneven-photographs",
   "dark-room",
   "place-carries-it",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "One word as heading at 32–40px, top-left on the margin. Projects then appear one at a time down the page, each a single photograph at its own width and side, with 22–30vh of empty ground between them. Use a repeating set of three placements on the 12-column grid: columns 1–6 hugging the left margin at 3:2; columns 9–12 hugging the right margin at 4:5, starting 80px above where the previous image ended so the two overlap in height; columns 3–9 inset at 16:9. Above each image's top-left corner, the project name at 13px in the soft ink; beneath its bottom-left corner, a one-line description at clamp(15px, 1.1vw, 17px) in full ink. Once, in the gap beside the narrow right-hand image, a two-line remark at 20px sits alone in columns 2–5. Square corners. Holds five to eight projects; more belongs in a denser index.",
  "mobile": "Images alternate widths against alternating margins: 100%, 68% right, 82% left, with 72px between projects. The name above and the description below each image stay left-aligned to that image's edge. The two-line remark sits full width between the second and third project.",
  "seenIn": [
   "aww-nothin",
   "opl-deeo-studio-2",
   "opl-kereliott"
  ]
 },
 {
  "id": "work-panels-midline-row",
  "role": "work",
  "name": "Full panels, one midline row",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "place-carries-it",
   "pinned-poster",
   "dark-room",
   "borrowed-category",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "Four to seven consecutive panels, each 100vw by 100svh, butted together with no gap. Each panel is one photograph of the project cropped much tighter than its frame (`object-fit: cover` with the image scaled to 130%), so a detail fills the screen. The same three-part text row sits at exactly 50% of the height on every panel, in white with `mix-blend-mode: difference` or over a neutral dark scrim band 90px tall: the date or year at the left margin at 15px, the project title in capitals at clamp(22px, 2vw, 30px) starting 10% in from the left, and one fact (location, client, number of pieces) at the right margin at 15px. Because the row never moves, scrolling reads like turning plates in a book. Panels may be `position: sticky; top: 0` so each slides over the last. There is no section heading; the first panel's title row is the heading.",
  "mobile": "Panels are 100vw by 82svh. The row drops to the bottom of each panel, 20px above its edge: the title at 22px on the first line, then date at the left and fact at the right on a second line at 13px, over the same scrim band.",
  "seenIn": [
   "aww-julien-calot",
   "aww-milledollars",
   "aww-depo-luxe",
   "aww-risk"
  ]
 },
 {
  "id": "work-stacked-folders-tabs",
  "role": "work",
  "name": "Stacked folders with tabs",
  "shape": "band",
  "tags": [
   "colour-field",
   "object-led",
   "poster"
  ],
  "directions": [
   "page-is-the-object",
   "colour-chapters",
   "one-motif",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Projects are filed as folders: four to six full-width flat colour bands stacked so that each shows a 64px strip above the next, built with `position: sticky` and increasing `top` offsets (0, 64, 128px) so the bands pile up as the page scrolls. From the top-left edge of each band one or two tabs rise: filled shapes 44px tall and 200–320px wide with shouldered corners cut by `clip-path: polygon()` or an inline SVG path, side by side with 6px gaps, each in a flat colour that contrasts with its band. The project name sits inside the tab at 26–28px; the category sits right-aligned in the band's visible strip at 15px. An open band is 80svh: a 16:10 photograph in columns 1–7 and, top-aligned in columns 9–12, a 60-word description with a link. A two-word heading at 32px precedes the stack.",
  "mobile": "Bands do not stick; they stack in normal flow, each led by one tab 40px tall with the name at 20px and the category beside it. Inside a band the photograph is full width at 4:3 with the description beneath at 16px and 20px padding.",
  "seenIn": [
   "aww-mosbys-files",
   "opl-medium-rare"
  ]
 },
 {
  "id": "work-book-spine-index",
  "role": "work",
  "name": "Book-spine index",
  "shape": "index",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "type-only-statement",
   "contact-sheet",
   "borrowed-category",
   "dense-tiles",
   "page-is-the-object"
  ],
  "needs": [],
  "blueprint": "The complete index in the footprint of a banner. Twenty to forty project names are set as single lines rotated to read top to bottom with `writing-mode: vertical-rl`, packed side by side like book spines across the full content width. Each spine is a column 30–34px wide and 420px tall: the name at 18px starts at the top, and the discipline follows after a 12px gap on a flat highlighter-colour background chosen from four palette colours by discipline. Spines are separated only by 6px of space. Hovering or focusing a spine turns it to full ink and dims the others to 40%; the whole spine is the link. A two- or three-word heading at 28px sits top-left above the shelf with a one-sentence note at 15px to its right on the same baseline. No images. Below the shelf, 96px of empty ground. It turns a long client list into one dense typographic object.",
  "mobile": "Rotation is dropped. Names become a horizontal wrap: each name at 17px followed by its highlighted discipline, items flowing inline with 10px gaps and 14px of line spacing, so the index reads as one dense paragraph of links filling the width.",
  "seenIn": [
   "opl-adam-ho"
  ]
 },
 {
  "id": "work-staggered-tiles-empty-cells",
  "role": "work",
  "name": "Staggered tiles, empty cells",
  "shape": "grid",
  "tags": [
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "uneven-photographs",
   "editorial-report",
   "specimen-catalogue",
   "one-motif",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A four-column grid with 16px gutters and 5vw margins in which tiles snap to columns but vary in span and height, and some cells are deliberately left empty. Pattern for eight projects: row one, a wide tile spanning columns 1–2 at 3:2 and a single tile in column 4 at 4:5, column 3 empty; row two, single tiles in columns 2 and 3, columns 1 and 4 empty; row three, a wide tile in columns 3–4 and a single in column 1. Each tile is a photograph, or a project image centred with 12% padding on a flat pastel or dark ground from the palette. Square corners, or one consistent shape detail such as a large radius on the bottom-right corner only. Under each tile one caption row at 14px: project name left, year and category right. The heading, two words stacked at clamp(40px, 4.5vw, 68px), occupies the empty cell of row one.",
  "mobile": "Two columns with 10px gutters. Wide tiles span both columns; single tiles alternate left and right with the other cell left empty beside every second one, so the stagger survives. The heading sits above at 10vw. Captions stack the name over the year.",
  "seenIn": [
   "aww-haoqi-design",
   "aww-revelatio-studio",
   "opl-nia-creative",
   "opl-mammut-studios"
  ]
 },
 {
  "id": "work-quiet-ledger-preview",
  "role": "work",
  "name": "Quiet ledger, side preview",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "split-stage",
   "type-only-statement",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "A plain ledger in one column 560px wide whose left edge sits at 22% of the viewport width. A heading of two or three words at 24–28px in medium weight, then 32px of space, then one row per project, 44px tall: the name at 16px in full ink at the left, the kind of work at 14px in the soft ink starting at 55% of the column, and the year right-aligned with tabular figures. Nothing is drawn between rows; the three fixed starts make the columns. Hovering or focusing a row keeps it in full ink, drops the others to the soft ink, and shows that project's image in the empty area right of the column: a 4:3 frame 300px wide, `position: absolute`, its top aligned to the hovered row, 64px from the column's right edge, appearing with a 150ms fade. Holds 6–20 rows; group by year with 28px of extra space.",
  "mobile": "The column fills the width with 20px margins. Rows are 52px tall: name at the left, year at the right, kind of work on a second line at 13px. There is no hover preview; instead the first three projects each show a 16:10 image at full column width above their row.",
  "seenIn": [
   "opl-shawn-golden",
   "opl-britt-taylor",
   "opl-george-wood"
  ]
 },
 {
  "id": "work-oversized-visual-small-cluster",
  "role": "work",
  "name": "Oversized visual, small cluster",
  "shape": "split",
  "tags": [
   "object-led",
   "photo-led"
  ],
  "directions": [
   "hero-object",
   "specimen-catalogue",
   "borrowed-category",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "One project per block, three or four blocks, 140px apart. In each, one oversized visual fills columns 1–7 and bleeds off the left viewport edge: an object photograph scaled to 120% of its box and cropped by `overflow: hidden` on the section, 80svh tall, sitting on the page ground (use `mix-blend-mode: multiply` on a light ground so a white studio background disappears). The right third, columns 9–12, holds a small cluster bottom-aligned with the visual: a landscape image at 16:10 with 6px corners, then 28px below it a two-column row with the project title at clamp(28px, 3vw, 44px) on two lines at the left and a five-line description at 13–14px at the right. Column 8 stays empty. Blocks do not mirror; the second visual is 60svh tall and the third 90svh, so the rhythm changes while the structure holds. No section heading: each title leads its block.",
  "mobile": "The visual comes first at 110% of the width, bleeding off the left edge in a 4:5 crop. Beneath it the title at 8vw, then the description at 15px, then the small landscape image at 70% width against the right margin. 88px between blocks.",
  "seenIn": [
   "opl-artem-taradash",
   "aww-normal-is-boring"
  ]
 },
 {
  "id": "work-names-as-paragraph",
  "role": "work",
  "name": "Names as one paragraph",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "sentence-as-layout",
   "type-only-statement",
   "name-as-monument",
   "loud-flat-poster",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "All project or client names run together as one paragraph at poster scale: 10–18 names at clamp(40px, 5.2vw, 78px), leading 1.1, flowing inline across the full width between 24px margins and wrapping where they fall, five to seven lines in all. Names are separated by a comma, or by a small inline frame: an `inline-block` 0.8em tall and 1.2em wide, aligned to the cap height, holding that project's photograph and placed directly after its name. Every name is a link; all names rest in the soft ink, and the hovered one takes full ink while its inline frame scales to 1.6 times from its left edge. A heading of two words opens the paragraph as its first words in full ink, on the same line and at the same size, followed by a colon. Below the paragraph at the left margin, one line at 15px gives the count and the years covered.",
  "mobile": "The paragraph is set at 9vw, left-aligned, about twelve lines, with names separated by commas. Inline frames are removed except after the first three names, where they stay at cap height. All names are full ink; each remains a link with 6px of extra line spacing for touch.",
  "seenIn": [
   "opl-adam-ho",
   "aww-noho",
   "aww-artem-shcherbakov"
  ]
 },
 {
  "id": "gallery-staggered-pairs",
  "role": "gallery",
  "name": "Staggered pairs in empty ground",
  "shape": "stack",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "uneven-photographs",
   "dark-room",
   "place-carries-it",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "No heading. Photographs are placed one or two per screen so that more than half of every screen stays empty ground. Screen one: a 3:4 portrait in columns 1–4 against the left margin, and a near-square 5:4 image in columns 7–12 against the right margin whose top edge starts 30vh lower than the portrait's. Screen two: a 3:2 landscape in columns 6–11 and, 24vh lower, a small 4:5 image in columns 2–4. Repeat the two arrangements, mirrored on the second pass, for six to ten photographs. Under each image's left corner, a one-line caption at 13px in the soft ink, 10px below the image. Vertical gaps between pairs are 22–28vh. Every edge sits on a column line and offsets come from only two values. Square corners. If motion is used, images enter with a short `clip-path` wipe from the bottom edge.",
  "mobile": "A single column in which widths and sides alternate: 70% against the left margin, 84% against the right, 56% left, then 100% full-bleed, with 64px between images. Captions sit under each image's left corner at 13px. Portrait crops are preferred so each image fills most of the screen height.",
  "seenIn": [
   "opl-kereliott",
   "opl-the-deep-end",
   "aww-tengile-malamala-collection"
  ]
 },
 {
  "id": "gallery-drag-row-mixed-modules",
  "role": "gallery",
  "name": "Drag row of mixed modules",
  "shape": "carousel",
  "tags": [
   "photo-led",
   "dense"
  ],
  "directions": [
   "place-carries-it",
   "split-stage",
   "dense-tiles",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "One horizontal row, 68svh tall, running off both viewport edges with 12px gaps, draggable and scrollable sideways (`overflow-x: auto`, `scroll-snap-type: x proximity`, scrollbar hidden). The row is built from three module types: a tall single image at 3:4 filling the row height; a column of two stacked 4:3 images sharing the height with a 12px gap; and a wide 16:10 landscape at full height. Sequence for nine photographs: tall, stacked pair, wide, stacked pair, tall, wide. The row starts 6vw in from the left, so the first module is whole and the last visible one is cut by the right edge. A heading of two or three words at 28–36px sits 32px above the row's left start; the count of photographs sits at the right margin on the same baseline. No captions and no arrows. Inside a narrow reading column the same row runs 300px tall and is cut by the column's edge.",
  "mobile": "The row is 62svh tall with 8px gaps and starts 20px in. Modules are sized so one and a third are visible: tall images 72vw wide, stacked pairs 64vw wide, landscapes 88vw wide at reduced height and centred vertically. Scroll snapping aligns each module to the left margin.",
  "seenIn": [
   "opl-transport",
   "opl-marina-rachello",
   "opl-le-petit-bleu"
  ]
 },
 {
  "id": "gallery-full-bleed-sequence",
  "role": "gallery",
  "name": "Full-bleed sequence, text interlocked",
  "shape": "full-bleed",
  "tags": [
   "photo-led"
  ],
  "directions": [
   "place-carries-it",
   "dark-room",
   "hero-object",
   "pinned-poster",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Three photographs, each 100vw by 100svh, shown one after another with nothing between them. Each is `position: sticky; top: 0` inside a wrapper 300svh tall, so the second slides up over the first and the third over the second like a curtain; no scroll hijacking. The section above interlocks with the first photograph: a narrow paragraph at 13–14px, 220px wide, sitting on the column line at 58% of the width, is pulled down with a negative bottom margin so its last four lines overlap the photograph's top edge, in a colour that reads on both ground and image. Each photograph carries one caption line at 15px in white at the bottom-left margin, over a neutral dark scrim limited to the lowest 120px. No heading. Choose three frames with the same light and a calm lower-left corner. The last photograph releases into the next section.",
  "mobile": "Each photograph is 100vw by 88svh in a portrait crop and still sticks so the next covers it. The interlocking paragraph sits at the left margin, 60% wide, overlapping the first photograph by three lines. Captions stay bottom-left at 14px.",
  "seenIn": [
   "aww-white-desert",
   "aww-squarespace-foundations"
  ]
 },
 {
  "id": "gallery-small-frames-wide-space",
  "role": "gallery",
  "name": "Small frames, wide space",
  "shape": "strip",
  "tags": [
   "quiet",
   "object-led",
   "photo-led"
  ],
  "directions": [
   "quiet-instrument",
   "specimen-catalogue",
   "hero-object",
   "contact-sheet",
   "dark-room",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "A flat, quiet viewport 100svh tall in which about 70% of the screen is empty. At mid-height a single row of small portrait frames runs across and off both sides: each frame 11vw wide and 24svh tall (roughly 2:3), spaced 5vw apart, vertically centred, six or seven visible at 1440. Frames have square corners and sit directly on the ground with no border. The row scrolls sideways by drag or trackpad (`overflow-x: auto` with the scrollbar hidden) and may hold 12–24 frames. One line of text overlaps the top-left of the first frame by 12px: a title of two or three words at 20–24px followed by the count, placed above the frame in the stacking order. Hovering a frame scales it to 1.1 from its centre over 250ms; nothing else moves. No captions under frames, no arrows, and no other text in the section.",
  "mobile": "The row sits at 45% of an 80svh section. Frames are 34vw wide at 2:3 and 5vw apart, so two and a half are visible and the row scrolls by touch with snapping. The title line sits 20px above the row at the left margin.",
  "seenIn": [
   "aww-julien-calot",
   "aww-2xa-studio"
  ]
 },
 {
  "id": "gallery-prints-over-headline",
  "role": "gallery",
  "name": "Prints dropped on the headline",
  "shape": "statement",
  "tags": [
   "poster",
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "loud-flat-poster",
   "name-as-monument",
   "borrowed-category",
   "colour-chapters",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "A flat colour field 100svh tall. A two-line headline of two to four words in an ultra-condensed or ultra-heavy face is centred, each line clamp(120px, 14vw, 210px) tall with leading 0.85. Two portrait photographs, each about 26vw wide at 3:4 with an 8px solid white border like a print, are absolutely placed on top of the headline's centre: one rotated −8 degrees, one +6 degrees, overlapping each other by a third and hiding roughly half the letters, so the eye completes the words. A third, smaller print (16vw) may sit at the lower right, cropped by the section's bottom edge. One plain sentence at 16px sits at the bottom-left margin, 24px from the edges. The prints are above the type in the stacking order; hovering one brings it to the front and straightens it to 0 degrees over 200ms. Nothing else is on the field.",
  "mobile": "Headline lines at 30vw, still two lines, centred in a 78svh field. Two prints only, each 52vw wide, rotated −6 and +5 degrees, overlapping at the headline's centre. The small sentence sits at the bottom-left margin at 15px.",
  "seenIn": [
   "aww-decathlon-yestalgia",
   "aww-pp-neue-montreal"
  ]
 },
 {
  "id": "gallery-dense-masonry-sheet",
  "role": "gallery",
  "name": "Dense masonry sheet",
  "shape": "mosaic",
  "tags": [
   "dense",
   "photo-led"
  ],
  "directions": [
   "contact-sheet",
   "dense-tiles",
   "editorial-report",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "A three-line statement at 22px, 30ch wide, sits at the 66% column with 64px of space below it; it is the only large text. Then a dense masonry of about twenty tiles in six columns with 4px gaps and 12px outer margins, built with CSS `columns: 6; column-gap: 4px` and `break-inside: avoid` on each figure. Tile sizes range from 90px squares to one feature tile of 280 by 160px that sits above the first two columns, outside the column flow. Images keep their natural ratios, so column bottoms are ragged. Every tile has one caption line at 12px directly beneath, 6px below the image and 14px above the next tile, in the form name, comma, type. Mix photographs, posters, details and textures. No hover chrome; the sheet reads at a glance and fills the full width.",
  "mobile": "Three columns with 4px gaps and 10px margins; the feature tile spans the full width at the top. Captions stay at 12px but shorten to the name only. The statement sits above at 19px, full width and left-aligned.",
  "seenIn": [
   "aww-oimachi",
   "opl-lovably"
  ]
 },
 {
  "id": "gallery-wall-of-word-tiles",
  "role": "gallery",
  "name": "Wall of word tiles",
  "shape": "grid",
  "tags": [
   "type-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "type-only-statement",
   "editorial-report",
   "colour-chapters",
   "loud-flat-poster",
   "dense-tiles",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "A gallery that works with no photographs: a four-column grid of identical square cells with 8px gaps running three or more rows, edge to edge with 8px margins. Each cell is a flat filled tile and centres one large element about 55% of the cell's width: a single word, a letter, a figure with its unit, or a simple inline SVG shape taken from the subject, set at clamp(56px, 9vw, 150px) in the display face. A one- or two-word caption at 14px sits centred 18px above the cell's bottom edge. Tile and ink colours rotate through three or four palette pairs in a fixed order, so a uniform grid looks varied. Where photographs exist, every third or fourth cell is a photograph covering its tile, all given one treatment. There is no hierarchy between cells: it is a specimen sheet. A heading is optional; if used, it occupies the first cell as that cell's word.",
  "mobile": "Two columns with 6px gaps; cells stay square and the central element is set at 22vw. Captions stay at 13px. Twelve cells make six rows; if there are more than sixteen, show twelve and let the rest follow after a full-width tile holding a text link.",
  "seenIn": [
   "opl-beige-force",
   "opl-proooof",
   "opl-hebbia"
  ]
 },
 {
  "id": "gallery-one-frame-changing",
  "role": "gallery",
  "name": "One frame, contents change",
  "shape": "pinned",
  "tags": [
   "object-led",
   "quiet",
   "photo-led"
  ],
  "directions": [
   "quiet-instrument",
   "hero-object",
   "pinned-poster",
   "product-performs",
   "split-stage",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "Instead of many pictures side by side, one frame stays in place and its contents change. The frame is small and exact: 300px square on a quiet page, or up to 40vw at 4:5 on an object-led one, centred or sitting on the text column's left edge, with at least 20vh of empty ground above and below. Five to seven photographs are stacked inside it with `position: absolute` and crossfade in turn (opacity over 600ms, each held for 2.4 seconds, via CSS keyframes with staggered delays). All are shot or cropped alike: same scale, same light direction, the subject at the same position, so only the subject seems to change. On a flat ground use `mix-blend-mode: multiply` so studio backgrounds vanish. One caption line at 14px sits 16px below the frame and changes with the image. No arrows and no counter. On a motif-led page the frame takes the motif's shape through `clip-path`.",
  "mobile": "The frame is 72vw wide, centred, in a section 70svh tall. The crossfade continues on its own; tapping the frame advances to the next image at once. The caption sits beneath, centred, at 14px. When reduced motion is requested the first image holds and tapping still advances.",
  "seenIn": [
   "opl-archiv"
  ]
 },
 {
  "id": "gallery-tiles-with-label-strips",
  "role": "gallery",
  "name": "Photo tiles with label strips",
  "shape": "grid",
  "tags": [
   "photo-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "specimen-catalogue",
   "page-is-the-object",
   "colour-chapters",
   "dense-tiles",
   "product-performs",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Four columns with 12px gaps; every tile is a photograph with a filled label strip attached directly beneath it, the two forming one object like a print with its swing tag. The photograph is 1:1 or 4:5 with square corners. The strip is the same width, 84–96px tall with 12px padding, and holds two or three rows of text at 12–13px in the text face with tabular figures: row one spreads four short values evenly across the width with `justify-content: space-between`; row two puts a date at the left end and a time or place at the right end; row three names the maker or model. Strip and text colours change per tile through four palette pairs (dark on bright, bright on dark), so a uniform grid looks varied. Rows of tiles are 28px apart. A two- or three-word heading at 32px sits top-left, 40px above the grid. Holds 8–16 tiles.",
  "mobile": "Two columns with 8px gaps. Strips keep two rows only: the four values wrap to two per row, and the date and place line is dropped. Text stays at 12px. The heading sits above at 24px. Colour pairs keep rotating so adjacent tiles never match.",
  "seenIn": [
   "opl-proooof",
   "opl-compile"
  ]
 },
 {
  "id": "list-lineup-one-size",
  "role": "list",
  "name": "Line-up at one type size",
  "shape": "table",
  "tags": [
   "type-led",
   "dense"
  ],
  "directions": [
   "type-only-statement",
   "dark-room",
   "loud-flat-poster",
   "name-as-monument",
   "borrowed-category",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "A full-bleed flat section. The programme is a four-column table spanning the full width between 24px margins, with every cell at one size, clamp(20px, 2.1vw, 30px), one weight, no bold: the act or session flush left, the format starting at 33%, the room or stage at 66%, and the time range right-aligned at the far edge with tabular figures. Rows are 1.5em tall and separated by nothing but that leading; the fixed column starts do the organising. Day or part headings are a single line at the same size in the accent ink with 72px above and 20px below, and may be `position: sticky; top: 0` on a strip of the section's ground colour so the current day stays in view. Breaks and changeovers are rows set in capitals in the soft ink. There is no other heading. Comfortably holds 12–40 rows; with fewer than eight, raise the size to 3vw.",
  "mobile": "Each entry becomes two lines: the act at 22px on the first, then format, room and time on one line at 14px in the soft ink with the time right-aligned. 18px between entries. Day headings stay sticky at the top on the ground colour.",
  "seenIn": [
   "opl-spatial",
   "opl-select26"
  ]
 },
 {
  "id": "list-menu-photo-bullets",
  "role": "list",
  "name": "Two-column menu, photo bullets",
  "shape": "grid",
  "tags": [
   "object-led",
   "photo-led"
  ],
  "directions": [
   "specimen-catalogue",
   "one-motif",
   "hero-object",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A menu or catalogue list in which each item has a small cut-out photograph where a bullet would be. Each category opens with its name at clamp(32px, 3.4vw, 50px) at the left margin and the number of items at 16px right-aligned on the same baseline, with 96px above and 40px below. Items then run in two columns with a 64px gutter and 36px between rows. Each item is a two-part row: a 64px photograph of the item on a plain background at the left (multiplied onto the ground with `mix-blend-mode: multiply`, or masked to the page's one shape), then 20px to its right the name in bold at 17–18px with the price right-aligned on the same line, and beneath it a two- or three-line description at 15px in the soft ink, max 38ch. A short note such as an allergen follows the description in the accent ink. No boxes. Holds 6–10 items per category.",
  "mobile": "One column. Photographs shrink to 52px and stay at the left; name and price share the first line with the description beneath at 15px. Category names at 8.5vw with the count on the same baseline. A sticky row of category names as text links lets the reader jump.",
  "seenIn": [
   "opl-madie",
   "opl-zara-webb"
  ]
 },
 {
  "id": "list-giant-word-bar-rows",
  "role": "list",
  "name": "Giant word, filled bar rows",
  "shape": "stack",
  "tags": [
   "type-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "split-stage",
   "page-is-the-object",
   "name-as-monument",
   "dense-tiles",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "Sized for a reading column 520–640px wide, or centred at that width on a full page. The heading is one word (menu, hours, prices) in tall condensed capitals, its `font-size` set in container units (around 46cqw for four letters, tuned per word) so the letters fill the column from edge to edge, leading 0.8. 20px below, a stack of full-width filled bars, each 56px tall with 6px gaps, in a flat tone one step from the ground, with square corners or a 6px radius. Each bar carries a category name at 16px in medium weight at the left with 18px padding and a plus sign at the right. A bar opens in place as a `details` element: it keeps its fill, and its panel lists items at 16px, name left and price right-aligned with tabular figures, 14px between items, 20px padding. Six to nine bars; the first is open by default.",
  "mobile": "The column is the full width with 16px margins, so the one-word heading spans the screen. Bars are 54px tall with 6px gaps and open the same way. Item descriptions, if any, sit under the name at 14px in the soft ink.",
  "seenIn": [
   "opl-le-petit-bleu"
  ]
 },
 {
  "id": "list-day-tabs-banded-agenda",
  "role": "list",
  "name": "Day tabs, banded agenda",
  "shape": "list",
  "tags": [
   "dense",
   "type-led",
   "colour-field"
  ],
  "directions": [
   "editorial-report",
   "product-performs",
   "dense-tiles",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "A two-line heading at clamp(36px, 4vw, 60px), first line in the soft ink and second in full ink, with the booking button on the same row at the far right. 56px below, left: a 150px column of stacked day tabs, each a filled block 150 by 64px with 6px gaps showing the weekday at 16px and the date at 13px; the active tab takes the darkest fill with inverted text. Right, from 190px to the right margin: the day's sessions as alternating tone bands, each 76px tall with an 8px radius and 4px gaps, the two tones one step apart. Inside a band, on fixed column starts: the time at 20px with tabular figures, 20px from the left; the title at 18px in medium weight at 22%, with a one-line note at 13px in the soft ink under it; speaker names right-aligned at 14px beside up to three overlapping 32px round portraits.",
  "mobile": "The heading stacks above a full-width button. Day tabs become a horizontal row of equal filled blocks, sticky under the top edge. Each band stacks time over title over speakers with 16px padding and grows to fit; portraits sit at the band's top-right corner.",
  "seenIn": [
   "opl-builders-table",
   "opl-select26"
  ]
 },
 {
  "id": "list-poster-lines",
  "role": "list",
  "name": "One item per poster line",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster",
   "colour-field"
  ],
  "directions": [
   "loud-flat-poster",
   "type-only-statement",
   "name-as-monument",
   "colour-chapters",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "A short list set as a poster. Four to seven items (services, headline acts, courses, opening days) stack one per line at clamp(64px, 10vw, 150px) in the display weight, all capitals or all lowercase, leading 0.92, flush left at a 20px margin on a flat field. Its one detail (a price, a date, a time range) sits at the right margin on the same baseline at 18–20px with tabular figures, so the page has two alignments only: giant words left, small facts right. Items too long for one line at this size are shortened, not shrunk. Between items there is only the tight leading. Hovering an item swaps its ink for the accent. There is no heading: the first item starts 80px below the section top and the last ends 80px above the bottom. The list reuses the hero title's exact type size, so it reads as another poster.",
  "mobile": "Items at 15vw, one per line, flush left with 16px margins. Each detail moves beneath its item at 15px, left-aligned, with 6px above and 22px below. If any single word is wider than the screen at 15vw, drop the whole list to 12vw rather than breaking a word.",
  "seenIn": [
   "opl-city-of-angels"
  ]
 },
 {
  "id": "list-sticky-photo-active-list",
  "role": "list",
  "name": "Sticky photograph, active list",
  "shape": "split",
  "tags": [
   "photo-led",
   "type-led",
   "object-led"
  ],
  "directions": [
   "hero-object",
   "split-stage",
   "place-carries-it",
   "dark-room",
   "pinned-poster"
  ],
  "needs": [],
  "blueprint": "Two columns. The left, columns 1–5, is `position: sticky; top: 12vh` and holds one photograph or object at 4:5, 34vw wide, unframed on the ground. The right, columns 7–11, holds a vertical list of five to nine names (treatments, rooms, courses, services) at clamp(20px, 1.6vw, 24px), each row 58px tall with the name at the left and a price or duration right-aligned at 15px in the soft ink. The active row is full ink; the others rest at 40% opacity. Hovering, focusing or scrolling a row to the middle of the viewport makes it active, crossfades the left photograph to that item's image over 300ms, and opens a three-line description at 15px, 34ch wide, directly under the name, pushing later rows down. A heading of two or three words at clamp(32px, 3.4vw, 48px) sits above the list, aligned to its left edge, 48px above the first row.",
  "mobile": "The photograph becomes a 4:3 band pinned to the top of the screen at 38svh while the list scrolls beneath it; the heading sits above the first row. Rows are 56px tall and the description opens on tap. The image changes with the row nearest the band.",
  "seenIn": [
   "opl-neuropelvic-surgery",
   "opl-humanoid-index"
  ]
 },
 {
  "id": "list-two-ended-rows",
  "role": "list",
  "name": "Two-ended rows, narrow column",
  "shape": "list",
  "tags": [
   "quiet",
   "type-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "contact-sheet",
   "type-only-statement",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "A list in one narrow column, 520px wide, starting at 22% of the viewport width or centred. Every row is 34–38px tall with no box and nothing drawn between rows. Each row has two ends: at the left, a 16px round image (a face, a logo, a colour dot) followed after 8px by the name at 15–16px in full ink; at the far right of the column, a short value at 13px in the soft ink (a date, a price, a domain, an opening time) with tabular figures. Entries that need a sentence run to two lines at the left, and the value aligns with the first line. Groups are separated by 40px of space and begin with one plain word in the soft ink at the same size. A heading of two to four words at 24–28px in medium weight sits 32px above the first row. Holds 10–40 rows.",
  "mobile": "The column fills the width with 20px margins. Rows are 44px tall for touch; the left image stays 16px. Long values wrap beneath the name at 13px, left-aligned with it, and the row grows. Group spacing is 32px.",
  "seenIn": [
   "opl-tiny-computer-co"
  ]
 },
 {
  "id": "list-printed-menu-sheet",
  "role": "list",
  "name": "The printed menu sheet",
  "shape": "band",
  "tags": [
   "object-led",
   "type-led",
   "colour-field"
  ],
  "directions": [
   "page-is-the-object",
   "place-carries-it",
   "split-stage",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The list is one tall sheet of paper. A filled paper-colour panel 640px wide sits centred, or offset to columns 7–12, on a contrasting flat ground or a full-bleed photograph, with square corners and its top and bottom edges cut into shallow scallops by `clip-path` or an SVG `mask`. Inside, 64px padding and a centred, symmetrical setting like a printed card: the venue or section name at 40–48px, a one-line note at 15px, then three or four groups 56px apart. Each group opens with its name at 22px, centred. Items follow at 17px with 16px between them: the dish or service at the left of a 420px measure, the price at the right with tabular figures, and a one-line description at 14px in the soft ink under the name. Alignment alone links name and price; there are no leaders. The sheet ends with the opening hours as two centred lines. Holds 12–24 items.",
  "mobile": "The sheet spans the screen with 10px of ground showing at each side and 28px padding; the scalloped edges stay at top and bottom. The measure is the full inner width and prices stay right-aligned. Group names at 20px, items at 16px.",
  "seenIn": [
   "opl-le-petit-bleu",
   "opl-madie",
   "opl-sarah-matt-wedding"
  ]
 },
 {
  "id": "list-statement-table-pushed-right",
  "role": "list",
  "name": "Statement, table pushed right",
  "shape": "table",
  "tags": [
   "type-led",
   "quiet",
   "photo-led"
  ],
  "directions": [
   "sentence-as-layout",
   "uneven-photographs",
   "editorial-report",
   "place-carries-it",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "A heading of four to six words spans the full width on two lines at clamp(40px, 4.5vw, 68px), left-aligned with its first line indented 3vw and leading 0.98. 96px below, the table is pushed into the right-hand five columns (8–12) and the left stays open. One plain word naming the table (offices, hours, rates) hangs at 14px in column 7, top-aligned with the first row. Each row is a three-part line at 15–16px with 26px between rows: the kind or day in the soft ink at the left of the measure, the place or service at 30%, and the address, time or price right-aligned, with a small arrow where the row links. Rows are separated by space only; every fourth row has 44px above it to form groups. In columns 1–4, bottom-aligned with the last row, one 4:5 photograph 22vw wide may sit alone. Holds 5–14 rows.",
  "mobile": "Heading at 9.5vw without the indent. The table takes the full width at 16px; each row stacks the kind above the place, with the value right-aligned on the first line. The naming word is dropped because the heading already says it. The photograph follows at 60% width on the right margin.",
  "seenIn": [
   "aww-kononenko-architectural-bureau",
   "opl-monte"
  ]
 },
 {
  "id": "contact-address-display-rows",
  "role": "contact",
  "name": "Address in display rows",
  "shape": "list",
  "tags": [
   "type-led",
   "poster",
   "colour-field"
  ],
  "directions": [
   "type-only-statement",
   "name-as-monument",
   "loud-flat-poster",
   "colour-chapters",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The contact block is a stack of five full-width rows, every one set at the same size, clamp(44px, 6vw, 88px), regular weight, left-aligned with a 20px inset and leading 1.0: the building or venue name, the street, the city with postcode, the opening hours as one compact line, the phone number. Each row is about 150px tall with the text sitting in its lower third; rows are told apart by alternating two flat tones of the section colour as full-width bands, or by 28px of space alone. A sixth row holds a one-word greeting at the same size followed, 24px to its right and centred on the x-height, by a solid round button 0.9em across with an arrow, which opens the email or booking link. Street and phone rows are links (map, call). No heading and no form. Optionally, one small round photograph sits at the right end of the first row.",
  "mobile": "Rows at 9.6vw with 16px insets, about 84px tall; the hours break onto two rows (days, then times). The greeting row becomes a full-width band in the accent colour with the round arrow button at its right end, 56px across.",
  "seenIn": []
 },
 {
  "id": "contact-email-display",
  "role": "contact",
  "name": "The email as display type",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "name-as-monument",
   "type-only-statement",
   "dark-room",
   "pinned-poster",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "The section is 100svh in the inverse of the page's colours. A single email address is set as one line of display type spanning the content width: the font-size is fitted so the address runs from margin to margin (about 7–9vw for 20 characters; break after the @ onto two lines if it is longer than 26), light or regular weight, tight tracking, sitting on a baseline 12vh above the bottom edge. It is a `mailto:` link; on hover the whole line takes the accent ink. In the top-left corner, a sentence of up to 12 words at 20–22px says what to write about. In the two bottom corners at 14px: phone and city at the left, two or three social links at the right. No heading and no form. For a pinned ending, make the section `position: sticky; bottom: 0` beneath the page so the previous section lifts off it.",
  "mobile": "The address breaks after the @ onto two lines at 11.5vw, left-aligned, sitting at 55% of an 86svh screen. The sentence sits at the top-left at 18px. Phone, city and social links stack at the bottom-left at 15px with 12px between them.",
  "seenIn": [
   "aww-bleibtgleich26"
  ]
 },
 {
  "id": "contact-photo-strip-three-cell-bar",
  "role": "contact",
  "name": "Photo strip, three-cell bar",
  "shape": "strip",
  "tags": [
   "colour-field",
   "photo-led",
   "dense"
  ],
  "directions": [
   "colour-chapters",
   "loud-flat-poster",
   "dense-tiles",
   "specimen-catalogue",
   "contact-sheet"
  ],
  "needs": [],
  "blueprint": "A saturated flat field about 80svh tall. Across the top, 48px from the section edge, a strip of small photographs runs off both sides: all 150px tall at differing widths (3:4, 1:1, 3:2 in turn), 8px gaps, ten or more, drifting slowly as a marquee. A heading of two to four words at clamp(36px, 4vw, 60px) sits 72px below the strip at the left margin. 40px below the heading, the newsletter or booking form is one bar spanning the content width, 96px tall, divided into three equal cells with 8px gaps. Each cell is a filled block in a tone one step from the field with one radius: the first two are inputs (name, email) with placeholder text centred at 18px, the third is the submit action in the inverse colours with its word centred. Focus changes a cell's fill to the lightest tone. Small print at 13px sits under the first and third cells.",
  "mobile": "The strip is 110px tall and scrolls by touch. The heading follows at 9vw. The three cells stack as full-width filled blocks 60px tall with 8px gaps, the submit block last. Small print sits beneath the stack, left-aligned.",
  "seenIn": [
   "aww-paul-kalkbrenner"
  ]
 },
 {
  "id": "contact-clip-out-coupon",
  "role": "contact",
  "name": "Clip-out coupon form",
  "shape": "split",
  "tags": [
   "object-led",
   "colour-field"
  ],
  "directions": [
   "page-is-the-object",
   "one-motif",
   "borrowed-category",
   "loud-flat-poster",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "The form is a coupon to cut out. One filled card in a strong flat colour, about 2.2:1, spans columns 2–11 with a 14px radius, with 120px of ground above and below. Semicircular notches 28px across are cut into its top and bottom edges at the 46% mark with `clip-path` or an SVG `mask`, and a vertical row of round dots (a dotted `border-left` 5px thick in the ground colour) joins the two notches as the perforation. Left of the perforation, with 48px padding: a two-line heading of three to five words at clamp(28px, 3vw, 44px) in heavy capitals at the top, and one sentence at 16px at the bottom. Right of it: four inputs stacked with 10px gaps, each a filled block 52px tall in a lighter tone of the card with its placeholder at 16px, then a full-width solid submit bar 56px tall in the darkest ink. Inputs keep visually hidden `label` elements.",
  "mobile": "The card turns upright and spans the width with 12px margins. The perforation becomes horizontal: notches on the left and right edges, dots across. Heading and sentence sit above it; the four inputs and the submit bar sit below, each 54px tall.",
  "seenIn": [
   "opl-the-sapphic-bar-index",
   "opl-rafael-derolez-2026"
  ]
 },
 {
  "id": "contact-single-field",
  "role": "contact",
  "name": "One field, nothing else",
  "shape": "stack",
  "tags": [
   "quiet",
   "ui-led"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "hero-object",
   "dark-room",
   "split-stage",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "The whole section is one prompt and one field in a column 520px wide, on the page's text column line or centred, with 200px of empty ground above and 160px below. The prompt is two lines at one size, 24–28px in medium weight: the first line in the soft ink, the second in full ink. 28px below, a single input 520px wide and 64px tall with a 12px radius, filled with the lightest tone on the page so it is the only bright surface in view; no outline at rest and a 2px inner ring in the accent on focus. Inside the right end of the field, 8px from its edge, a round submit button 44px across with an arrow and an accessible name. 16px below the field, one line at 13px says what happens next. 72px lower, three label and value rows at 14px: email, phone, address.",
  "mobile": "The column fills the width with 20px margins. Prompt at 22px. The field stays one row, 60px tall, with the arrow button inside its right end. The three label and value rows stack with the label above each value and 20px between them.",
  "seenIn": [
   "opl-archiv",
   "opl-ayush-halder-portfolio",
   "opl-medium-rare"
  ]
 },
 {
  "id": "contact-visit-card-on-photograph",
  "role": "contact",
  "name": "Visit card on a photograph",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "object-led"
  ],
  "directions": [
   "place-carries-it",
   "dark-room",
   "page-is-the-object",
   "pinned-poster",
   "hero-object"
  ],
  "needs": [],
  "blueprint": "A full-bleed photograph of the place, 100vw by 100svh, carries one solid card and nothing else. The card is a filled panel in the paper colour, fully opaque, 380px wide with 8px corners and 32px padding, placed in the upper-right corner 60px from the top and right edges (or the upper-left: pick the corner where the photograph is calmest). It holds, top to bottom: a two- or three-word title at 28px in a light weight; the address on two lines at 16px; the opening hours as three rows with days at the left and times right-aligned in tabular figures; the phone number; then, flush with the card's bottom edge and spanning its width, a solid booking button 52px tall. The card may be `position: sticky; top: 60px` inside a taller section so it holds while the photograph scrolls beneath it. The photograph's focal point must stay clear of the card. No heading outside the card.",
  "mobile": "The photograph is 100vw by 70svh. The card leaves the corner: it spans the width with 16px margins and overlaps the photograph's bottom edge by 80px, so image and card interlock. The booking button is full width, 56px tall, at the card's bottom.",
  "seenIn": [
   "opl-the-deep-end",
   "opl-madie"
  ]
 },
 {
  "id": "contact-sentence-portrait-links",
  "role": "contact",
  "name": "Sentence, portrait, two links",
  "shape": "band",
  "tags": [
   "type-led",
   "quiet",
   "photo-led"
  ],
  "directions": [
   "sentence-as-layout",
   "uneven-photographs",
   "contact-sheet",
   "editorial-report",
   "quiet-instrument"
  ],
  "needs": [],
  "blueprint": "One closing row of three elements hanging from the same top line, with open ground between them, on a 12-column grid. Columns 1–5: a sentence of 12–18 words at clamp(26px, 3vw, 44px), four lines, leading 1.1, saying what kind of enquiries are welcome; it is set as a paragraph and there is no separate heading. Columns 8–9: one small square portrait or photograph of the place, 150–170px, with square corners. Columns 10–12: a short question at 18px in medium weight, then 20px below it two underlined links stacked at 18px (the email address and one booking or social link), and beneath them a line at 14px in the soft ink with the city and the usual reply time. Columns 6–7 stay empty. The row has 160px of space above and 120px below. No form. On a dark ground the photograph is the only bright thing in the row.",
  "mobile": "The sentence comes first at 7.4vw. Below it the portrait at 96px square sits at the left margin with the question and the two links to its right, top-aligned, in the remaining width. The city and reply-time line spans the full width beneath.",
  "seenIn": [
   "opl-artem-taradash",
   "aww-bleibtgleich26",
   "opl-kereliott"
  ]
 },
 {
  "id": "showcase-one-control-blown-up",
  "role": "showcase",
  "name": "One control, blown up",
  "shape": "full-bleed",
  "tags": [
   "ui-led",
   "poster",
   "colour-field"
  ],
  "directions": [
   "loud-flat-poster",
   "colour-chapters",
   "one-motif",
   "borrowed-category",
   "dark-room",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "A flat saturated field, full-bleed and 92svh tall, holds one working control from the product: the toggle, slider, segmented switch or record button the whole product turns on. Build it at true proportions with every dimension in em, mark its root data-ui, then raise that root's font-size until the control stands about 46vh tall. Anchor it right of centre so the section's right and bottom edges crop about a fifth of it (overflow: hidden on the section). It keeps its real label and value, now at poster size, and it works: operating it swaps the field between two palette colours and changes one word of the heading. The heading, two to four words at clamp(28px, 2.6vw, 40px), sits top-left on the page margin with one sentence at 17px beneath it. The lower-left third of the field stays empty.",
  "mobile": "The field is 86svh. Heading and sentence sit top-left at 26px and 16px. The control is enlarged to about 130vw wide and anchored bottom-left, so the right edge crops its far end while its thumb or active segment stays within reach. It still works and still swaps the field colour.",
  "seenIn": [
   "opl-textmode-overlay",
   "opl-uni"
  ]
 },
 {
  "id": "showcase-output-index-live-sheet",
  "role": "showcase",
  "name": "Output index, live sheet",
  "shape": "index",
  "tags": [
   "ui-led",
   "object-led"
  ],
  "directions": [
   "editorial-report",
   "split-stage",
   "borrowed-category",
   "product-performs",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "For products that make documents: invoices, reports, emails, statements. Columns 1–5 list the names of three to five outputs, one per line, at clamp(36px, 4vw, 64px), leading 1.1, each a `<button>`; the selected name is in full ink, the rest in the soft ink. There is no other heading. Columns 7–12 are a stage in a flat tone one step from the ground, 88svh tall, and on it lies the selected output as an object: a paper-white sheet 560px wide, built in HTML at true size, root marked data-ui, with a real sender, real line items, sums that add up and a date. The sheet starts 72px below the stage's top and runs past its bottom edge, which crops it. Selecting a name swaps the sheet with a 200ms crossfade; an email is narrower (440px), a receipt narrower still (300px), all left-aligned 64px into the stage.",
  "mobile": "Names become one sideways-scrolling row at 22px with scroll-snap, the selected one in full ink. Beneath, the stage fills the width and is 70svh tall; the sheet keeps its true size, anchored top-left with a 16px inset and cropped by the stage's right and bottom edges. Tapping a name swaps it.",
  "seenIn": [
   "aww-cominvi"
  ]
 },
 {
  "id": "showcase-one-row-three-states",
  "role": "showcase",
  "name": "One row, three states",
  "shape": "stack",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "editorial-report",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "The product's central row (a task, a transaction, a message, a deploy) is shown three times, one directly beneath the other, in three states: as it arrives, with the pointer on it and its actions revealed, and finished. Each copy is built at true size, 640px wide, with real names, amounts and times, its root marked data-ui; inside it the product's own control borders and radii apply. The copies sit in columns 4–9 with 56px between them and nothing framing them. In columns 1–3, right-aligned and level with each copy, one word at 22px in the soft ink names its state. Columns 10–12 stay empty. The middle copy is live: its checkbox or button works and turns it into the third state. The heading, two to four words at 28–32px, sits on column 4's left edge, 96px above the first copy, with no supporting line.",
  "mobile": "Copies stack at full width with 16px margins and stay at true size; a row wider than the screen is cropped at its right end, never shrunk. Each state word sits beneath its copy at 17px in the soft ink, 10px away. 48px separates the three groups. Heading at 26px.",
  "seenIn": [
   "opl-rows"
  ]
 },
 {
  "id": "showcase-headline-you-can-type",
  "role": "showcase",
  "name": "Headline you can type",
  "shape": "statement",
  "tags": [
   "ui-led",
   "type-led",
   "poster"
  ],
  "directions": [
   "sentence-as-layout",
   "type-only-statement",
   "product-performs",
   "loud-flat-poster",
   "dark-room",
   "colour-chapters"
  ],
  "needs": [],
  "blueprint": "The section's headline is an input. One line at clamp(48px, 8vw, 132px), tight tracking, flush left across columns 1–12, is a real `<input>` (root marked data-ui) with no border and no fill, prefilled with a believable two-to-five-word example of what a user gives the product: a search, a question, a date in words, a colour name. A solid caret block 0.08em wide blinks after the last character. 48px beneath, on the same left edge, the product's answer appears at true interface size, at most 560px wide: three result rows, one parsed value, or a swatch, updating as the visitor types. Bottom-left, three more examples at 17px act as plain text buttons that refill the line. The rest of the 88svh field is empty. If the text outgrows the line it scrolls inside the input; it never wraps.",
  "mobile": "The input is set at 11vw on one line and scrolls sideways inside itself when the text runs long. The answer follows 28px below at full width and true size. The three examples become a wrapping row of filled blocks 44px tall beneath the answer; tapping one refills the line.",
  "seenIn": [
   "opl-shelf",
   "opl-tiny-computer-co"
  ]
 },
 {
  "id": "showcase-input-half-output-half",
  "role": "showcase",
  "name": "Input half, output half",
  "shape": "split",
  "tags": [
   "ui-led",
   "colour-field"
  ],
  "directions": [
   "split-stage",
   "product-performs",
   "colour-chapters",
   "dark-room",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "Two flat fields meet on a vertical edge at 50vw, each 90svh tall, no gap. The left field is the darkest palette tone and holds what the user hands the product, raw: a pasted note, a messy row of figures, a prompt, or literal code inside `<code>`. It is an editable region at 20–24px, 34ch wide, starting at column 2, 30% down the field. The right field is the lightest tone and holds what the product makes of it as a true-size fragment marked data-ui: the tidy record, the chart, the formatted reply, 440–520px wide, on the same top line. Editing the left changes the right. The heading is split across the edge: its first one or two words bottom-left on the dark field, its last one or two bottom-right on the light field, both at clamp(40px, 5vw, 80px) on one baseline 32px from the bottom.",
  "mobile": "The fields stack, dark above light, each 62svh, meeting on a horizontal edge. Input text at 18px with 20px margins; the output fragment at true size on the light field. The heading's two parts sit either side of that edge at 11vw: one at the foot of the dark field, one at the top of the light.",
  "seenIn": [
   "opl-textmode-overlay",
   "opl-vessa"
  ]
 },
 {
  "id": "showcase-rows-as-a-board",
  "role": "showcase",
  "name": "Rows as a board",
  "shape": "table",
  "tags": [
   "ui-led",
   "dense"
  ],
  "directions": [
   "dense-tiles",
   "borrowed-category",
   "editorial-report",
   "dark-room",
   "product-performs",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "The product's main list is set like a station departure board. Eight to ten real rows (transactions, issues, deploys, bookings) run the full viewport width inside 12px side margins. Each row is a filled band 72px tall, one tone off a dark ground, separated from the next by a 4px gap and never by a line; the whole board is one fragment marked data-ui. Text inside is 26–30px: the name from column 1, two or three facts at columns 6, 8 and 10, and at column 12 a status word on a solid block of its status colour; figures use tabular numerals. The section's top and bottom edges cut the first and last rows in half, so the list seems to continue. The fourth row is open: 216px tall, in the accent colour, with its detail in two lines at 17px. Clicking another row opens that one instead. No heading: the open row's name is the largest type.",
  "mobile": "Rows keep the 4px gaps and are 64px tall inside 8px margins: the name at 20px on the left, the status block on the right, the other facts dropped. The open row is 200px tall and lists the dropped facts as three lines at 16px. Six rows show; the first and last are cropped.",
  "seenIn": [
   "opl-rows",
   "opl-near"
  ]
 },
 {
  "id": "showcase-lit-panel-drag-row",
  "role": "showcase",
  "name": "Lit panel, dark row",
  "shape": "carousel",
  "tags": [
   "ui-led",
   "object-led"
  ],
  "directions": [
   "dark-room",
   "quiet-instrument",
   "borrowed-category",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "One dark field, 96svh tall. Across its middle runs a horizontal row of five to seven interface panels, each a different true-size fragment marked data-ui (an open menu, an inspector, a composer, a chart card), 320–460px wide at their natural heights, vertically centred and 26vw apart. The row scrolls sideways with scroll-snap-type: x mandatory and scroll-snap-align: center, and can be dragged. Only the panel at the centre is lit: full opacity, in the product's light theme, the brightest thing on screen. The others sit at 22% opacity and are inert. The viewport's edges crop the outermost panels. In the gap to the right of the centred panel, top-aligned with it, a 24ch column gives its name at 24px in a light weight and two lines at 16px in a lifted ink; this text changes as panels arrive. There is no heading above the row.",
  "mobile": "The row is 62svh tall; panels snap one per screen at true size, capped at 86vw wide, with neighbours showing 7vw at each side at 22% opacity. The name and its two lines sit beneath the row on the left margin and change with the centred panel. Swiping moves the row.",
  "seenIn": [
   "aww-butter",
   "opl-plastic"
  ]
 },
 {
  "id": "features-giant-command-list",
  "role": "features",
  "name": "Giant command list",
  "shape": "list",
  "tags": [
   "ui-led",
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "loud-flat-poster",
   "product-performs",
   "dark-room",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "The capabilities are set as the product's own command menu, scaled up until it is the section; the root is marked data-ui. First a real search `<input>` at clamp(28px, 3vw, 44px) across columns 1–10: a filled block one tone off the ground, 1.9em tall, whose placeholder of two to four words serves as the heading ('Find a command'). Beneath it six to eight capability names, one per line, at clamp(36px, 4.6vw, 76px), leading 1.1, flush left, each row the full content width. At the right end of a row sits its shortcut as key caps: filled squares 1em tall, one character each, in the text face. The selected row lies on a solid block of the accent colour and shows one sentence at 17px under its name. Typing filters the rows; arrow keys and hover move the selection. Nothing else is in the section.",
  "mobile": "The input is 56px tall at 20px. Capability names are set at 8.5vw, one per line; key caps are dropped, since a phone has no shortcuts. Tapping a name selects it: the accent block and the 16px sentence open beneath it and the previous one closes. Filtering still works.",
  "seenIn": []
 },
 {
  "id": "features-sentences-with-switches",
  "role": "features",
  "name": "Sentences with switches",
  "shape": "band",
  "tags": [
   "ui-led",
   "colour-field",
   "type-led"
  ],
  "directions": [
   "sentence-as-layout",
   "colour-chapters",
   "one-motif",
   "loud-flat-poster",
   "product-performs",
   "type-only-statement"
  ],
  "needs": [],
  "blueprint": "Four or five capabilities, each one plain sentence of five to eight words about what the product will do for you, each ending in a working switch. Every sentence is a full-bleed band 20vh tall, butted against the next with no gap. Inside, on the 12-column grid: the sentence at clamp(30px, 3.6vw, 60px) on one line in columns 1–10, vertically centred; in columns 11–12, right-aligned, the product's real toggle, built in em with its root marked data-ui, 1.1em tall so it matches the type. Off, a band is the page ground and its sentence is in the soft ink. On, the band fills with a palette colour, a different one per band, the sentence turns to full ink and a second line at 17px appears beneath stating the consequence in figures. Two bands are on at load. Toggles answer click, Space and Enter. There is no heading; the first sentence does that work.",
  "mobile": "Bands are at least 132px tall. The sentence is set at 6.4vw on two lines in the left 76% of the width; the toggle sits at the right, vertically centred, with a 44px touch target. When a band is on, the consequence line opens under the sentence at 15px.",
  "seenIn": []
 },
 {
  "id": "features-panel-with-margin-notes",
  "role": "features",
  "name": "Panel with margin notes",
  "shape": "index",
  "tags": [
   "ui-led",
   "quiet",
   "object-led"
  ],
  "directions": [
   "editorial-report",
   "quiet-instrument",
   "product-performs",
   "borrowed-category",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "One tall panel from the product is shown whole, like a plate in a manual, with notes in both margins. The panel (an inspector, a sidebar, a composer) is built at true size, 360–400px wide and 640–720px tall, root marked data-ui, and stands in columns 5–8 on the plain ground with no stage behind it. Six notes describe six of its parts: three in columns 1–4, right-aligned, and three in columns 9–12, left-aligned, each 24ch wide and placed by absolute top offsets so its first line is level with the control it explains. A note is a name at 18px medium and two lines at 15px in the soft ink. Hovering or focusing a note keeps its control at full strength and drops the rest of the panel to 30% opacity; the controls themselves also work. The heading, two to four words at 32–40px, sits right-aligned in columns 1–4, level with the panel's top edge.",
  "mobile": "The panel is shown in three slices, top, middle and bottom, each at true size and centred, cut where the next begins. Under each slice its two notes sit side by side in two columns at 15px. 56px between slices. The heading leads at 26px on the left margin.",
  "seenIn": [
   "aww-the-watch",
   "opl-specia1ne"
  ]
 },
 {
  "id": "features-fixed-output-controls-in-text",
  "role": "features",
  "name": "Fixed output, live controls",
  "shape": "pinned",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "split-stage",
   "product-performs",
   "quiet-instrument",
   "editorial-report",
   "colour-chapters",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A split the reader operates. Left, columns 1–6: a flat colour field that is position: sticky; top: 0; height: 100svh, edge to edge, holding one thing the product makes (a card, an email, a chart, a rendered page) centred at true size, about 420px wide, root marked data-ui. Right, columns 8–12: a reading column no wider than 44ch, with four or five capability blocks 50vh apart. Each block is a sentence at 28–32px, a paragraph at 17px, and directly beneath it one real control at interface size: a slider, a segmented choice, a toggle, a short text field. Operating a control changes the fixed output on the left at once, and changes persist, so by the last block the reader has configured their own. The section heading, two to four words at 40–44px, opens the right column, 72px from the top.",
  "mobile": "The output sits in a sticky field at the top, 42svh tall, cropped to its most telling region, not shrunk. Blocks scroll beneath on the page ground: sentence at 22px, paragraph at 16px, control at full width with 44px targets. The field releases after the last block.",
  "seenIn": [
   "opl-dialkit",
   "aww-cerebrium"
  ]
 },
 {
  "id": "features-close-ups-on-a-board",
  "role": "features",
  "name": "Close-ups on a board",
  "shape": "mosaic",
  "tags": [
   "ui-led",
   "dense",
   "colour-field"
  ],
  "directions": [
   "dense-tiles",
   "loud-flat-poster",
   "colour-chapters",
   "one-motif",
   "borrowed-category",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A board of filled tiles, each a macro view of one small part of the interface. A 12-column grid with 8px gaps, 104px rows and 10px outer margins, no empty cells, no outlines. Eight tiles in four sizes: 6×4, 6×2, 3×4 and 3×2 (columns × rows). Each tile is a flat palette colour with overflow: hidden and holds one real element, built in em and enlarged to 300–400% through the font-size of its data-ui root: a toggle, a pair of key caps, a status word on its block, one figure from a readout, a slider thumb on its track, three stacked initials discs. The element is pushed off-centre so two of the tile's edges crop it. Bottom-left in every tile, 20px in: the capability's name at 18px medium and one line at 15px. One 6×2 tile holds only the heading, two to four words at clamp(36px, 4vw, 60px), top-left.",
  "mobile": "Two columns, 6px gaps, rows of 28vw. Large tiles span both columns and three rows; the rest are one column by two rows. The heading tile leads at full width, two rows tall, type at 9vw. Enlarged elements keep their scale and are cropped harder; captions stay bottom-left at 16px and 14px.",
  "seenIn": [
   "opl-woblo",
   "opl-uglycash",
   "opl-uncle-rudy"
  ]
 },
 {
  "id": "how-it-works-one-card-four-fields",
  "role": "how-it-works",
  "name": "One card, four fields",
  "shape": "strip",
  "tags": [
   "ui-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "colour-chapters",
   "dense-tiles",
   "one-motif",
   "loud-flat-poster",
   "product-performs",
   "borrowed-category"
  ],
  "needs": [],
  "blueprint": "Four flat colour fields stand side by side across the full viewport, each 25vw wide and 84svh tall, butted together with no gaps; neighbours differ clearly in lightness. The same card from the product (a ticket, an order, a request) appears once in each field at the same height, 38% down, centred, 260px wide at true size, each copy marked data-ui. It is further along every time: bare as it arrives, then with an owner and a due date, then with its checklist half ticked, then closed with its final figure. Along the bottom of each field, 28px in, one clause at clamp(22px, 2vw, 32px) on two lines begins with its order word: 'First it lands here.' 'Then someone takes it.' The heading, two to four words at the same size in the heaviest weight, sits top-left in the first field only. On hover a field's card rises 6px over 150ms.",
  "mobile": "The fields stack, each full width and 64svh tall, colours meeting edge to edge. In each: the clause at 24px top-left with 20px margins, then the card at true size, left-aligned, 32px below. The heading sits in the first field above its clause, at 24px in the heaviest weight.",
  "seenIn": [
   "opl-typewave",
   "opl-depo-budget-app",
   "opl-leedlime"
  ]
 },
 {
  "id": "how-it-works-call-and-response-column",
  "role": "how-it-works",
  "name": "Call and response column",
  "shape": "list",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "quiet-instrument",
   "product-performs",
   "type-only-statement",
   "editorial-report",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "The sequence is shown as what you give and what comes back, in one narrow column 560px wide starting at column 4, or on the page's text axis. Three exchanges, 88px apart. Each opens with a sentence at 20px medium that begins with its order word ('First, install it.' 'Then ask for a chart.'). 16px under it, the call: the literal thing the user enters, in a filled panel one tone off the ground, radius 8px, padding 20px. For a developer tool that is real code in `<pre><code>` at 14px with a working copy button; otherwise it is the product's own input holding real typed text. Under the call, indented 56px, the response: a true-size fragment marked data-ui showing what the product returns. The third response is the finished result and breaks out of the column, 760px wide, running to column 12. The heading, two to four words at 28px, sits 56px above the first sentence.",
  "mobile": "The column fills the width with 20px margins. Calls and responses stack without the indent; a 24px gap and a lighter panel tone set the response apart. Code panels scroll sideways inside themselves. The final result runs edge to edge at true size, cropped on the right if wider.",
  "seenIn": [
   "opl-dialkit",
   "opl-ohhmydesign"
  ]
 },
 {
  "id": "how-it-works-drag-through-the-stages",
  "role": "how-it-works",
  "name": "Drag through the stages",
  "shape": "split",
  "tags": [
   "ui-led",
   "colour-field"
  ],
  "directions": [
   "split-stage",
   "product-performs",
   "colour-chapters",
   "sentence-as-layout",
   "dark-room",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "A 50/50 split, 88svh tall: two flat fields meeting on a clean vertical edge, and one slider that drives both. Left field: the current stage as a clause of three to six words at clamp(40px, 4.6vw, 76px) on two lines, top-left from column 1, starting with its order word; it serves as the heading. Right field: one fragment of the product at true size, about 440px wide, centred, root marked data-ui, in the state that matches the clause. Along the bottom, crossing both fields inside 28px margins, a real range `<input>` with four stops: a solid track 8px tall, the part already passed in full ink, and a square thumb 32px across in the accent. Under the track, four stage names at 16px, the current one in full ink. Dragging, or the arrow keys, swaps the clause and the fragment's state with a 200ms crossfade; nothing depends on scroll.",
  "mobile": "The fragment's field comes first, 46svh, the fragment at true size and cropped to its active region. The slider follows directly beneath on the seam, thumb 40px. Then the clause field: the clause at 9vw on two lines, with the four stage names under it in a two-by-two block at 15px.",
  "seenIn": [
   "opl-textmode-overlay",
   "opl-vessa",
   "opl-juggle"
  ]
 },
 {
  "id": "how-it-works-stages-as-one-bar",
  "role": "how-it-works",
  "name": "Stages as one bar",
  "shape": "band",
  "tags": [
   "ui-led",
   "poster",
   "type-led"
  ],
  "directions": [
   "loud-flat-poster",
   "type-only-statement",
   "colour-chapters",
   "one-motif",
   "editorial-report",
   "dense-tiles",
   "sentence-as-layout"
  ],
  "needs": [],
  "blueprint": "The product's progress bar, enlarged until it spans the page, is the diagram. One bar runs across columns 1–12, 22vh tall, built as a flex row of three or four solid segments with 6px gaps, root marked data-ui. Each segment's width follows how long that stage really takes (flex-grow from the true durations, 12% minimum), and each is a different flat palette colour, so the reader sees at once that signing up is a sliver and the rest is the product working. Inside every segment, top-left with 20px padding, its duration in words at clamp(20px, 2vw, 30px): 'Two minutes', 'About a day'. Under each segment, on its left edge, one clause at 17px, 22ch wide, beginning with its order word. Above the bar, 64px clear of it, the heading: two to five words at clamp(48px, 6.4vw, 104px), flush left. Nothing else.",
  "mobile": "The bar turns vertical: segments stack as full-width blocks whose heights keep the proportions, 72px minimum, 6px gaps, about 70svh in total. Each holds its duration at 22px top-left and its clause at 16px beneath. The heading sits above at 11vw on two lines.",
  "seenIn": [
   "opl-wild-as"
  ]
 },
 {
  "id": "stats-figure-set-by-a-slider",
  "role": "stats",
  "name": "Figure set by a slider",
  "shape": "statement",
  "tags": [
   "ui-led",
   "type-led",
   "poster"
  ],
  "directions": [
   "product-performs",
   "loud-flat-poster",
   "type-only-statement",
   "colour-chapters",
   "sentence-as-layout",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "The number is the visitor's own. A flat field at least 88svh tall. Top-left, on the page margin: a question of two to five words at 36–44px that serves as the heading ('How many invoices a month?'). 28px under it, one control from the product at true size, 400px wide, root marked data-ui: a slider with its real label and current value, or a stepper, or a short numeric field, preset to a typical answer. Then a long gap. Bottom-left, on the same edge, the result at clamp(140px, 19vw, 320px), heavy, leading 0.8, tabular numerals, with its unit at 0.22em on the same baseline. It recalculates on every input event from a stated, honest formula. Bottom-right, on the figure's baseline, one sentence at 17px, 30ch wide, says how the figure is worked out. No other figure appears in the section.",
  "mobile": "Question at 26px, then the control at full width with a 44px thumb. The figure sits at the bottom at 30vw with its unit beneath it at 20px, both flush left. The working-out sentence follows at 15px. The section is at least 84svh so the gap between control and figure survives.",
  "seenIn": []
 },
 {
  "id": "stats-bars-standing-on-the-edge",
  "role": "stats",
  "name": "Bars standing on the edge",
  "shape": "full-bleed",
  "tags": [
   "ui-led",
   "colour-field"
  ],
  "directions": [
   "editorial-report",
   "dark-room",
   "dense-tiles",
   "colour-chapters",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "The product's own chart, drawn in HTML, is the whole section. A full-bleed field 90svh tall. Along its bottom edge stand ten to fourteen solid bars, one per true value (months, releases, cohorts), in a CSS grid spanning the full viewport width with 8px gaps and no side margins; heights are the values scaled so the tallest is 58vh. No axes, no grid, no legend; the chart's root is marked data-ui. Every bar is one tone two steps from the field except the one the page is about, which takes the accent. Three bars print their value directly above themselves at clamp(20px, 2vw, 30px), tabular. The headline figure sits top-left on the page margin at clamp(88px, 10vw, 168px), and its claim, two to five words at 28px, sits beneath it on the same margin. Hovering or focusing a bar shows its value and period above it.",
  "mobile": "Bars run sideways: each a full-width row 28px tall growing from the left edge, 6px gaps, eight values at most. The headline figure sits above at 26vw with its claim at 22px. Three rows print their value at 17px inside the bar's right end. The accent row stays.",
  "seenIn": [
   "aww-alethia"
  ]
 },
 {
  "id": "testimonial-quote-as-comment-thread",
  "role": "testimonial",
  "name": "Quote as comment thread",
  "shape": "stack",
  "tags": [
   "ui-led",
   "quiet"
  ],
  "directions": [
   "product-performs",
   "quiet-instrument",
   "dark-room",
   "editorial-report",
   "split-stage"
  ],
  "needs": [],
  "blueprint": "The customer's words arrive the way words arrive inside the product: as a comment. One comment component from the interface is built in em, its root marked data-ui, and enlarged through the root's font-size until the body text is clamp(28px, 3vw, 46px), leading 1.2. It occupies columns 2–9 on the plain ground. It keeps every real part: a filled disc with the speaker's initials, name and company on one line at 0.5em, a true date, the quotation as the comment body (no quotation marks; the component already says who is speaking) and the product's own small resolved or reaction state beneath. Below it, indented to the body text's left edge, two shorter quotations follow as replies in the same component at true size, 440px wide, 32px apart. Columns 10–12 stay empty. There is no heading; the first comment is the largest type in the section.",
  "mobile": "The large comment fills the width inside 20px margins, body text at 7vw, initials disc 40px. The two replies follow at true size, indented 28px, using the remaining width, 24px apart. Dates and states stay. Nothing is centred.",
  "seenIn": [
   "opl-ohhmydesign",
   "opl-heyclicky"
  ]
 },
 {
  "id": "cta-first-step-already-open",
  "role": "cta",
  "name": "First step, already open",
  "shape": "split",
  "tags": [
   "ui-led",
   "colour-field"
  ],
  "directions": [
   "product-performs",
   "colour-chapters",
   "loud-flat-poster",
   "split-stage",
   "dark-room",
   "one-motif"
  ],
  "needs": [],
  "blueprint": "The page ends with the product's first screen already open. A full-bleed field in the accent colour, 90svh tall. Right, in columns 7–11, vertically centred: the product's own create dialog at true size, 440px wide, root marked data-ui, with its real title ('Name your workspace'), one text field holding an editable, believable example, one two-option segmented choice and its primary button. Inside the fragment the product's conventions apply, including the shadow its dialog normally casts. The field accepts typing, the choice switches, and the primary button is the page's only action: it carries the typed value into sign-up. Left, in columns 1–5, anchored to the bottom with 40px margins: the closing line, two to four words at clamp(56px, 7vw, 112px), leading 0.92, on two lines, and under it one sentence at 17px saying what happens after the button. The upper-left stays empty.",
  "mobile": "The closing line leads at 13vw on two lines, top-left, with its sentence beneath at 16px. The dialog follows at full width inside 16px margins, at true size, its primary button 52px tall and full width. The field is at least 84svh; the dialog sits in its lower half.",
  "seenIn": [
   "opl-near",
   "opl-midnight-2"
  ]
 },
 {
  "id": "work-names-pass-a-held-square",
  "role": "work",
  "name": "Names passing a held square",
  "shape": "index",
  "tags": [
   "type-led",
   "photo-led",
   "poster"
  ],
  "directions": [
   "name-as-monument",
   "loud-flat-poster",
   "pinned-poster",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "A flat ground and one centred column of very large names: eight to fourteen project or product names in condensed capitals at clamp(96px, 14vw, 200px), leading 0.85, one per line and each line centred, so long names run nearly to the margins and short ones leave wide ground either side. A square frame 450px across (31vw), square corners, holds at the centre of the viewport behind the list: a sticky element (`position: sticky; top: calc(50svh - 225px)`) at the head of the list with a negative bottom margin equal to its height, centred, below the names in the stacking order, so the passing names cross the picture and are never hidden by it. Choose pictures with a calm centre, or lay a flat 30% scrim inside the frame, so the crossing name keeps 3:1. In the study the picture sits on top of the names; here nothing lies on top of large type. It shows the picture of whichever name is crossing its centre (an IntersectionObserver with a thin root margin at the viewport's middle sets the current name) and swaps with a 200ms crossfade. Level with the frame's middle, 48px to its left and right, two facts about the current item sit at 16px, the kind of work on the left and the year or price on the right, changing with it. The heading opens the list as its first line, at the same size in the soft ink, two words at most. Every name is a link. The section runs about three screens, so each name holds the centre for about a quarter of a screen.",
  "mobile": "Names drop to 15vw and may wrap to two lines, still centred. The frame shrinks to a 60vw square and stays sticky at the centre; the two facts move into one row at 16px directly under it. The heading line stays first.",
  "seenIn": [
   "aww-decathlon-yestalgia"
  ]
 },
 {
  "id": "work-title-torn-at-the-seam",
  "role": "work",
  "name": "Title torn at the seam",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "poster"
  ],
  "directions": [
   "split-stage",
   "pinned-poster",
   "dark-room",
   "name-as-monument"
  ],
  "needs": [],
  "blueprint": "A wrapper about 95svh tall per project holds a sticky stage of 100svh, split at exactly 50vw into two portrait panes of 720×900px, `object-fit: cover`, square corners, no gap: two different pictures or loops of the same project (the whole and a detail, before and after). The project's title sits on the seam, centred on x 720 at the optical middle: capitals in a heavy face at clamp(96px, 11vw, 160px), leading 0.8, tracking -0.04em, at most two lines, in one light colour chosen per project. It is set twice inside the one heading element, the second copy `aria-hidden`, each copy clipped to its own half with `clip-path: inset(0 50% 0 0)` and `inset(0 0 0 50%)`. Directly under it, centred, the discipline and year at 16px and a text link with an arrow at 16px. While a project crosses the stage, the left pane and left half-title travel up and the right pane and right half-title travel down (panes ±20svh, title ±30svh, on a scroll-linked timeline), so the name tears at the seam and lines up exactly when the project is centred. The section's two-word heading sits at clamp(28px, 2.4vw, 36px) in the stage's top-left corner, 32px in. Four to eight projects; the stage releases after the last. Under reduced motion the halves stay aligned and the projects change by crossfade.",
  "mobile": "Keep the split: two strips 195px wide running the full height of the screen with the same opposing travel. The title drops to 12.5vw on the seam, at most two lines, with the discipline line and the link under it at 16px; give the link a 44px hit area.",
  "seenIn": [
   "aww-glitch-grit"
  ]
 },
 {
  "id": "work-one-slide-two-neighbours",
  "role": "work",
  "name": "One slide, two neighbours",
  "shape": "strip",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "editorial-report",
   "contact-sheet",
   "place-carries-it",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "Each project is one band the full width of the viewport. In its middle stands one slide 73% of the width (1046×600px, x 197–1243), square corners, `object-fit: cover`. The previous and next pictures of the same project peek in at the edges, 177px of each visible, with 20px gaps, so the row reads as a filmstrip cut by the viewport. The strip scrolls sideways with `overflow-x: auto` and `scroll-snap-type: x mandatory`, each picture snapping to the centre, the scrollbar hidden. 26px under the strip runs one row at 16px on the 12-column grid with 20px margins, every item on a fixed column start: the project's name in the full ink at column 1 with its category in the soft ink after it, the recognition it received at column 4, the year at column 7, the team or client at column 10. A text link with an arrow sits 24px under the name. 240px of ground separates one project from the next, and all projects share one photographic treatment so the run reads as a set. The section's heading, two or three words at clamp(40px, 4vw, 60px), sits on the left margin 96px above the first strip.",
  "mobile": "The slide is 78% of the width at 4:5, with 32px of each neighbour showing and 10px gaps, still snapping. The row of facts becomes two columns, name and category on the left, year and team from 50% of the width; the link drops under the name. 64px between projects.",
  "seenIn": [
   "aww-leo-parpeix-portfolio-2026"
  ]
 },
 {
  "id": "showcase-cards-cross-a-held-photograph",
  "role": "showcase",
  "name": "Cards crossing a held photograph",
  "shape": "carousel",
  "tags": [
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "place-carries-it",
   "pinned-poster",
   "colour-chapters",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A wrapper about 350svh tall holds a sticky stage of 100svh. On the stage one full-bleed photograph of the subject's ground (a landscape, a material, an aerial texture) stays still, darkened evenly with `filter: brightness(0.6)`. Over it a track of four to six cards travels from right to left as the page scrolls (a `translateX` tied to the wrapper's scroll progress), so one card is always near the centre and the next shows about 300px at the right edge. Each card is 720×560px, square corners, vertically centred with 170px of photograph above and below it: its top 68% is a photograph of one item (a room, a building, a stop on the route), its lower 32% a flat panel in one earth tone from the palette, a different tone per card at a similar lightness, holding the item's name at clamp(32px, 3vw, 48px) bottom-left with 28px padding and a two-line description at 16px right-aligned beside it. Cards are 40px apart. The section heading, two to four words at clamp(40px, 4.4vw, 68px) in the paper colour, sits top-left on the stage over the photograph before the first card arrives and moves out ahead of it. Keep the run under three screens; at its end the stage releases into the next section.",
  "mobile": "No pin and no sideways travel. The photograph runs full-bleed as a 4:5 band under the heading at 9vw, and the cards follow it at full width minus 16px margins, 24px apart, each a 4:3 photograph over its colour panel 140px tall, with the name at 26px and the description at 16px.",
  "seenIn": [
   "aww-lpas",
   "aww-white-desert"
  ]
 },
 {
  "id": "list-object-rides-the-schedule",
  "role": "list",
  "name": "Object riding down the schedule",
  "shape": "table",
  "tags": [
   "object-led",
   "type-led",
   "dense"
  ],
  "directions": [
   "hero-object",
   "borrowed-category",
   "loud-flat-poster",
   "dark-room",
   "specimen-catalogue"
  ],
  "needs": [
   "product-photo"
  ],
  "blueprint": "A schedule of dated events (races, concerts, tastings, departures) with the subject's own object travelling down it. On a dark flat ground, on the page's own margins, the heading is one centred line of condensed capitals at clamp(40px, 3.6vw, 56px), split into its two words with a gap between them, and in the gap stands a cut-out photograph of the object seen from above or in profile, about 320px wide (22% of the width) and up to 64% of the viewport tall. Below come full-width rows 84px tall, each a two-part grid: the event's name at 26px in condensed capitals on the left with a one-line note at 16px in the soft ink under it, and the date and place right-aligned at 16px on the note's baseline. Rows alternate between the ground and a tone one step lighter instead of rules. As the page scrolls, the object leaves the heading and moves down the centre line over the rows (`position: sticky` in a centred column that spans the list, `pointer-events: none`), where it covers only the empty middle of each row and marks how far the reader has come. The row for the next event takes the accent colour across its whole band, with its name and date in the accent's ink. About ten rows fit on one screen; the list ends 120px under the last row.",
  "mobile": "Rows stack at full width on 20px margins: the name at 22px on up to two lines, the note at 16px, the date under the note. The object shrinks to 40% of the width and stays in the heading; it does not travel, because on a phone it would cover the rows. The accent band stays on the next event.",
  "seenIn": [
   "aww-nick-ho"
  ]
 },
 {
  "id": "gallery-rising-triptych",
  "role": "gallery",
  "name": "Rising triptych",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "place-carries-it",
   "uneven-photographs",
   "pinned-poster",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "A wrapper about 200svh tall holds a sticky stage of 100svh on the plain page ground. The heading, two lines at clamp(32px, 2.8vw, 44px), is centred in the top fifth of the stage with one sentence at 17px under it. From the bottom edge three photographs rise as the page scrolls, each at its own speed. The centre one, a 4:5 portrait, starts 620px wide with only its top quarter showing and grows (scale and travel on a scroll-linked timeline) until it is about 760px wide with its top 40px below the sentence, cropped by the bottom edge, so it never reaches the heading. In the study it slides over the heading; here nothing lies on top of a heading. The two outer ones, each 360×530px, stand beside it with 10px gaps, their tops always 55px lower than the centre's, and by the end they are cut by the left and right viewport edges. Square corners, no captions, one consistent light across all three (warm interiors with warm, cold coast with cold). Under reduced motion show the end state with the heading above the three photographs.",
  "mobile": "The centre portrait starts at 60% of the width and grows to nearly the full height; the outer two show as 40px slivers at the screen edges. The heading and sentence sit in the top third above it.",
  "seenIn": [
   "aww-sobha-privy-collection"
  ]
 },
 {
  "id": "showcase-portrait-on-its-own-ground",
  "role": "showcase",
  "name": "Portrait on its own ground",
  "shape": "full-bleed",
  "tags": [
   "photo-led",
   "colour-field"
  ],
  "directions": [
   "colour-chapters",
   "dark-room",
   "hero-object",
   "editorial-report"
  ],
  "needs": [],
  "blueprint": "One chapter per screen, three to six chapters. Each is a full-bleed field 100svh tall whose flat colour is taken from its own photograph: the darkest large tone in the picture (a deep brown for a wooden interior, a slate for a winter coast), so picture and page seem to be one surface. In the centre stands one portrait photograph at 3:4, 506×675px (75% of the height), top 112px, square corners, no frame. Type sits in the corners on 32px margins in the light ink: the chapter's title at clamp(28px, 2.4vw, 36px) top-left as its heading; one sentence at 17px, max 32ch, bottom-left; a text link to the chapter's page bottom-right. As each chapter arrives its field starts as a panel inset 20px from the viewport edges and grows to full-bleed, while the portrait scales from 0.92 to 1 (background colour over 600ms, scale over 800ms, both easing out), so the change reads as the room changing, not as a new slide. A chapter may hold a short muted loop or a single enlarged letterform in place of the portrait, on the same kind of field. The first chapter's title doubles as the section heading.",
  "mobile": "The portrait becomes 80% of the width at 3:4, centred at 45% of the height. The title sits top-left at 24px, and the sentence and link stack bottom-left at 16px under the portrait. The field still takes the picture's darkest tone.",
  "seenIn": [
   "aww-squarespace-foundations"
  ]
 },
 {
  "id": "cta-question-at-both-edges",
  "role": "cta",
  "name": "Question at both edges",
  "shape": "statement",
  "tags": [
   "type-led",
   "poster"
  ],
  "directions": [
   "type-only-statement",
   "sentence-as-layout",
   "dark-room",
   "loud-flat-poster"
  ],
  "needs": [],
  "blueprint": "For a closing choice between two answers: book or ask, this date or that, the tasting or the tour. A flat field exactly 100svh tall in the darkest tone. The question is a two-line heading at clamp(110px, 11vw, 168px), leading 1.0, centred, about 54% of the width: line one with its cap line 32px under the field's top edge, line two with its baseline 24px above the bottom edge. The band of about 560px between them is empty except for one row at its optical centre: two solid rectangular buttons, each about 200×56px with its label at 18px in medium weight, their inner edges 120px either side of the centre axis, and between them, exactly on the axis, the word 'or' at 18px in the soft ink. 16px under the row, centred, one line at 16px in the soft ink says what happens after either choice. The type above and below closes the empty band, so it reads as held breath rather than a gap.",
  "mobile": "The question drops to 18vw, still two centred lines, line one 32px from the top and line two 24px above the bottom. The buttons stack at the centre at 72% of the width, 12px apart, with 'or' between them and the line of explanation under the second.",
  "seenIn": []
 },
 {
  "id": "intro-headline-crossing-onto-a-photograph",
  "role": "intro",
  "name": "Headline crossing onto a photograph",
  "shape": "split",
  "tags": [
   "type-led",
   "photo-led"
  ],
  "directions": [
   "sentence-as-layout",
   "editorial-report",
   "place-carries-it",
   "uneven-photographs"
  ],
  "needs": [],
  "blueprint": "A white or pale ground. A photograph taller than it is wide, 720px wide (columns 7–12), sits flush against the right viewport edge with its top at 46% of the section's first screen and runs below the fold. The heading starts on the 32px left margin across columns 1–8: two lines of light-weight serif capitals at clamp(64px, 5.6vw, 88px), leading 1.04, the first line starting 34% down the screen and the second placed so that its right half runs onto the photograph's upper-left corner. Choose a picture whose upper-left quarter is pale enough for the ink, or set the second line twice, in the ink and in the paper colour, each copy clipped to its side of the photograph's left edge. Under the heading on columns 1–5, one paragraph of up to 25 words at 22px, leading 1.4. At the bottom-left a second, smaller photograph of 360×400px enters, cropped by the screen's lower edge, and on the next screen becomes the left half of a staggered pair with the large one. Nothing else is on the screen.",
  "mobile": "The heading sits at 10vw on two lines on the 20px margin. The photograph becomes 75% of the width, flush right, and starts under the heading's first line so the second line crosses its top-left corner. The paragraph follows at 17px, then the small photograph at 50% of the width on the left margin.",
  "seenIn": [
   "aww-sobha-privy-collection"
  ]
 },
 {
  "id": "features-four-cards-dealt-into-a-fan",
  "role": "features",
  "name": "Four cards dealt into a fan",
  "shape": "pinned",
  "tags": [
   "colour-field",
   "poster"
  ],
  "directions": [
   "pinned-poster",
   "loud-flat-poster",
   "borrowed-category",
   "colour-chapters",
   "specimen-catalogue"
  ],
  "needs": [],
  "blueprint": "Four values, rules or features, each on a card dealt onto the table as the page scrolls. A wrapper about 400svh tall holds a sticky stage of 100svh on the page's darkest flat ground. Each card is a portrait panel of 340×400px with a 20px radius, filled with its own palette colour (four colours that differ clearly in lightness), no border, no shadow. On a card, with 28px padding: its name at 32px in heavy capitals on one or two lines at the top, a photograph or a single picture of the thing at 60% of the card's width in the middle, and one sentence at 16px at the foot. Card one lands alone at the centre; each further quarter of the wrapper deals the next from below the bottom edge, landing 250px to the right of the one before, rotated between -4 and 4 degrees and overlapping only its right 90px, until the four form a fan about 1,090px wide centred on the stage. Keep each card's name, picture and sentence inside its left 240px, so no card ever covers another's words. In the study the cards overlap by two thirds and bury the earlier titles; here nothing lies on top of text. The heading, two to four words at clamp(40px, 4vw, 60px), sits top-left on the stage on the 32px margin with one sentence at 17px under it. The stage releases after the fourth card has settled; under reduced motion the fan is simply there.",
  "mobile": "No pin. The cards sit in one row that scrolls sideways with scroll-snap, each 82% of the width with the next peeking in at the right edge, all upright. The heading and sentence sit above the row.",
  "seenIn": [
   "aww-made-with-gsap-1"
  ]
 },
 {
  "id": "work-small-index-large-picture",
  "role": "work",
  "name": "Small index, large picture",
  "shape": "pinned",
  "tags": [
   "photo-led",
   "type-led"
  ],
  "directions": [
   "split-stage",
   "pinned-poster",
   "editorial-report",
   "product-performs"
  ],
  "needs": [],
  "blueprint": "A sticky stage of 100svh split 38/62. The right 62%, from x 560 to the right viewport edge, is one photograph the full height of the stage, bleeding off the right, top and bottom edges, square corners: a staged picture of the current project in use, in a real place. The left column, from the 20px margin to x 520, holds the heading at the top, two lines at clamp(80px, 8.2vw, 120px), leading 0.95, 40px under the navigation; then empty ground; then, anchored to the bottom with a 32px margin, the index: three to six rows 44px tall, each with the project's name at 28px on the left and two or three disciplines at 16px right-aligned in the soft ink. The current row is in the full ink and the others at 40% opacity; rows are told apart by space only. Each project gets one viewport of scroll: as it arrives, its photograph wipes in from the bottom edge (`clip-path: inset(100% 0 0 0)` to `inset(0)` over 900ms) and the current row moves down the list. The empty middle of the left column is closed by the heading above it and the index below.",
  "mobile": "The heading sits at the top at 14vw on two lines, then the photograph at full width at 4:5, sticky under the heading while the index rows below it change the picture; or let each project simply stack as photograph and row.",
  "seenIn": [
   "aww-neutomni"
  ]
 },
 {
  "id": "list-cards-fold-into-spines",
  "role": "list",
  "name": "Cards that fold into spines",
  "shape": "pinned",
  "tags": [
   "type-led",
   "colour-field",
   "dense"
  ],
  "directions": [
   "editorial-report",
   "colour-chapters",
   "page-is-the-object",
   "dense-tiles"
  ],
  "needs": [],
  "blueprint": "For a journal, a run of notes or a list of chapters without pictures. A wrapper about 400svh tall holds a sticky stage of 100svh. The heading, two to four words at clamp(48px, 4.2vw, 64px), sits top-left on the 32px margin, 120px from the top. Under it a band 360px tall spans the content width and holds a horizontal deck of text cards, each about 620px wide, in three neighbouring flat tones of one hue taken in turn. On a card, with 32px padding: its title at 30px on up to two lines, leading 1.1; an excerpt of two lines at 16px; and along the bottom edge the date on the left and a text link to read it on the right, both at 16px. As the page scrolls, each step slides the next card in from the right edge and squeezes the earlier ones into slivers 120px wide stacked edge to edge at the left, like book spines, where only the first letters of their titles show, clipped by the sliver. Clicking a sliver brings its card back. 48px under the band, the number of entries and a link to the full list sit at 17px on the left margin, so the stage does not end on an empty floor.",
  "mobile": "No pin and no slivers. The cards stack at full width with 12px gaps, each in its tone, the title at 24px, the excerpt at 16px, the date and link at the foot. The heading sits above them at 10vw.",
  "seenIn": [
   "aww-noho"
  ]
 },
 {
  "id": "gallery-photographs-around-a-heading",
  "role": "gallery",
  "name": "Photographs gathered round a heading",
  "shape": "mosaic",
  "tags": [
   "photo-led",
   "quiet"
  ],
  "directions": [
   "uneven-photographs",
   "place-carries-it",
   "quiet-instrument",
   "dark-room"
  ],
  "needs": [],
  "blueprint": "A band about one screen tall on the plain page ground, used as the page's one deliberate rest. At its visual centre sits the heading, two lines at clamp(32px, 2.6vw, 40px) in a light weight, centred, with one sentence at 17px under it and a text link beneath that. Around it six to eight small photographs are scattered irregularly across the middle columns (x 320–1120; the outer 320px on each side stay empty, equally), each 160–200px wide at about 9:10, square corners, at different heights with 60–200px of air between them. Two of them come within 40px of the heading's block, which their drift never reaches, so the cluster reads as gathered round it rather than laid on a grid. No captions and no frames. Each photograph drifts at its own rate as the band passes (4–12% of its own height on a scroll-linked timeline), so the cluster loosens and tightens; under reduced motion it stays put. The pictures come from one place or one day and share one treatment.",
  "mobile": "Five photographs at 30% of the width sit offset left and right around the centred heading, two above it and three below, none cut by an edge. The heading drops to 7vw on two lines.",
  "seenIn": [
   "aww-tengile-malamala-collection",
   "aww-moto-finance"
  ]
 }
]
library/type.json›
[
 {
  "id": "switzer-semibold",
  "name": "Switzer",
  "class": "grotesk-tight",
  "display": {
   "family": "Switzer",
   "source": "fontshare",
   "slug": "switzer",
   "weights": [
    600
   ],
   "italic": false,
   "settings": "weight 600 · tracking -0.04em · leading 0.95 · sentence case; charcoal rather than pure black",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Switzer",
   "source": "fontshare",
   "slug": "switzer",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking -0.005em",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Suisse Intl",
   "Neue Haas Grotesk"
  ],
  "note": "One neo-grotesk doing everything: semibold headings pulled tight, small regular text, no capitals and no second voice. Suits software, hardware and studio pages that want to look exact and unshowy.",
  "seenIn": [
   "aww-noho",
   "opl-vessa",
   "aww-aspen-search",
   "opl-rows"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=switzer@400,500,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "general-sans-bold",
  "name": "General Sans",
  "class": "grotesk-tight",
  "display": {
   "family": "General Sans",
   "source": "fontshare",
   "slug": "general-sans",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.02em · leading 0.9 · sentence case; sizes jump straight from 16px text to 10vw and larger",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500,700&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "General Sans",
   "source": "fontshare",
   "slug": "general-sans",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body and lists, 500 for emphasis · leading 1.45 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500,700&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "PP Neue Montreal",
   "Aeonik"
  ],
  "note": "A grotesk with slightly geometric bowls, set bold and very large against plain 16px text with nothing in between. Suits studios, type and product launches, and finance or tech brands that lead with the name.",
  "seenIn": [
   "aww-pp-neue-montreal",
   "aww-moto-finance",
   "opl-brikkenco",
   "aww-nothin"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=general-sans@400,500,700&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "schibsted-heavy",
  "name": "Schibsted Grotesk",
  "class": "grotesk-tight",
  "display": {
   "family": "Schibsted Grotesk",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.05em · leading 0.88 · sentence case or all lowercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;600;800&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Schibsted Grotesk",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for paragraphs, 600 for navigation and other functional text · leading 1.4 · tracking -0.01em",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;600;800&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "ABC Diatype",
   "Khteka"
  ],
  "note": "A compact, warm grotesk set heavy with letters almost touching; small text stays in the same family at semibold. Suits music, events and culture pages where the type is the image.",
  "seenIn": [
   "aww-paul-kalkbrenner",
   "opl-rendez-vous-franceclat",
   "aww-monolog"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;600;800&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "hanken-medium",
  "name": "Hanken Grotesk",
  "class": "grotesk-tight",
  "display": {
   "family": "Hanken Grotesk",
   "source": "google",
   "weights": [
    500
   ],
   "italic": false,
   "settings": "weight 500 · tracking -0.05em · leading 0.9 · sentence case; one statement size (about 6vw) used for every heading",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Hanken Grotesk",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.45 · tracking -0.005em",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Akzidenz Grotesk",
   "LayGrotesk"
  ],
  "note": "Medium-weight grotesk statements at one large size against caption-size text: a two-size system with no bold. Suits portfolios, agencies and tools that speak in short declarative lines.",
  "seenIn": [
   "aww-bleibtgleich26",
   "aww-made-with-gsap-1",
   "opl-locker-land",
   "opl-builders-table"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "bricolage-contrast",
  "name": "Bricolage Grotesque",
  "class": "grotesk-tight",
  "display": {
   "family": "Bricolage Grotesque",
   "source": "google",
   "weights": [
    600
   ],
   "italic": false,
   "settings": "weight 600 · tracking -0.04em · leading 0.92 · sentence case; leave optical sizing on so large sizes tighten and gain contrast",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Bricolage Grotesque",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Beausite Classic",
   "F37 Analog"
  ],
  "note": "A grotesk with visible stroke contrast and slightly irregular curves, so headings look drawn rather than engineered. Suits reports, consultancies and design-led software that want warmth without a serif.",
  "seenIn": [
   "aww-ai-in-design-report-2026",
   "aww-illoca",
   "opl-ohhmydesign"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "franklin-source",
  "name": "Libre Franklin / Source Serif 4",
  "class": "grotesk-tight",
  "display": {
   "family": "Libre Franklin",
   "source": "google",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.035em · leading 0.92 · uppercase for the title, sentence case for section headings",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@700&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Source Serif 4",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": true,
   "settings": "400 for body, 600 for emphasis, italic for quotations · leading 1.55 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@700&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Founders Grotesk",
   "Die Grotesk",
   "NON Natural Grotesk"
  ],
  "note": "An American gothic for headings over a sober transitional serif for reading, the pairing of a newspaper front page. Suits essays, annual reports, archives and finance pages that have real paragraphs.",
  "seenIn": [
   "aww-dragonfly-redux",
   "aww-trevor-noah",
   "aww-mosbys-files"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@700&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "switzer-roman",
  "name": "Switzer",
  "class": "grotesk-light",
  "display": {
   "family": "Switzer",
   "source": "fontshare",
   "slug": "switzer",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 · tracking -0.03em · leading 1.1 · sentence case; headings stay small (28–40px) and at most one line jumps to 5vw",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Switzer",
   "source": "fontshare",
   "slug": "switzer",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for everything, 500 only for names and links · leading 1.45 · tracking -0.01em; hierarchy by ink against mid-grey",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Neue Haas Grotesk 55 Roman",
   "Helvetica Now"
  ],
  "note": "A single roman weight of a Swiss neo-grotesk at two or three modest sizes; position and grey value do the work that size and bold usually do. Suits architects, galleries, studios and any quiet page where photographs carry the scale.",
  "seenIn": [
   "aww-revelatio-studio",
   "opl-spatial",
   "aww-pensatori-irrazionali",
   "opl-compile"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "archivo-regular",
  "name": "Archivo",
  "class": "grotesk-light",
  "display": {
   "family": "Archivo",
   "source": "google",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 · tracking -0.02em · leading 1.0 · sentence case; never bold, inactive items fade to grey",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Archivo",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for buttons and short emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Scto Grotesk A",
   "Cash Sans"
  ],
  "note": "A sturdy grotesque kept at regular weight even at 88px, so the page reads as composed rather than loud. Suits finance, infrastructure and research pages that need to look sober and current.",
  "seenIn": [
   "aww-sharplink",
   "opl-plastic",
   "opl-lovably",
   "opl-berd"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Archivo:wght@400;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "clash-grotesk-regular",
  "name": "Clash Grotesk",
  "class": "grotesk-light",
  "display": {
   "family": "Clash Grotesk",
   "source": "fontshare",
   "slug": "clash-grotesk",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 · tracking -0.03em · leading 0.95 · sentence case, or uppercase for one statement",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=clash-grotesk@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Clash Grotesk",
   "source": "fontshare",
   "slug": "clash-grotesk",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.4 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=clash-grotesk@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Sneak",
   "ABC Monument Grotesk"
  ],
  "note": "A slightly narrow neo-grotesk with closed apertures, used at regular weight from marquee size down to paragraphs. Suits agencies and digital studios that want large type without heaviness.",
  "seenIn": [
   "aww-px-push",
   "opl-wild-as",
   "aww-sstr-friction-reduction"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=clash-grotesk@400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "work-sans-light",
  "name": "Work Sans",
  "class": "grotesk-light",
  "display": {
   "family": "Work Sans",
   "source": "google",
   "weights": [
    300
   ],
   "italic": false,
   "settings": "weight 300 · tracking -0.035em · leading 1.0 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Work Sans",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "ABC Favorit",
   "Clarkson Light",
   "PP Mori"
  ],
  "note": "A light grotesque with open, slightly old-fashioned shapes; large headings look airy and the text weight beneath stays easy to read. Suits AI and research products, fashion houses and calm brand sites.",
  "seenIn": [
   "aww-cerebrium",
   "aww-squarespace-foundations",
   "aww-boc-studio"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "anton-work",
  "name": "Anton / Work Sans",
  "class": "condensed-heavy",
  "display": {
   "family": "Anton",
   "source": "google",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 (single heavy cut) · tracking -0.01em · leading 0.86 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Anton:wght@400&family=Work+Sans:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Work Sans",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for links and short emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Anton:wght@400&family=Work+Sans:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Druk",
   "Thunder",
   "Acme Gothic"
  ],
  "note": "Compressed heavy capitals stacked tight, with a neutral grotesque kept small beneath. Suits fashion drops, sport, nightlife and any page built as a poster.",
  "seenIn": [
   "aww-serotoninn",
   "opl-juggle",
   "opl-lorenzo-bocchi-photography"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Anton:wght@400&family=Work+Sans:wght@400;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "big-shoulders-public",
  "name": "Big Shoulders / Public Sans",
  "class": "condensed-heavy",
  "display": {
   "family": "Big Shoulders",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.005em · leading 0.88 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Big+Shoulders:wght@800&family=Public+Sans:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Public Sans",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Big+Shoulders:wght@800&family=Public+Sans:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Founders Grotesk Condensed",
   "PP Neue York",
   "Knockout"
  ],
  "note": "A tall American gothic taken from city signage, blunt and civic, over a plain public-service sans. Suits culture indexes, community projects, bars, sport clubs and local campaigns.",
  "seenIn": [
   "aww-mosbys-files",
   "opl-the-sapphic-bar-index",
   "opl-the-deep-end",
   "aww-colonia-zacamil"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Big+Shoulders:wght@800&family=Public+Sans:wght@400;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "archivo-compressed",
  "name": "Archivo",
  "class": "condensed-heavy",
  "display": {
   "family": "Archivo",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 at font-stretch 62% (load the wdth axis and use its narrowest setting) · tracking -0.01em · leading 0.86 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo:wdth,[email protected],100..900&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Archivo",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "normal width · 400 for body, 500 for buttons and emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo:wdth,[email protected],100..900&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Helvetica Now Display Condensed"
  ],
  "note": "One variable family pushed to its narrowest, heaviest setting for headlines and relaxed to normal width for text, so display and body share a skeleton. Suits sportswear, campaigns and fintech pages that speak in capitals.",
  "seenIn": [
   "aww-decathlon-yestalgia",
   "opl-uglycash",
   "opl-juggle"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Archivo:wdth,[email protected],100..900&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "bebas-garamond",
  "name": "Bebas Neue / EB Garamond",
  "class": "condensed-heavy",
  "display": {
   "family": "Bebas Neue",
   "source": "google",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 (single cut, capitals only) · tracking -0.02em, tightening to -0.06em above 15vw · leading 0.85 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bebas+Neue:wght@400&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "EB Garamond",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "leading 1.45 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bebas+Neue:wght@400&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Founders Grotesk Condensed",
   "Cardinal Classic Long",
   "Signifier"
  ],
  "note": "Huge condensed capitals against a light old-style serif with italics: an industrial voice and a literary one, with a twenty-fold size gap between them. Suits crafted physical products, spirits, outdoor goods and hospitality.",
  "seenIn": [
   "aww-longbow",
   "aww-white-desert",
   "aww-mosbys-files"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Bebas+Neue:wght@400&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "barlow-condensed",
  "name": "Barlow Condensed / Barlow",
  "class": "condensed-heavy",
  "display": {
   "family": "Barlow Condensed",
   "source": "google",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.02em · leading 0.9 · uppercase for statements, mixed case for item titles",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700&family=Barlow:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Barlow",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700&family=Barlow:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "FF DIN Condensed",
   "Helvetica Now Display Condensed"
  ],
  "note": "Road-sign condensed type with squared curves, bold but not compressed into a wall, with its normal-width sibling for text. Suits logistics, motorsport, hardware and directories with many short titles.",
  "seenIn": [
   "aww-julien-calot",
   "opl-medium-rare",
   "opl-uglycash"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700&family=Barlow:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "panchang-switzer",
  "name": "Panchang / Switzer",
  "class": "extended-heavy",
  "display": {
   "family": "Panchang",
   "source": "fontshare",
   "slug": "panchang",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.02em · leading 1.0 · uppercase; four words or fewer per line because the face is very wide",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=panchang@700&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Switzer",
   "source": "fontshare",
   "slug": "switzer",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=panchang@700&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Monument Extended",
   "Owners Wide",
   "Nimbus Sans Extended"
  ],
  "note": "A flat-sided extended grotesk in heavy capitals, wide enough that two words fill the screen, with a neutral sans kept small. Suits portfolios, real estate, travel and brand sites that want a broad, confident wordmark.",
  "seenIn": [
   "opl-belen-jones",
   "aww-lxl-creative",
   "aww-realevate",
   "aww-the-tuscan-journey-begins"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=panchang@700&display=swap",
   "https://api.fontshare.com/v2/css?f[]=switzer@400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "tiktok-sans-wide",
  "name": "TikTok Sans",
  "class": "extended-heavy",
  "display": {
   "family": "TikTok Sans",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 at font-stretch 150% (load the wdth axis and use its widest setting) · tracking -0.01em · leading 1.0 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=TikTok+Sans:wdth,[email protected],300..900&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "TikTok Sans",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "normal width · 400 for body, 500 for emphasis · leading 1.45 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=TikTok+Sans:wdth,[email protected],300..900&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Herokid Wide",
   "At Aero",
   "Wintyr"
  ],
  "note": "One variable grotesk stretched wide and bold for the headline and the closing statement, then returned to normal width for reading. Suits personal portfolios, apps and tech brands that want width without a second family.",
  "seenIn": [
   "aww-haoqi-design",
   "opl-tara",
   "aww-produx-design",
   "opl-proooof"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=TikTok+Sans:wdth,[email protected],300..900&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "unbounded-onest",
  "name": "Unbounded / Onest",
  "class": "extended-heavy",
  "display": {
   "family": "Unbounded",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.03em · leading 1.0 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Unbounded:wght@800&family=Onest:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Onest",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Unbounded:wght@800&family=Onest:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "IntraNet",
   "Nekst",
   "Owners Wide"
  ],
  "note": "A wide display sans built from rounded squares, heavy and a little architectural, beside a clean text grotesk. Suits architecture indexes, watches, mobility and events that want a title with a constructed feel.",
  "seenIn": [
   "opl-belgrade-architecture",
   "aww-the-watch",
   "aww-risk",
   "aww-house-of-honey-1"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Unbounded:wght@800&family=Onest:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "chivo-black",
  "name": "Chivo",
  "class": "heavy-black",
  "display": {
   "family": "Chivo",
   "source": "google",
   "weights": [
    900
   ],
   "italic": false,
   "settings": "weight 900 · tracking -0.05em · leading 0.88 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Chivo",
   "source": "google",
   "weights": [
    400,
    700
   ],
   "italic": false,
   "settings": "400 for paragraphs, 700 for navigation, tags and links · leading 1.35 · tracking -0.01em",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Elza",
   "ABC Diatype Heavy",
   "Suisse Intl Black"
  ],
  "note": "A black grotesque in capitals with letters nearly colliding, and only two sizes on the page: enormous and small. Suits agencies, city and culture projects and musicians who want the type to land like a headline.",
  "seenIn": [
   "aww-glitch-grit",
   "opl-city-of-angels",
   "aww-warm-fuzzy"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "cabinet-grotesk-black",
  "name": "Cabinet Grotesk",
  "class": "heavy-black",
  "display": {
   "family": "Cabinet Grotesk",
   "source": "fontshare",
   "slug": "cabinet-grotesk",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.035em · leading 0.9 · lowercase or sentence case, one or two words per line",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,800&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Cabinet Grotesk",
   "source": "fontshare",
   "slug": "cabinet-grotesk",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,800&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Champ",
   "Uncut Sans"
  ],
  "note": "A heavy sans with tight joins and an eccentric lowercase, friendly but loud. Suits bookshops, food, consumer subscriptions and marketing studios.",
  "seenIn": [
   "aww-aardvark-book-club",
   "opl-protip-marketing",
   "opl-rendez-vous-franceclat"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,800&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "epilogue-black",
  "name": "Epilogue",
  "class": "heavy-black",
  "display": {
   "family": "Epilogue",
   "source": "google",
   "weights": [
    900
   ],
   "italic": false,
   "settings": "weight 900 · tracking -0.04em · leading 0.9 · sentence case or title case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Epilogue:wght@400;500;900&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Epilogue",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Epilogue:wght@400;500;900&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Die Grotesk D",
   "Acme Gothic",
   "FH Oscar"
  ],
  "note": "A broad black grotesque with a slightly uneven rhythm, at its best as a name set very large in mixed case. Suits entertainers, podcasts, consumer apps and blunt SaaS pages; emphasis comes from colour, not from a change of weight.",
  "seenIn": [
   "aww-trevor-noah",
   "opl-juggle"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Epilogue:wght@400;500;900&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "red-hat-black",
  "name": "Red Hat Display / Red Hat Text",
  "class": "heavy-black",
  "display": {
   "family": "Red Hat Display",
   "source": "google",
   "weights": [
    900
   ],
   "italic": false,
   "settings": "weight 900 · tracking -0.03em · leading 0.92 · uppercase; centred three-line stacks work well",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@900&family=Red+Hat+Text:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Red Hat Text",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@900&family=Red+Hat+Text:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "FH Oscar",
   "Futura Bold"
  ],
  "note": "A geometric black with round bowls and flat terminals, cleaner and more even than a grotesque at the same weight. Suits fintech, apps and portfolios that want heavy type with a tidy, technical finish.",
  "seenIn": [
   "aww-risk",
   "aww-leo-parpeix-portfolio-2026"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@900&family=Red+Hat+Text:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2.5rem, 1.2rem + 5vw, 5.5rem)"
  }
 },
 {
  "id": "jost-caps",
  "name": "Jost",
  "class": "geometric",
  "display": {
   "family": "Jost",
   "source": "google",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.03em · leading 0.9 · uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Jost:wght@400;500;700&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Jost",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Jost:wght@400;500;700&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Futura Std Bold",
   "Futura PT"
  ],
  "note": "The pointed, circular geometry of the 1920s in bold capitals, with the same family at reading size. Suits space, science, museums, architecture and anything that should feel like a mid-century poster.",
  "seenIn": [
   "aww-21-hrs-on-the-moon",
   "aww-leo-parpeix-portfolio-2026"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Jost:wght@400;500;700&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "stack-sans-light",
  "name": "Stack Sans Headline / Stack Sans Text",
  "class": "geometric",
  "display": {
   "family": "Stack Sans Headline",
   "source": "google",
   "weights": [
    300
   ],
   "italic": false,
   "settings": "weight 300 · tracking -0.022em · leading 1.05 · sentence case; a heading may be two-tone, grey then full ink",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@300&family=Stack+Sans+Text:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Stack Sans Text",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@300&family=Stack+Sans+Text:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Mluvka",
   "Roobert"
  ],
  "note": "A geometric sans in a light weight at large size, soft and exact at once, with its text cut underneath. Suits consumer apps, utilities and developer tools that want to feel calm and modern.",
  "seenIn": [
   "opl-screentune",
   "opl-dialkit",
   "opl-typewave"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@300&family=Stack+Sans+Text:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "albert-sans-semibold",
  "name": "Albert Sans",
  "class": "geometric",
  "display": {
   "family": "Albert Sans",
   "source": "google",
   "weights": [
    600
   ],
   "italic": false,
   "settings": "weight 600 · tracking -0.035em · leading 1.0 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Albert Sans",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "BT Steinhart",
   "TT Norms Pro",
   "Objektiv"
  ],
  "note": "A geometric grotesque with Scandinavian restraint: round letters, even colour, nothing decorative. Suits logistics, health, property and corporate sites that need to look clear and dependable.",
  "seenIn": [
   "aww-united-carriers",
   "aww-likova"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "spartan-figtree",
  "name": "League Spartan / Figtree",
  "class": "geometric",
  "display": {
   "family": "League Spartan",
   "source": "google",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.04em · leading 0.88 · lowercase or sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Figtree:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Figtree",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Figtree:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Degular",
   "Roobert"
  ],
  "note": "A compact bold geometric with a low waist, punchy in short lowercase lines, over a friendly open text sans. Suits newsletters, consumer products, creators and small shops.",
  "seenIn": [
   "opl-lennys-newsletter-product-pass",
   "opl-near",
   "opl-sand-studio",
   "aww-aardvark-book-club"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Figtree:wght@400;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "pilcrow-rounded",
  "name": "Pilcrow Rounded",
  "class": "rounded-friendly",
  "display": {
   "family": "Pilcrow Rounded",
   "source": "fontshare",
   "slug": "pilcrow-rounded",
   "weights": [
    700
   ],
   "italic": false,
   "settings": "weight 700 · tracking -0.05em · leading 0.9 · all lowercase, one or two words per line",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=pilcrow-rounded@400,500,700&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Pilcrow Rounded",
   "source": "fontshare",
   "slug": "pilcrow-rounded",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0; lowercase throughout is fine",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=pilcrow-rounded@400,500,700&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Milling Triplex",
   "Ease"
  ],
  "note": "A sturdy sans with fully rounded stroke ends, set bold and tight in lowercase with one small size for everything else. Suits toys, gadgets, cosmetics and other objects with soft moulded edges.",
  "seenIn": [
   "opl-when-you-wish-upon-a-starboy",
   "opl-anidachi",
   "aww-artem-shcherbakov"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=pilcrow-rounded@400,500,700&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "roundo-soft",
  "name": "Roundo",
  "class": "rounded-friendly",
  "display": {
   "family": "Roundo",
   "source": "fontshare",
   "slug": "roundo",
   "weights": [
    500
   ],
   "italic": false,
   "settings": "weight 500 · tracking -0.025em · leading 1.05 · sentence case; colour the type to its section instead of black",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=roundo@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Roundo",
   "source": "fontshare",
   "slug": "roundo",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=roundo@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Ease",
   "Open Runde"
  ],
  "note": "A rounded geometric sans at medium weight and a generous text size, gentle without looking childish. Suits wellness, home goods, family apps and everyday money tools.",
  "seenIn": [
   "opl-anidachi",
   "opl-billow"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=roundo@400,500,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "chillax-semibold",
  "name": "Chillax",
  "class": "rounded-friendly",
  "display": {
   "family": "Chillax",
   "source": "fontshare",
   "slug": "chillax",
   "weights": [
    600
   ],
   "italic": false,
   "settings": "weight 600 · tracking -0.03em · leading 1.0 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=chillax@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Chillax",
   "source": "fontshare",
   "slug": "chillax",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=chillax@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "SF Pro Rounded",
   "Open Runde"
  ],
  "note": "A soft geometric sans with wide round letters and relaxed joins, warm at semibold and easy at text size. Suits consumer apps, community products, cafes and small friendly brands.",
  "seenIn": [
   "opl-uncle-rudy",
   "opl-mat"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=chillax@400,500,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "gambarino-general",
  "name": "Gambarino / General Sans",
  "class": "serif-display",
  "display": {
   "family": "Gambarino",
   "source": "fontshare",
   "slug": "gambarino",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 (single cut) · tracking -0.02em · leading 0.92 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=gambarino@400&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "General Sans",
   "source": "fontshare",
   "slug": "general-sans",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, navigation and buttons, 500 for emphasis · leading 1.5",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=gambarino@400&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "PP Editorial New",
   "Migra"
  ],
  "note": "A narrow, sharp display serif for headlines against a small plain sans: a five-to-one jump with nothing between. Suits agencies, bakeries and restaurants, fashion and editorial brands.",
  "seenIn": [
   "aww-hilden-kaira",
   "opl-tab-kitchen-bakery",
   "opl-glide-typeface",
   "aww-milledollars"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=gambarino@400&display=swap",
   "https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "bodoni-jost",
  "name": "Bodoni Moda / Jost",
  "class": "serif-display",
  "display": {
   "family": "Bodoni Moda",
   "source": "google",
   "weights": [
    500
   ],
   "italic": true,
   "settings": "weight 500 with optical sizing on · tracking -0.02em · leading 0.95 · uppercase or sentence case; italic for a whole line, never for one word",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,wght@0,500;1,500&family=Jost:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Jost",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.6 · tracking 0.01em",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,wght@0,500;1,500&family=Jost:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "TT Ramillas",
   "Freight Big"
  ],
  "note": "A true didone with hairline serifs and vertical stress over a light geometric sans, the classic fashion-magazine pairing. Suits luxury property, jewellery, fragrance and fashion.",
  "seenIn": [
   "aww-sobha-privy-collection",
   "opl-april-leihuanani-evangelista"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,wght@0,500;1,500&family=Jost:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "imbue-albert",
  "name": "Imbue / Albert Sans",
  "class": "serif-display",
  "display": {
   "family": "Imbue",
   "source": "google",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 with optical sizing on · tracking -0.01em · leading 0.9 · uppercase, sized so one short word fills its column (6–12vw)",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Imbue:wght@400&family=Albert+Sans:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Albert Sans",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Imbue:wght@400&family=Albert+Sans:wght@400;500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Ambroise Francois"
  ],
  "note": "A tall, condensed high-contrast serif whose capitals read like a masthead or a shop fascia. Suits residences, hotels, restaurants and wine, where a short name can stand floor to ceiling.",
  "seenIn": [
   "aww-era-residence",
   "opl-le-petit-bleu"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Imbue:wght@400&family=Albert+Sans:wght@400;500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "erode-sharp",
  "name": "Erode",
  "class": "serif-display",
  "display": {
   "family": "Erode",
   "source": "fontshare",
   "slug": "erode",
   "weights": [
    500
   ],
   "italic": false,
   "settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Erode",
   "source": "fontshare",
   "slug": "erode",
   "weights": [
    400,
    600
   ],
   "italic": true,
   "settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Noe Text",
   "Reckless",
   "Migra"
  ],
  "note": "A serif with chiselled, angular curves and wedge serifs, sharp at display size and firm in text. Suits studios, galleries, advisory firms and written guides.",
  "seenIn": [
   "aww-house-of-honey-1",
   "aww-milledollars",
   "aww-colonia-zacamil"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "boska-roman-italic",
  "name": "Boska",
  "class": "serif-display",
  "display": {
   "family": "Boska",
   "source": "fontshare",
   "slug": "boska",
   "weights": [
    400
   ],
   "italic": true,
   "settings": "weight 400 · tracking -0.02em · leading 0.95 · roman capitals for the main words with lowercase italic for the connecting words at the same size, or sentence case throughout; centred lines work",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Boska",
   "source": "fontshare",
   "slug": "boska",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Louize Display",
   "PP Pangaia"
  ],
  "note": "A high-contrast serif with pointed serifs and a lively italic, used roman and italic together at one size. Suits perfume, jewellery, lodges, weddings and other occasions for ceremony.",
  "seenIn": [
   "aww-vero-new-york",
   "opl-eagles-nest",
   "opl-sarah-matt-wedding"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "newsreader-light",
  "name": "Newsreader",
  "class": "serif-light-large",
  "display": {
   "family": "Newsreader",
   "source": "google",
   "weights": [
    300
   ],
   "italic": false,
   "settings": "weight 300 with optical sizing on · tracking -0.05em · leading 0.9 · sentence case at 12–14vw; chapter headings drop to the text weight at about 30px",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Newsreader",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "400 for body, 500 for emphasis, italic for titles · leading 1.6 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Self Modern",
   "PP Editorial New Light"
  ],
  "note": "One news serif with optical sizes: light and very tight when huge, open and bookish at 18px. Suits long-form stories, culture pieces, journals and research write-ups.",
  "seenIn": [
   "opl-the-queue",
   "opl-leedlime",
   "opl-le-petit-bleu"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "zodiak-hairline",
  "name": "Zodiak / Hanken Grotesk",
  "class": "serif-light-large",
  "display": {
   "family": "Zodiak",
   "source": "fontshare",
   "slug": "zodiak",
   "weights": [
    100,
    300
   ],
   "italic": false,
   "settings": "weight 100 above 96px, 300 for 28–36px statements · tracking -0.01em · leading 1.0 · capitals for the headline, sentence case for statements; never bold",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=zodiak@100,300&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Hanken Grotesk",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": false,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=zodiak@100,300&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "PP Fragment",
   "PP Editorial New Ultralight",
   "Cardinal Classic Long"
  ],
  "note": "Hairline serif capitals set large enough that the thin strokes read as drawing, with a small neutral sans for everything practical. Suits lodges, spas, architecture and interiors; keep it on flat pale or dark fields where the strokes have full contrast.",
  "seenIn": [
   "aww-tengile-malamala-collection",
   "opl-deeo-studio-2",
   "aww-normal-is-boring",
   "aww-white-desert"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500&display=swap",
   "https://api.fontshare.com/v2/css?f[]=zodiak@100,300&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "sentient-light",
  "name": "Sentient",
  "class": "serif-light-large",
  "display": {
   "family": "Sentient",
   "source": "fontshare",
   "slug": "sentient",
   "weights": [
    300
   ],
   "italic": false,
   "settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Sentient",
   "source": "fontshare",
   "slug": "sentient",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Season Mix",
   "Exposure",
   "Self Modern"
  ],
  "note": "A soft, low-contrast serif in a light weight, more book than fashion magazine, large and calm in sentence case. Suits outdoor and craft products, restaurants, cameras and slow consumer goods.",
  "seenIn": [
   "aww-seasats",
   "opl-poetry-camera",
   "opl-le-petit-bleu"
  ],
  "links": [
   "https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "eb-garamond-quiet",
  "name": "EB Garamond",
  "class": "serif-text",
  "display": {
   "family": "EB Garamond",
   "source": "google",
   "weights": [
    400
   ],
   "italic": true,
   "settings": "weight 400, italic for the title · tracking -0.01em · leading 1.1 · sentence case or all lowercase; the largest size stays near 40px",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "EB Garamond",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Edict",
   "Louize"
  ],
  "note": "A humanist Garamond in roman and italic at two modest sizes, with no capitals and no bold. Suits set designers, antique dealers, wine, publishers and portfolios that whisper.",
  "seenIn": [
   "aww-depo-luxe",
   "opl-hebbia"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "literata-book",
  "name": "Literata",
  "class": "serif-text",
  "display": {
   "family": "Literata",
   "source": "google",
   "weights": [
    500
   ],
   "italic": false,
   "settings": "weight 500 with optical sizing on · tracking -0.02em · leading 1.05 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Literata:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Literata",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": true,
   "settings": "400 for body, 600 for emphasis, italic for titles · leading 1.6 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Literata:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Albra Book",
   "Signifier",
   "Domaine Text"
  ],
  "note": "A sturdy screen-reading serif with a large x-height; headings are simply the text face at medium weight and a larger size. Suits essays, reports, newsletters and knowledge products.",
  "seenIn": [
   "opl-the-new-industrials",
   "aww-mosbys-files",
   "opl-monte"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Literata:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "stix-plain-roman",
  "name": "STIX Two Text",
  "class": "serif-text",
  "display": {
   "family": "STIX Two Text",
   "source": "google",
   "weights": [
    400
   ],
   "italic": true,
   "settings": "weight 400 · tracking -0.026em · leading 1.1 · sentence case at 2.6–3.6vw; one running line may be set in italic capitals",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "STIX Two Text",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for emphasis · leading 1.45 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Untitled Serif",
   "Times New Roman",
   "FK Roman Standard"
  ],
  "note": "The plain Times-style roman of a typed document, slightly tightened and never larger than a few vw. Suits vehicles and tools presented dryly, artists' pages, investment memos and anything meant to look undesigned on purpose.",
  "seenIn": [
   "opl-transport",
   "opl-shawn-golden",
   "aww-dragonfly-redux"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "young-source",
  "name": "Young Serif / Source Serif 4",
  "class": "serif-text",
  "display": {
   "family": "Young Serif",
   "source": "google",
   "weights": [
    400
   ],
   "italic": false,
   "settings": "weight 400 (single heavy cut) · tracking -0.02em · leading 0.95 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Young+Serif:wght@400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Source Serif 4",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": true,
   "settings": "400 for body, 600 for emphasis, italic for quotations · leading 1.55 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Young+Serif:wght@400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "FS Kim",
   "Recoleta",
   "Gelica"
  ],
  "note": "A heavy, round old-style headline face with soft bracketed serifs over a clear transitional text serif. Suits essays, food, independent publishers and personal sites that want warmth with weight.",
  "seenIn": [
   "opl-applied-imagination",
   "opl-flowjam-2",
   "opl-marina-rachello"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Young+Serif:wght@400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "zilla-slab-even",
  "name": "Zilla Slab",
  "class": "slab",
  "display": {
   "family": "Zilla Slab",
   "source": "google",
   "weights": [
    600
   ],
   "italic": false,
   "settings": "weight 600 · tracking -0.025em · leading 1.0 · sentence case",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Zilla+Slab:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Zilla Slab",
   "source": "google",
   "weights": [
    400,
    500
   ],
   "italic": true,
   "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Zilla+Slab:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Museo Slab"
  ],
  "note": "A contemporary slab with smooth curves and unbracketed serifs, even enough to carry headings and paragraphs alike. Suits developer tools, personal sites, open-source and nonprofit projects.",
  "seenIn": [
   "opl-justin-lerner"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Zilla+Slab:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "besley-franklin",
  "name": "Besley / Libre Franklin",
  "class": "slab",
  "display": {
   "family": "Besley",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.02em · leading 0.95 · sentence case or uppercase",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Besley:wght@800&family=Libre+Franklin:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Libre Franklin",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": false,
   "settings": "400 for body, 600 for emphasis · leading 1.5 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Besley:wght@800&family=Libre+Franklin:wght@400;600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "FS Kim",
   "Museo Slab"
  ],
  "note": "A fat Clarendon with ball terminals and bracketed slabs over a plain gothic, the look of a nineteenth-century bill poster. Suits breweries, barbers, markets, fairs and heritage goods.",
  "seenIn": [
   "opl-applied-imagination",
   "opl-justin-lerner"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Besley:wght@800&family=Libre+Franklin:wght@400;600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 },
 {
  "id": "rokkitt-literata",
  "name": "Rokkitt / Literata",
  "class": "slab",
  "display": {
   "family": "Rokkitt",
   "source": "google",
   "weights": [
    800
   ],
   "italic": false,
   "settings": "weight 800 · tracking -0.01em · leading 0.85 · uppercase, sized by cap height (about 14vw)",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Rokkitt:wght@800&family=Literata:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "text": {
   "family": "Literata",
   "source": "google",
   "weights": [
    400,
    600
   ],
   "italic": true,
   "settings": "400 for body, 600 for emphasis · leading 1.6 · tracking 0",
   "css": "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Rokkitt:wght@800&family=Literata:ital,wght@0,400;0,600;1,400;1,600&display=swap\" rel=\"stylesheet\">"
  },
  "standsInFor": [
   "Albra Book"
  ],
  "note": "Narrow geometric slab capitals as a tall title block, then a bookish serif for a long essay beneath. Suits manufacturing, trades, manifestos and industrial history.",
  "seenIn": [
   "opl-the-new-industrials"
  ],
  "links": [
   "https://fonts.googleapis.com/css2?family=Rokkitt:wght@800&family=Literata:ital,wght@0,400;0,600;1,400;1,600&display=swap"
  ],
  "scale": {
   "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
   "step-body": "clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem)",
   "step-lead": "clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem)",
   "step-head": "clamp(2rem, 1.3rem + 3.2vw, 4rem)"
  }
 }
]
README.md›
# tasteskill v2 (`design-taste-frontend`)

A design skill for coding agents, built from a study of 247 curated sites (One Page Love and Awwwards
Sites of the Day, October 2026). It gives an agent an art direction instead of letting it fall back on
the average landing page, holds it to a strict no-ornament house style, and makes it look at its own
render before calling the work done.

## How it works

1. **Inventory.** The agent lists what only this client has: the name and what it means, the street,
   the numbers, the one constraint, the materials, the hours.
2. **Draw.** `scripts/draw.mjs` deals three of 20 directions (each an idea engine seen on the
   strongest studied sites); the agent picks the one that can carry something from the inventory, and
   the script deals the rest: a hero composition, section compositions, a type system of real free
   fonts, a palette, photographs, a motion, studied references, and under every blueprint a list of
   where it disagrees with the house rules. Recent draws are remembered so projects do not repeat.
3. **Idea and live thing.** One sentence tying the client's own material to the direction's device,
   checked against the nearest competitor, and one thing the visitor can do on the page.
4. **Build** from the sheet, words first, full content. When the project has no photographs of its
   own, `scripts/assets.mjs` finds real pictures of the subject's world in open collections (its
   street in old postcards, old maps, its materials, engravings of its trade, museum objects of its
   kind), downloads them into the project and records their credits; the subject is drawn in code
   only where no honest picture exists.
5. **Gate.** `scripts/lint.mjs` (source) and `scripts/inspect.mjs` (real browser, 1440 / 768 / 390)
   measure the house rules: headlines of two lines or fewer, no eyebrow labels, no monospace, no
   01/02/03, no thin rules, no gradients, no resting shadows, no cream-and-terracotta, no violet, and
   the faults blind jurors punished: no focal point, empty screens, text covered or cut, long pins,
   sideways rows, faded text.
6. **Look.** The inspector saves screenshots; the agent critiques them with an Awwwards-weighted
   rubric, fixes the top three problems and repeats, at most three rounds.

Five rounds of blind tests against a no-skill baseline and another design skill are recorded in
`references/research.md` §11.

## Contents

```
SKILL.md                 the run, the house rules, the laws
references/              copy, type, colour, layout, imagery, motion, critique, tells, research
scripts/draw.mjs         deal options / a direction / reprint / redraw; --alternatives, --photos, --site
scripts/assets.mjs       search open image collections (Openverse, Wikimedia Commons, Art Institute of
                         Chicago, the Met) and, with --video, real footage (Wikimedia Commons, Internet
                         Archive, NASA); --get downloads with the credit, --credits prints them
scripts/lint.mjs         static scan for banned patterns and stock phrases
scripts/inspect.mjs      render, measure, photograph
scripts/lib/             zero-dependency Chrome DevTools client, the in-page probe, the rules
library/                 directions (20), heroes, sections, type systems, palettes, photographs,
                         studied sites, motions
assets/base.css, base.js an optional neutral foundation
evals/evals.json         six briefs for testing the skill against a no-skill baseline
```

## Requirements

Node 22 or newer and an installed Chrome, Edge or Chromium for `inspect.mjs` (it drives the browser
over the DevTools protocol with Node's built-in WebSocket; nothing is installed). `draw.mjs`,
`lint.mjs` and `assets.mjs` run on Node 18+; `assets.mjs` needs network access and no API keys. Set
`CHROME_PATH` if the browser is somewhere unusual.

## Install

Copy this folder to your skills directory, for example `~/.claude/skills/design-taste-frontend/`, or keep it in
a repository under `.claude/skills/`.

## Notes

- Photographs in the library are served by Lorem Picsum from Unsplash, under the Unsplash licence.
  They are placeholders for atmosphere; replace them with the project's own pictures.
- Pictures found with `assets.mjs` keep their own licences. By default it offers only CC0, public
  domain, CC BY and CC BY-SA, and `--get` records the title, creator, licence and source of every
  download in `assets/credits.json`. The page must carry the credits (`--credits` prints them).
- `assets.mjs --video` finds short clips of the subject's world, takes a 720p-or-smaller rendition,
  refuses files over 25 MB unless `--max-mb` is passed, and saves a poster frame with each clip; how
  to use one on a page (muted loop, poster, stopped under reduced motion) is in
  `references/imagery.md` §11.
- Fonts are loaded from Google Fonts and Fontshare. Check each face's licence for your use.
- The scores in `references/research.md` are model judgements from screenshots, not Awwwards scores.
- The draw history lives in `~/.taste-skill/history.json` (set `TASTE_SKILL_HISTORY` to move it).
  Delete it to reset.
- A build takes an agent roughly 30–45 minutes and a few hundred thousand tokens with the full
  look-and-fix loop; most of that is finding or drawing pictures when a project has no photographs.
references/app-screens.md›
# App screens

Dashboards, admin, analytics, settings, inboxes, wallets and builders: the screens people work in.
Read this file whole before you start one. The page run in `SKILL.md` does not apply to them.

A generated dashboard is the average of every dashboard template: a dark or tinted sidebar with a
coloured pill on the active item, "Welcome back" and a waving hand, four KPI cards each with an icon
in a tinted circle, a green "+12.5%" and a sparkline, a gradient area chart, a zebra table under
uppercase headers. Nothing in it is wrong and none of it was decided.

This chapter is distilled from a study of 23 real product screens chosen by the owner: 17 products,
17 light screens and 6 dark, all at desktop width (1440 or 1512px). Each screen was measured from its
pixels and noted for what works and what fails; the numbers below are those measurements and their
medians. The products are not named. What separates these screens from the generated kind, in order
of how much it shows:

1. **Colour goes on data, not chrome.** The median screen has about 3% coloured pixels; 17 of 23 stay
   at 5% or less. 12 have no filled primary button at all.
2. **KPIs are type, not tiles.** No screen puts an icon in a coloured circle on a KPI, and none draws a
   sparkline on one.
3. **Surfaces are flat.** 17 of 23 give their cards no shadow; separation is a 1px line or one tone
   step.
4. **There is no hero header.** 9 screens have no heading over 16px; where there is one, its median is
   24px.
5. **Charts carry almost no chrome.** A single series has no legend; gridlines are 3-7 faint lines or
   none.
6. **Navigation recedes.** The active item is a neutral grey pill or a change of text colour; a
   coloured left bar appears once.
7. **Tables are rules and alignment.** No zebra striping, no header bands, numbers right-aligned.
8. **Data states are honest.** Zeros print as zeros, missing values as a grey dash, and an empty
   section keeps its frame.

Taking the template's devices away is half the job, as it is on a page. The other half is substance:
the product's real data, a screen that answers its one question first, and a grid whose arithmetic
closes.

**A warning learned in testing.** In the first blind round, screens built with this chapter were
clean, quiet and correct, and they lost to richer screens built without it. The judges' reasons were
the same every time, and each is now a rule below:

- **The first screen was thin.** One large chart of a flat line filled it, and half the blocks the
  brief asked for started below the fold. On a 1440×900 screen, every block the brief names shows its
  header and its first rows (§2.2).
- **The product was missing.** No name, no workspace, no mark: the screen could have belonged to
  anyone. The shell names the product and the scope (§3.2).
- **Restraint hid meaning.** A latency rise and a request rise were the same grey. Colour on a change
  says good or bad, not up or down (§5).
- **There was nothing to do.** No control for the job the screen exists for. The most frequent job is
  one click from the first screen (§2.2).
- **Rows ended ragged** and **a style's move was over-applied** (a tray around every card, an icon
  rail where people needed labels). Keep the move to where it carries meaning.

Quiet is the starting point, not the goal. A screen that a person would trust shows more of the
product than a template does: its people, its objects, its numbers and the one thing to do next.

**Give the product a face.** After four rounds the judges' top complaint was the same on most
screens: "a well-applied kit, not a product with its own identity" (stock Geist or Inter, a #f8f8f8
ground, white 12px cards, lime on black). The style's tokens make a screen correct; these make it
this product's, and choosing them is a step of the run, not a finish (§2.3):

- **One face from the sheet, chosen before you build.** Each style lists two or three ways it takes a
  face from the brief: a ground tinted from the product's world, a figure face with character, the
  brand's hue on the main data element. Choose one, write it in your notes next to the screen's
  question, and check that it shows on the first screenshot.
- **The dealt typeface, really loaded.** A build that fell back to the default face landed on the
  default look; the inspector now fails it.
- **The product's mark and name** at the top of the shell, drawn for this product (a simple
  monogram or symbol in the accent, from its name or its object), never a black square with a letter.
- **One element only this product has**, built from its own things: an energy home's power flow, a
  bakery's sell-out strip, a leave planner's cover against the minimum, the pace groups of a running
  club as a unit chart, the couple as three lanes, a deploy's commit as the hero of the overview. The
  sheet lists the library's signature elements whose words appear in the brief; otherwise invent one.
  Name it in your notes next to the screen's question.
- **The one chart hue on the main data element**: today's line, the booked bars, the current period,
  the margin that breaks. A screen whose only hue is a delta reads as a kit, and the inspector warns
  when a screen with charts carries no hue on its data (`no-data-hue`).
- **Its people and objects as themselves**: each person gets a stable initial and a pale tint of their
  own hue, with people listed next to each other at least 60 degrees apart. These tints are identity,
  not data: they stay pale and out of the chart legends. Products show their thumbnails, cover art
  shows the cover.
- **Words of the trade** in labels and empty states ("Waiting on the customer", "Off sick", "Due
  on the 28th"), never the generic noun of a template ("Items", "Records", "Status").

**Calendars and timelines** (house rule, from testing; the study has none). People down the left on a
fixed 160-220px column, grouped under a team subheader rather than repeating the team on every row;
days across with faint 1px day lines, weekends one tone step darker, and today marked by a 1px line
in the one data hue with its date in ink. Bars are 20-24px tall at radius 4. One-line names sit on a
32px pitch; a name with a subline (team, office) needs 40-44px, or the subline touches the next name.
**Every kind of booking has its own mark**, so sickness reads apart from a holiday at a glance:
holiday a pale fill, off sick a 1px diagonal hatch, long leave a darker grey step, working away an
outline, a pending request a dashed outline. A style with a hue budget may add a 3px left edge in the
hue for the kind the screen is about. Full colour is only for what needs a decision (a request that
breaks cover). A label sits inside its bar; a bar too short for it shows the kind's code inside and
the full label in its tooltip, or the label after the bar's end, never over the days before it.
Holiday markers name their office in a multi-office product. Every swatch in the legend is the mark
itself, scaled down, and lists only kinds on screen: `Charts.timeline(el, rows, kinds)` draws the bars
and returns that legend from the same kinds map.

## Contents

1. When this applies
2. The run for an app screen
3. The shell
4. The canvas and the page title
5. KPIs
6. Charts
7. Tables and lists
8. Forms and settings
9. Type and colour
10. Density
11. Empty, zero, loading and error states
12. The phone
13. House rules for app screens
14. Tells of generated dashboards

## 1. When this applies

An app screen is worked in rather than read. This chapter covers:

- dashboards and overviews: a seller's day, a project's health, a publication's growth;
- admin and back office: members, orders, events, a catalogue;
- analytics and reports, including a generated report with no app around it;
- settings and billing;
- list screens to work through: inboxes, queues, tables with filters;
- wallets and finance homes;
- builders and editors: flow, form and page builders, canvases;
- the detail page of one object: an event, a deployment, a customer.

A product site that shows its interface is a page; the interface fragments on it follow these rules
for their insides (mark each fragment's root with `data-ui`). When a request holds both, build each
with its own run and share one palette and one typeface.

The study is 23 desktop screens. It has no phone screens, no loading or error states, one settings
page, no inbox and no dense operations table (the densest screen shows about 120 data points). Where a
rule below goes past the study, it says **house rule**.

## 2. The run for an app screen

Do these in order. The first three steps decide whether the screen is any good.

### 2.1 Inventory the data and the jobs

The page run inventories the subject's world; an app screen inventories its data. Write in your notes,
in 8 to 12 lines:

- **the objects** the screen shows (deployments, invoices, members, events, conversations) and their
  fields, with units;
- **realistic magnitudes**: how many objects, a typical amount, the range of a rate, how values move
  over a day, a month, a year, and for a new account how small they really are;
- **the people**: who opens this screen, how often (a glance once a day, or an eight-hour session),
  on what device;
- **the jobs**: what they do next from here (approve, reply, pay, ship, invite), and which job comes
  up most;
- **the product's own things**: its name and mark, the workspace or account it is scoped to, the people
  in it (two partners, a team of six, the club's members), and the objects people recognise by sight
  (product photographs in a shop, cover art in a media tool, a map of the route). These are what make
  a screen belong to this product instead of to a template; plan where each one appears.

Then write the data itself: a small dataset in a JSON or JS file that the screen renders from, with
believable names, amounts that add up to their totals, dates in order and a unit on every figure.
Totals are computed from the rows, never typed in separately. Small numbers stay small: the studied
screens show new accounts with 1, 6 or 9 of something, drawn honestly at their real size. Write the
figures that must agree as assertions in the data file (`console.assert(sold - cancelled === rsvps)`,
the bins sum to the total, the parts to the whole) and read the console: in testing, a ticket table
that did not reconcile with its own KPI cost a judge's trust in the whole screen. Checks that need
computed values (allowance after a request, cover per day) go in the script that computes them.

**Data that tells the truth** (house rules, from testing; judges checked every one of these):

- **A fact shown twice is one variable.** A time window, a total or a state that appears in two cards
  (the battery full 13:30-16:30 in the KPI label, the shaded span and the battery card) is one value in
  the data file, and the copy and the chart annotation are both built from it. Two cards that
  disagree by half an hour cost more trust than a missing card.
- **Totals and deltas come from the parts as shown.** Sum the rounded pennies the screen displays,
  never round the unrounded total separately: £21.68 + £8.34 − £3.81 is £26.21, and a screen that
  says £26.22 is caught. A change is the difference of the two values on screen (£5.28 against
  £5.22 is +£0.06, not +£0.07).
- **Every relative figure names its baseline** in the same phrase: "3 h 40 m before close", "45 m
  earlier than last Sunday", "+5 on the usual Monday". "Early", "up" or a bare "+5" next to another
  time or figure reads as measured against that one.
- **One name per time.** Shops close at 15:00 and the tills are cashed up at 15:12; the header and
  the card use the same words for the same moment, and an error state rewrites the status line.
- **If a caption has to say which bars are which, encode it in the chart instead**: a marked period in
  a second tone with its own legend entry, a signed axis, a label on the mark.

### 2.2 Name the screen's one question

Every screen answers one question first: "Did today's sales come in?", "Which deploy broke?", "Who is
waiting for a reply?", "How much of this month's plan is left?". Write it in one line. It decides
three things:

- **what is biggest.** In the study the largest block is always the one that carries the screen's
  question, whatever form it takes (a chart, a plan, a ring, a list). The same holds for figures:
  the largest figure on the screen answers the question the title row's sentence answers. In testing,
  an 88% sun ratio at 32px out-shouted the 24px charging window the title led with;
- **what comes first** in the reading order;
- **what is left out.** The studied settings page shows three usage numbers and links to the
  analytics screen instead of drawing a chart; four screens draw no chart at all because a few
  numbers say it.

Then name the screen's **one finding**: the fact in today's data that most needs attention ("9 no-shows
while 8 people waited for a place", "the 23:28 deploy doubled the error rate", "eating out is £36 over").
Put it where it is read first, as the line under the title or the subline of the headline figure,
next to the action it drives. In testing, the finding that mattered sat in a 14px caption at the
bottom of a card and the screen read as flat.

**Do not rebuild a studied screen.** When the product is of the same kind as a screen in the study (a
finance home, a deploy overview), its arrangement is the trap: judges recognised the copies at once.
Take the structure from the product's own things instead: for a couple's budget, Mia, Joint and Tom
as three columns running through accounts, plan and bills; for a running club, the pace groups.

Then list the screen's blocks as the answers to its next questions, one line each, the way the page
run lists claims. A block with no question is deleted. This is how you avoid the stock order of KPI
row, big chart, two small charts, table.

Three more lines, each tested:

- **Everything the brief names is on the first screen.** At 1440×900, every block the brief asks for
  shows its header and its first rows or its figure. Size the headline block to the question (about
  200-360px tall), cap long lists at 4-6 rows with "View all", and put time-sensitive blocks (bills
  due, SLAs running out, a failed deploy) before long tables. A chart whose line is flat for most of
  the window does not get the full width: give it two-thirds and put the next answer beside it.
- **The most frequent job is one click away.** Name it ("pack the orders due today", "reply to the
  oldest breach", "redeploy the failed build") and put its control where the answer shows: a primary
  button in the title row, an action on the row that needs it, a "View logs" on the failed item. A
  screen with no control for its main job reads as a report, not as a tool.
- **Show the evidence beside the answer.** A judge trusts "Error rate 0.28%" more with its spike
  annotated ("peaked at 3.9% after the 23:28 deploy, reverted") than with the number alone. One line
  of explanation on the event that matters is worth more than a second chart.

### 2.3 Choose a style

```bash
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<one line about the product>" \
  --screens <screens> [--theme light|dark] [--keep palette,type] --options 3
```

This deals up to three styles from `library/app.json` (fewer when only two suit the kind and the
theme). `draw.mjs --app --list kinds` (or `styles`, `screens` or `signatures`) prints the choices; the
screens are `overview`, `analytics`, `list`, `detail`, `settings`, `wallet`,
`builder`, `inbox`, `home` and `report`. The library holds seven styles, each distilled from the
studied screens that share its move:

| Style | Theme | For | The move |
| --- | --- | --- | --- |
| `achromatic-ledger` | light | money homes, reports, settings with usage | No colour in the chrome; charts in ink; one small signal where a figure changed |
| `framed-sheet` | light | B2B analytics, developer platforms, builders | One tinted L-frame; the page on a white sheet inset 8px, no shadow |
| `accent-ladder` | light | creator and small-business admin with plans | The full hue on small marks only; its pale tints on every area |
| `editorial-greeting` | light | team and personal homes | The greeting as the h1 in a centred 12-column column; every figure at regular weight |
| `pastel-consumer` | light | wallets, budgeting | Pale same-hue tiles under one large radius; quiet money polarity |
| `true-black-flat` | dark | community, events, media | Elevation by lightness alone; colour only on data |
| `data-terminal` | dark | markets, monitoring | Every selection is the same 1px accent line; signs, not colour, on numbers |

Choose by how the product is used (long sessions or a quick look, money or media, a team or one
person, day or night), not by what is most familiar, and write one line on why. Then deal the sheet:

```bash
node <skill>/scripts/draw.mjs --app <the same arguments, without --options> --style <id>
```

The sheet gives the style's tokens and type, its shell and one pattern for each screen you named.
Keep the style's one move intact and take every other value from the defaults in this file. Redraw
(`--redraw style | shell | screen:<name>`) only for a reason you can state: it contradicts the brand,
or the product is used in a way the style does not suit. With an existing brand, pass
`--keep palette,type`, map its colours onto the roles in §9 and keep the style's colour budget.

**Then choose its face, before any markup.** The sheet's "Its face" list gives two or three ways this
style takes a face from the brief, and "Signature elements" lists the library's elements whose words
appear in the brief (a power flow for an energy home, a sell-out strip for a bakery, a cover matrix
and a kind-encoded timeline for a leave planner). Write in your notes, next to the screen's question:
the face you chose, the signature element (from the sheet, or your own built from the product's
things), and where each appears on the first screen. A style dealt to two products must not give them
the same face: in four rounds, the screens that skipped this step were scored as the same kit.

### 2.4 Build the shell

Build the frame first, empty: the sidebar or rail, the top bar if the shell has one, the canvas with
its gutters, the title row (§3 and §4). `<skill>/assets/app.css` is a neutral start for all of it:
copy it into the project and put the sheet's tokens in its `:root`. Look at it at 1440 and at 390 before any data goes in. Do the
grid arithmetic now and write it in a comment: the column count, the gutter, and the sum that proves
the cards land on the canvas edges.

### 2.5 Put in the data

Render every block from the dataset; draw the charts from it too, with `<skill>/assets/charts.js`
(`Charts.line`, `Charts.bars`, `Charts.diverging`, `Charts.ring`, `Charts.barList`, `Charts.plan`,
`Charts.bullet`, `Charts.timeline`, `Charts.matrix`, `Charts.timeStrip`; the file's header lists their
options) or your own SVG to the rules of §6, never a chart library's defaults and never a picture of a
chart. The newer helpers return `{ svg, legend }`: put that legend in the card's header rather than
drawing your own, so each swatch is the mark it explains. Format every figure by §7.4: separators, fixed decimals per
metric, a sign on every change, units as smaller grey suffixes, tabular figures, right alignment in
columns. Use the product's own words for labels. No placeholder data: the inspector fails lorem ipsum,
Acme, John Doe and their kind. Proof is not invented here either: no vanity totals, no "+12%" on an
account that is a week old.

### 2.6 Build the states

For every block, build its empty, zero, loading and error state (§11); for every row and control, its
hover, selected, focus and disabled state. Look at each one in the browser. A screen that only works
with full data is half built.

### 2.7 The check

```bash
node <skill>/scripts/lint.mjs --app <source files or folder>
node <skill>/scripts/inspect.mjs --app <url or path/to/index.html>
```

A sheet dealt with `--app` switches both tools to the app rules by itself. The inspector opens the
screen at 1440, 768 and 390 and measures what this chapter asks for: an h1 (and reports it as the
screen's hero); a sidebar that folds on the phone; the canvas used to its right edge; panel shadows
and borders; the number of corner radii; cards in one row that end apart, where a column of cards
stacked with the gutter counts as one block (`ragged-row`); a blank band inside a card of over 40px,
or over 64px before a new section's heading (`panel-void`); icons in tinted squares; numeric alignment and tabular figures; chart hues; colour
share, and a screen with charts but no hue on its data (`no-data-hue`); placeholder data; text under
11px; and the page rules that still hold (contrast, gradients, the banned palettes, emoji). Fix every
`FAIL`. Treat each `WARN` as wrong unless you can defend it to a designer in one sentence.

Then look at the screenshots, every width, and answer these in writing. This is the critique for an
app screen: the caps in `critique.md` for pictures, motion and the page's live element are page rules
and do not apply here.

1. Is the screen's question answered by the biggest thing on it, and is that read first? Is the
   largest figure the one that answers the title row's sentence?
2. Does every block share the screen's edges? Is there a dead strip on one side, a card stopping
   short, an orphan cell, a stretched card with a blank band (a legend pinned to its foot, four rows
   in a card kept at its neighbour's height)?
3. Is colour only on data, on status and on the one primary action, and does the main data element
   carry the one hue? Does the face you chose show on the first screen?
4. Do the numbers add up, and do they line up digit for digit? List every fact that appears twice
   and check that the copies agree; recompute each total and change from the values as displayed;
   check that every relative figure names what it is against.
5. Does every zero look like a zero and every missing value like a dash? Does every count badge sit
   on a queue that needs action, with its noun clear from the label?
6. Does every legend swatch match the mark it explains, and does any caption have to say which bars
   are which (then encode it in the chart)? Does any label collide with a line or another label?
7. On the phone, is the navigation folded away, and does each block still answer its question?

Fix the worst three, inspect again, look again. Three rounds at most. Judge the pictures: if a problem
shows in a screenshot, it is real, whatever the code says.

### 2.8 Report

Tell the user, briefly: the style dealt (name and seed) and any redraws with their reasons; the face
you chose and the signature element; each screen's one question; any check you allowed and why (for example `--allow icon-chips` for node-type
tiles in a builder); and the sample data that must be replaced with real data, listed so it can be
found.

App screens have no motion floor. Motion is feedback: press, hover, focus, a menu or a sheet opening,
a row arriving. Nothing on the screen performs an entrance (house rule).

## 3. The shell

### 3.1 Five archetypes

| Shell | In the study | What it is |
| --- | --- | --- |
| **Open plane** | 5 screens | A labelled sidebar on the page ground (or within 1-2% of it), separated by nothing or by one 1px hairline. No top bar: the header and the cards sit on the ground. The canvas is fluid with a 24-64px gutter, or a centred column. |
| **Tinted frame, white sheet** | 8 screens | Sidebar and top bar share one tinted ground (#f6f6f6-#f9fbfc) with no rule between them; the page sits on a white sheet inset 8px from the window's right and bottom edges, radius 10-16 (median 16), a 1px #eaeaea-#ededed edge and no shadow. Variants: a flush L-frame, the inset sheet, and floating panels inset 8-12px with a 12px gap. |
| **Ruled T-frame** | 4 screens | A 210-240px sidebar and a 64px top bar on one ground, divided only by 1px rules. The workspace switcher sits at the top bar's right end behind a full-height divider. Three of the four are dark. |
| **Icon rail** | 3 screens | An unlabelled rail 50-60px wide (median 55); 15-18px icons on a 34-49px pitch; the active item marked by colour or by a 2% tint band; groups split by 20px hairlines. It saves about 180px of width. |
| **No sidebar** | 3 screens | A 46-48px top bar over a centred 1088-1208px column with 152-212px gutters; the logo sits on the column's left edge. For reports, viewers and products with few destinations. |

All six dark screens use one ground everywhere, divided by 1px rules of #161616-#232323 or by nothing.
No screen in the study puts a dark or saturated sidebar against a light canvas (0 of 17).

### 3.2 Sidebar

- **Width.** Labelled sidebars run 210-292px; the median is 230 and most sit at 224-240. One floating
  sidebar card appears (280px wide, 40px in from the window edge).
- **Rows.** A 32px hit area on a 34px pitch is the mode (7 screens); the roomy versions run 40-52px on
  a 42-60px pitch. Icons are 14-20px, median 16, outline at about 1.5px stroke on 14 of 16 screens.
  Labels are 14px at 400 on 12 of 16, in ink or in a grey of #4b4d4f-#63666e, about 8-10px after the
  icon, with the label column at x 36-48.
- **Active item.** A neutral grey pill with the label unchanged, on 8 screens: radius 4-16 (median 8),
  inset 8-14px from the sidebar's edges, a fill of about #e9e9e9-#ededed. On dark, brightness alone:
  the label goes from #bbb to #fff (3 screens). Colour alone on the icon or label (3, all of them
  rails or a floating card). A tinted accent fill (3). A left accent bar once in 23. The weight
  changes on 2. Default: the grey pill on light, brightness on dark.
- **Groups.** Sentence-case grey labels at 12-13px, never uppercase (9 screens), with 22-56px of air
  above and 13-33px to the first item; or hairlines instead (4). Utilities such as settings and help
  pushed to the bottom (5).
- **Top and bottom.** The workspace or account switcher at the top of the sidebar (7 screens), at its
  bottom (2), or in the top bar. The bottom holds a user row (8) or one upsell (8); one of those
  upsells is a filled accent button.
- **The product is named** (house rule, from testing). The product's mark and name sit at the top of
  the sidebar or rail, and the scope (workspace, team, project, account) is shown as a switcher or a
  breadcrumb. A screen whose product cannot be named from the screenshot reads as a template.
- **Counts on nav items** (house rule, from testing). A count appears only on a queue that needs
  action, and its noun is clear from the label: "Requests 5", "Price changes 3". A bare "Range and
  prices 9" left the judge asking nine of what.
- **Labels by default.** A labelled sidebar unless the product has five destinations or fewer and the
  screen needs the width (a research or monitoring view); an icon rail always carries the product's
  mark at the top, a tooltip and an accessible name on every icon. Nav items are other screens, never
  anchors into the current page (a sidebar that scroll-spies one long page was marked down twice).

### 3.3 Top bar

14 of 23 screens have one; the default is none. Heights fall into two groups: 44-51px (median about
48) on light chrome, and 64px, usually with a 1px rule. A top bar must hold something real: search,
the workspace switcher, or the page or object name. Search sits centred on the window at 548x32 with
a shortcut hint (2 screens), or at 457-537x36 in a no-sidebar bar. The right cluster is inset 24-46px
from the edge. Two screens leave about 1070px of their top bar blank; both are flagged for it.

### 3.4 Right rail and pinned columns

One screen has a right rail: a fixed 384px column in a `1fr 384px` grid with a 24px gap, starting
level with the first card rather than with the title, its section headings outside the cards and 8px
above each. Use one only for a stable secondary stream: recent items, the details of the selected
object, share links. A pinned left column appears once: a 306px accounts card at the viewport height
minus 72px, so net worth stays in view while the rest of the screen scrolls.

Five screens float a button (24-56px), and one of those buttons covers the content it sits on and is
flagged. House rule: no floating button unless it is the screen's one primary action and covers
nothing at any width.

## 4. The canvas and the page title

### 4.1 Width and gutters

- **Fluid** (14 screens): side padding 17-64px, median 29, with 24 and 36-40 the common values;
  content 1120-1312px wide, median about 1204.
- **Centred** (8 screens): 624, 864, 1088, 1100, 1152, about 1190 and 1208px; median about 1126.
  Data pages land at 1100-1250 either way. Only two personal homes go narrow (624 and 864), and both
  are flagged for wasting the width.
- **Top offset**, from the canvas top to the title: 20-88px, median about 36. Tools sit at 20-36;
  greeting homes at 50-88.
- **One gutter.** Use one gap value horizontally and vertically. Mixed gutters (12 against 16, 20
  against 24, 16 against 12) are flagged on 8 screens.

### 4.2 One left edge, one right edge

The strongest screens run a single left edge from the header to the last row. One indents its page
header by the cards' 24px padding, so the title, the card titles, the numbers and the chart all start
on one line. Row rules may bleed 7-8px past the text column while the text stays on it.

The grid's arithmetic closes: 3 x 372 + 2 x 17 = 2 x 568 + 16 = 1152; 12 x 77 + 11 x 16 = 1100;
4 x 144 + 3 x 16 = 624; a second-row card exactly as wide as the two cards above it
(251 + 24 + 515). Write your sum down.

Failures to avoid, all from the study: cards stopping 120px short of the sheet's right edge; a 0px
right gutter against a 40px left one; a column centred on the window instead of on the area beside
the sidebar (204px on its left, 444px on its right); a medallion 108px off the column's axis. The
inspector warns when the content stops well short of the right edge.

**Dead space from a grid that does not fit the data** is the commonest failure in the study (10
screens): four KPI tiles in a three-column grid, orphaning the fourth; one card alone in a
three-column row beside 810px of nothing; a card stretched to its neighbour's height with 250px of
blank under its legend. Choose the column count from the number of items, let a lone card span, and
do not stretch a card's frame over empty space.

**Cards that share a row share a bottom edge.** In testing, rows whose cards stopped 26-190px apart
were marked down every time, by judges and by the inspector (`ragged-row`). Balance the row by its
content first: move a list's rows so the two columns hold the same number, cap both lists at the same
count, or pair blocks of similar height. Two cards stacked in one column beside a tall card count as
one block: they balance when the column ends level. Only when the contents cannot balance, stack the
two blocks, or let the shorter card fill the row height with its content top-aligned and the footer
action (a "View all", a total) pinned to its bottom so the extra height is not a hole.

**A stretched card stretches only when it has content to fill.** Only an action or a total may be
pinned to a stretched card's foot; a legend or a note stays directly under its chart. In testing, a
cover card stretched to its neighbour with its legend pinned to the bottom left 60px of blank under
the last row, and a sell-out card left 45px above its links. When the content is short, let the card
keep its own height (`align-items: start` on the row), move its footer link into its header, or give
the row a block of similar height. A filtered block shrinks to its rows, or keeps a fixed number of
rows with one sentence in the empty ones; four rows over 270px of empty card is a hole. The inspector
warns on a blank band of over 40px inside a card (`panel-void`).

**Narrow blocks do not span the canvas.** A table of five columns or fewer, or a list of six rows or
fewer, runs to about 760px at 1440 and pairs with the next block; stretched to 1300px it leaves
400-600px voids between a name and its first figure.

### 4.3 The title

- **Every screen has one h1**; the inspector fails a screen without one. 14 screens set it in the
  canvas at 18-48px, median and mode 24, at 600-700 (700 on 8). The rest have nothing over 16px: a
  breadcrumb or the top bar names the page. Then the breadcrumb's last item is the h1, at 16px, and
  the screen's name is still the first thing read.
- **A noun, not a greeting**: "Analytics", "Overview", the project's or the object's name. 3 of 23
  greet, and one adds an emoji, which goes. A greeting suits a personal or team home only.
- **An optional second line**: one 14-16px grey sentence about the data or the object (the finding,
  the period, the scope), never about the interface ("the filters narrow every block…", "this page
  shows…" read as instructions, not a product), or a 12px line
  naming the data's source.
- **Right of the title**: actions only when they are real (10 screens); otherwise nothing. A freshness
  stamp ("Updated Jul 27, 4:09 PM", with a refresh button) on 6.
- **The primary action.** 12 of 23 have no filled primary button; 5 make it near-black (#1d1f20,
  #1f2937) or inverted white on dark; 4 fill it with the brand hue. One primary per screen.

### 4.4 Tabs, filters and the date range

- **The date range** at page level (7 screens) or on each card (7), never both. A card that states its
  own period puts it in its header, right-aligned, in grey.
- **Underline tabs** (8 screens) always sit on a full-width 1px rule; the indicator is 1-4px (median
  2), as wide as the label or running 8-11px past it.
- **A segmented track** once: 48px, #f3f4f6, a white 36px active pill, and the date select inside its
  right end, so tabs and range read as one row. A segmented control is as wide as its options, and full
  width only below about 480px: three options spread across a 680px tablet card read as a progress bar.
- **Control height** in title and filter rows: 28-40px, median 34, clustering at 32 and 36.
- **Order**: scope on the left (the date first, then the filters), utility and refresh on the right.
- **Compound filters** that read as a sentence ("Date | Jun 14 - Jul 13 | Daily"), 28px tall and 8px
  apart, once.
- **To the first card**: 13-64px, median 24.

## 5. KPIs

- **No tile of its own.** 11 of the 20 screens with headline numbers give a KPI no tile: bare text on
  the page or on a card (5), or one container split by 1px hairlines (6). 9 use one card per KPI,
  61-147px tall (median about 108). The default is one container split by 1px dividers into equal
  columns. 7 screens have no KPI row at all and put each headline number in the card that explains
  it, which is often better.
- **Count.** 1-6 per row, median 3-4. The count matches the grid: four KPIs get four columns.
- **Size.** Values 16-40px, median 26, mode 24. Values in their own tile run larger (median 30) than
  in a shared strip (median 24). Nothing at 48px or over. Labels 12-16px, median 14; the value is
  about 1.7 times the label.
- **Weight.** An even split: 400 on 8 screens, 600 on 6, 700-800 on 6. A large number at regular
  weight under a semibold label is the common pattern. Bold for money only.
- **Order.** Label above the value on 14 screens; labels grey (#6b6b6b-#aaa) on 13; sentence case.
- **Nothing else.** No icon in a coloured circle (0 of 23), no sparkline (0 of 23), no tinted tile.
  An info icon beside the label is fine (5 screens). Units are a smaller grey suffix: "5 mins" at 24
  over 14, "2 m 24 s", "5 /10".
- **The answer figure leads.** When one figure is the screen's answer (a balance, net worth, the
  portfolio's value), it is 30-36px and at least 1.3 times its sibling figures (those at 18-24px). In
  testing, a 24px portfolio value beside two 24px figures left the screen without a lead. No other
  figure on the screen is larger: the biggest number answers the title row's sentence, so a
  secondary ratio in another card stays at the siblings' size.
- **One figure, one place.** A figure shown in the strip is not repeated in a legend beside it, and a
  KPI strip whose every cell names a panel below (RSVPs, then an RSVPs panel) is dropped: lead each
  panel with its own number instead.
- **A second job.** KPIs may be the chart's series tabs or the list's type filter (the selected card
  takes a 1.5px border). As tabs, the selected one carries a 2px rule or the active fill and an ink
  label; the others keep a grey label and their value in ink, at 400 against the selected one's 500.
  A grey value reads as disabled: in testing, three shops' takings set in grey because they were tabs
  read as secondary, though they were the brief's first ask. When the tabs are the per-entity
  breakdown the brief asks for, grey nothing and let the rule alone mark the selection. On a phone the
  tabs become a 2-up grid with the selected cell filled, or a row that scrolls sideways; never an
  underline floating inside the grid (`app.css` does both).

### Deltas

- Optional: 9 of the 20 KPI screens show one; 11 do not.
- 10-15px (median 14), never larger than the label. Text or a small glyph on 6 screens; a pale pill
  on 3.
- **The window in words**: "vs prior 30 days", "in the last 90 days", "from 0 (last 4 weeks)" (7 of
  the 9).
- **Colour only the number**, or set the whole delta in grey.
- **Colour says good or bad, not up or down** (house rule, from testing). Every metric has a good
  direction: latency, error rate, cost, churn and time-to-reply are better when they fall. A change
  in the bad direction puts `--negative` on the number (or a small down-pointing glyph in that
  colour); a change in the good direction is `--positive` or grey. A judge read a 2.5× rise in errors
  set in the same grey as a rise in visits as hiding the screen's only bad news.
- **Zero or undefined change is grey.** The study's commonest KPI failure is a zero or undefined change
  shown as a signal: three identical green "up 100%" pills grown from zero, a ">999%", a bold red
  "0.00%", a green "+0". When the prior period was zero, write "+4 (new)"; when there is nothing to
  compare, a short grey dash where the delta would be.

## 6. Charts

### 6.1 Which chart for which question

| Question | Form | In the study |
| --- | --- | --- |
| Is it moving over time? | One line; an area only with a fill of 15% or less | 9 screens |
| How much per period? | Columns with square corners | 3 |
| How is it distributed? | A histogram with equal bins | 1 (its unequal bins are flagged) |
| Who ranks? | A sorted horizontal bar list, or the row's fill as the bar in a one-figure list | 3 |
| What share? | A thin ring, or a 100% stacked bar with 2px seams; five parts at most | 5 |
| How far to the target? | A progress bar, both ends labelled, the fill the true ratio | 4 |
| How much of the plan is used? | A track as long as the plan, the spent part filled, what is already committed as a lighter segment, an overrun past the track's end in the negative colour (`Charts.plan`) | house rule |
| Has it drifted from its target? | One row per part: the current share as a bar with a 1px tick at the target (`Charts.bullet`); never two stacked bars to compare | house rule |
| Where? | A map with a list beside it; the list carries the magnitudes | 1 |
| Per period, and the running total? | The two side by side at equal width, with the same dates | 1 |
| Which way did it flow, and how much? | Columns above and below one zero line on one scale, the axis signed and captioned on each side ("Sold" above, "Bought" below), a marked period in a second tone with its own legend entry (`Charts.diverging`) | house rule |
| Who is away when? | A timeline of people by day, each kind of booking its own mark (`Charts.timeline`) | house rule |
| Is there enough cover? | A team-by-day matrix of slack against the minimum (+2, +1, 0, −1), one hue ramping toward the negative colour, the headcount on hover (`Charts.matrix`) | house rule |
| When in the day, against last time? | One row per item on a shared opening-to-closing axis: a 2px grey track, an ink dot for today, a hollow tick for the comparison day (`Charts.timeStrip`) | house rule |

Three more, from testing:

- **The pair of per-period bars and the running total is used once per screen**, and only when the
  total crosses a line that matters (capacity, a target). Three such pairs in six charts read as one
  pattern stamped out.
- **A series that is flat except for one event** gets at most two-thirds of the width and about 140px of
  height, or a range capped at about twice its reference line with the off-scale peak labelled
  (`Charts.line({ max })`). A full-width plot that is 85% empty was the biggest block on a first
  screen and was marked down.
- **A forecast is not a fact**: after today the line is dashed and the end value is labelled
  (`Charts.line({ today, endLabel })`), with the gap to the target bracketed when there is one.
- **What the brief asks to see over time is drawn on the first screen.** KPI cells may switch the chart
  only between metrics the brief did not name.

When there are only a few numbers, draw no chart: four screens skip it, and the settings page links
to the analytics screen with "View more".

### 6.2 One question, one series

One series per chart is the norm; three is the most any screen draws. To show another metric, switch
it with a control (a segmented control, a dropdown, the KPIs as tabs) instead of overlaying lines.
Plots run 112-264px tall (median about 195) and 316-1136px wide (median about 570), an aspect of 1.8
to 5.2.

### 6.3 Axes and gridlines

- **Gridlines** run horizontally only on 7 of the 12 cartesian charts, both ways on 3, and not at all
  on 2. Use 3-5 (the study runs 3-7, median 4-5): 1px, solid, about #e5e5e5 on light (range
  #cfcfd2-#f2f2f2), #2b2b2b on #111. Dashed lines are allowed in chart grids (4 screens: 3/3, 4/4,
  3/2.5) and nowhere else on the screen.
- **No y-axis line.** The baseline is the one heavier line (2px on two screens, 1px on one); full axes
  on one.
- **Y ticks**: 4-7, median 5, at 11-16px (median 12) in grey (#5f5f5f-#6c6c6c), on the left (8
  screens) or the right (2). Round, even steps (0/5/10/15%; 0-600 by 150), the top tick above the
  maximum, integers for counts, and zero as the baseline for bars and counts.
- **Never without a scale.** Two screens draw no y-scale and show magnitude only on hover; both are
  flagged. So are uneven ticks (0/3/6/10), a 0.8 tick on a count, a top tick equal to the maximum so
  the line hits the plot's edge, and a cumulative axis that starts at 1.
- **Axis text no larger than the data.** 15-16px ticks beside 14px values are flagged as louder than
  the numbers.
- **X labels**: 4-8 per chart, flat, never rotated (40 degrees is flagged), the first and last pulled
  inside the plot, on one step all the way: a forced closing label after 14:00 on a 90-minute step
  left a short last gap that a judge measured. Choose a step that lands on the end (hourly from 08:00
  to 15:00) or leave the end unlabelled. Labelling only the two ends hides when things happened. Irregular dates on a
  categorical axis are flagged: time goes on a time scale.

### 6.4 Marks

- **Lines** 1-3px, median 2, in straight segments. Four screens smooth their lines, and one smoothed
  curve invents values between daily totals (flagged), so draw straight segments. No point markers,
  except a hover dot (8px on one screen).
- **Fill**: none on 7 of 10 time charts. At most a flat tint of about 14%, or a fade from the line to
  transparent over 150-200px.
- **Bars** have square corners (4 screens), or at most a 4px top radius with a square bottom on the
  baseline. Stacked segments are split by a 1-2px seam.
- **Colour.** One hue per chart. The accent touches only data on 8 screens, and data ink is under 3%
  of the pixels on most. Neutral series are fine and often better: a near-black line that turns pale
  grey after the cursor; a white line against a red one; signs instead of colour ("+$24,000,000").
  But the one hue, when the style has one, goes on the screen's main data element (today's series,
  the booked bars, the current period, the margin that breaks), not on a secondary chart or a delta:
  in testing, an all-ink screen with no hue on any of its data read as a kit.
  One subject keeps one hue across the screen: the same subscribers drawn pink in one chart and indigo
  in the next is flagged. Unrelated hues on one screen (three, five) are flagged every time. Status
  colours are for status. The inspector warns over four hues and fails over six.
- **Legends.** None for a single series (9 charts): the title or the dropdown names it. Otherwise five
  items at most, as a 5-8px dot with a 12-14px grey label in the card's header, or as a small table
  (dot, name, value, change) that makes the legend carry data. Never list a series that has no data
  (flagged). Every swatch is the mark it explains, scaled down: the legend dot is the line's colour,
  a hatched bar's swatch is hatched. A swatch drawn separately from the mark drifted from it in testing
  (a black outline with a red edge explaining a red outline); the helpers return the legend built from
  the same style object as the marks.
- **Rings** only for three to five parts of a whole that are not already listed beside them (a ring
  redrawing the spent, due and left figures of the tile next to it was marked redundant). Never
  labelled on the slices; the total sits in the centre or beside the ring. The
  screens noted for their rings use thin strokes: 3-5px on a 90-154px ring, 8px on 182px. Two others
  use 28 and 37px.
- **Progress bars** are 4-24px tall (median 8), fully rounded, with both ends labelled ("Skilled 200"
  and "Expert 2,000"); the fill is the true ratio, so 303 on that scale fills 22px of 392. A zero has
  no stub (a 6px stub at zero is flagged).
- **Tooltips** are bare text or a plain box without an arrow: label and value on one line.
- **Annotate the event that matters.** A step or a spike more than about three times the usual change
  gets a 1px marker and a 12px label naming its cause ("Deploy a3f09c1", "Newsletter featured");
  reference lines (a budget, an SLO, a start time) are 1px dashed with their label at the end. In
  testing, an unexplained jump was the first thing a judge asked about. `Charts.line`,
  `Charts.bars` and `Charts.diverging` take `annotations` and `reference`.
- **Labels never collide.** Annotation labels sit in rows above the plot, each beside the top of its
  own leader line, and no leader line runs through another label; the rows are reserved only when a
  label exists, so a chart without one has no dead band above it. The "Now" label of a forecast goes
  on whichever side of its line the series does not cross. Lay labels out after the fonts load, or
  the label set changes between loads (it did in testing). The helpers do all of this; give a label
  that may not fit a `short` form.
- **Use the helper, then extend it.** Draw charts with `assets/charts.js` and add what a chart needs to
  it, rather than writing new axis code: in testing, every chart written from scratch on a phone put
  its y labels on top of its line.
- **Short and empty histories are drawn honestly**: a flat zero line with one spike, missing bars for
  empty periods, no padded y-range, no "No data" picture. Do not give one point the screen's largest
  plot, though: a lone dot in a full-size grid reads as barren (flagged once).

## 7. Tables and lists

### 7.1 Rows

- Single-line data rows: 36-53px, median 45.
- Two-line rows with media: 56-92px, median 72. The pitch is the media's height plus 24-32px
  (48 + 24, 48 + 32, 50 + 30).
- Key-value and legend rows inside cards: 21-32px.
- In a two-line cell, line 2 sits about 21px below line 1 (19-23), one size smaller (12-14px) and
  grey. Two-line labels need a 40-44px pitch at least: on a 32px pitch a subline nearly touched the
  next row's name and the list ran together (house rule, from testing).
- Overview lists show 2-5 rows.

### 7.2 Header

- 11-14px, median 12, mostly regular weight, grey, sentence case (9 of the 11 headed tables). The one
  uppercase header in the study is flagged as louder than the data.
- **No fill band** (0 of 11) and **no rule under it** on 8 of 11. Where there is a rule, it is the
  strongest line in the table (#e5e7eb over #f3f4f6 row rules).
- Header row 36-40px.
- Numeric headers are right-aligned and flush with their values; a header that stops 15px short of
  its column is flagged. A header over a control aligns with the control it labels (centred over a
  column of steppers), and a delta beside a stepper gets its own narrow unlabelled column; three
  alignments in one header row were flagged.
- A sort glyph on the active column only.

### 7.3 Dividers

- **No zebra striping** (0 of 23).
- No row dividers on about half the lists; they rely on a 61-92px pitch or on 2-8px gaps between
  filled rows.
- When present: 1px, 3-7% darker than the surface (#ededed-#f4f4f4 on white; #161616 on #000,
  #1b1a1f on #131217).
- Extent: bleeding 8-9px past the text edge; starting at the text column and skipping the media; or
  the full inner width. Pick one per screen.

### 7.4 Figures and alignment

- **Numbers right-aligned**, in tabular figures or a monospace. Every left-aligned numeric column and
  every column of proportional digits in the study is flagged (durations "00:00:12" and "00:00:04"
  that jitter, cents that drift).
- **Fixed decimals per metric**: money 2, percentages 1 or 2, counts none. Thousands separators
  everywhere ("$16,531.54"); abbreviate only in a capacity line ("12.7k of 20.0k"). A sign on every
  change ("-$0.09", "+10,100 (+0.20%)"). No space after the currency sign (one screen's "$ 89.00"
  beside "$15.00" is flagged).
- **The identity column gets the most width**, about a third to a half of the table, and it is never
  the one cut off at desktop width: shorten names editorially first (drop the issuer every row
  shares, the share class, "Ltd") and use the same short name at every width.
- **One right edge per row or card**: every right-aligned figure inside a card or row ends on the same
  padding line (a days-in-stage figure 18px short of the value above it reads as a second edge).
- **Two measures stack in one cell** rather than taking two columns: a count over its 12px grey
  percentage, a date over its time, a money value over the amount in units, both lines on one right
  edge.
- **Dates** in ISO form and right-aligned where rows are compared ("2024-02-15"); in words for
  recency ("Today", "Yesterday", "6 days").
- **IDs** in a monospace at body size, truncated to about 8 characters with an ellipsis and a copy
  icon 12px after.
- Truncate to one line with an ellipsis; never break inside a word.
- **A breakdown in a narrow card** ("Headingley 6 · Chapel Allerton 5 · Meanwood 3") goes in fixed
  mini-columns, or each item is `nowrap` with its separator attached to the next item, so no line ends
  on a dangling "·".
- **An edited cell shows that it changed** (house rule, from testing). In an editable grid a changed
  value carries a mark (a dot, a tinted border), and the column header or a caption names the
  baseline ("vs the usual Monday"); a bare grey "+5" beside a stepper was read as noise.

### 7.5 Ink inside rows

- Only the identity and the key number get the primary ink; everything else is grey ("5" in ink,
  " /10" in grey).
- A missing value is a grey dash; a real zero is printed ("$0", "0.00%", "0% (0)").
- The sign lives in the text, not in the colour: income green with a "+", expenses in ink, no red on
  every negative.
- Link colour on the name cell only, with no underline.

### 7.6 Status pills

- 15-28px tall (median 20), text 9-14px in the study (median 12) at 500; nothing under 11px here. A
  full pill or a 3-5px tag.
- A pale tint with dark text of the same hue, optionally a 1px border one step darker: #dcfae5 with
  #04250e, #ffedea with #831c1e.
- Neutral states are monochrome chips with a glyph ("Public" with a globe).
- A state machine uses one hue at three intensities: grey for off, the full hue for the call to
  action, a dark tint for running. A saturated fill with white text only for that one call to action,
  and only while it is rare: when more than about a fifth of the visible rows carry it, it becomes a
  stripe of black blobs louder than the data (marked down in testing); use the pale pair or a 6px dot
  with an ink label instead.
- **One status mark per row.** A tag and a badge that say the same thing are flagged.
- **A chip is never squeezed.** Tags and pills are `flex: none; white-space: nowrap`: in testing a
  table column collapsed an environment chip to a 12px box with its word spilling out.

### 7.7 Media

16px logos in dense rows; 24-32px avatars; 48-50px in two-line rows. Circles for people, tokens and
companies; a 2-3px radius for artwork and flags; 8px for photographs. One fixed media slot down the
list so the text starts on one edge (a 50px slot with the text 16px after it). Media-to-text gap
10-16px, median 12.

### 7.8 Actions and paging

- **No pagination on an overview** (0 of 23). Cap the rows and link out: "View all" in the section
  header, a 30px outlined button under the list, a full-width 32px bar. State the total ("3 tracks").
- Row actions: none on 9 screens; otherwise one kebab at the far right. Reserve a trailing action
  column only when most rows carry the action; otherwise the action is an ink text action on the
  row's second line and the figures stay flush to the card edge. When a row needs doing
  (a bill to pay, an order to pack, a failed deploy), its action is a visible ghost button or an ink
  text action in one fixed trailing column, at the same pitch for every row; never muted grey text,
  and never stacked under the amount so the rows change height.
- Checkboxes only where bulk actions exist (one screen: 18px, 2px stroke, 3px radius).
- Hover and selected: a 1px outline at 6px radius overhanging the panel by 1px, or a darker border
  with a 1px ring. No hover that changes the fill was seen. In a list that drives a detail pane (an
  inbox, a queue), the selected row takes the `--active` fill of the nav pill instead, so it cannot be
  mistaken for a focus ring or a hover (house rule, from testing).
- Toolbar: one search, 30-36px tall (256x30, 252x36, 316x32).
- **A full list screen** (house rule; the study has none): state the total above the table, keep the
  header row in view while scrolling, and page or load more at the end with the range shown
  ("1-50 of 1,284").

### 7.9 Lists that carry data

- **The row fill is the bar**: the row's rounded background (radius 8) as wide as its share of the
  largest share; 44px rows, 2px gaps. Only in a one-figure ranking list (a label and one value): in a
  row with several columns or two lines, the fill's edge cuts through the words (failed in testing).
  Planned against spent is a separate 6-8px track under the label (`Charts.plan`).
- **Bar list**: a 64px pitch, a label line, then a 10px pill bar with the value 5px after its end; the
  longest bar about 88% of the width; one colour, no track.
- **Inline bars in a list are thin and grey** unless they are the screen's main series: 2-3px at
  most. A ranked list of money in a narrow card carries a 2px proportional bar under each label, grey
  with the top item in ink (`Charts.barList(el, rows, { thin: true })`), so "most costly first" is
  seen and not only read. In testing, six 4px black bars were the loudest marks on the screen.
- **A timeline draws its comparison.** When the brief asks "against last Sunday", the comparison is a
  mark on the same axis (`Charts.timeStrip`: an ink dot for today, a hollow tick for the comparison
  day), not only a line of text.
- **Legend as table**: dot, name, pill, value and change on a 57px pitch.
- **Key-value well**: a tinted well, radius 8, 32px rows, label left, figure right.
- **Feed as row cards**: 63px tall, 8px gaps, radius 14, a fill one step lighter and a border one step
  darker than the panel, a relative date on the right.
- **Tree**: no dividers, 8px of indent per level, ink against grey by level, a pitch of about 30px,
  about 45px between groups.

## 8. Forms and settings

The study holds forms on three screens and one settings page, so this section rests on little. It is
enough for the defaults.

### 8.1 Fields

- **Label above the field**, 14px at 500, 8-10px gap. No floating labels and no placeholder standing
  in for a label.
- **Inputs** 38-40px in forms (radius 8-10); toolbar and filter controls 28-40px, median 32. One
  control height per context.
- Inputs stay white with a 1px #e5e7eb-#e8e8e8 border even on a grey panel; filter and select
  controls take a border one step darker (#cdcfcf-#d1d5db).
- A leading icon at 14px, 13px in from the edge, with the text starting at 38px.
- **Spacing**: 24px from a description to the first label, 16px between fields, 16px to the Save
  button.
- **A joined input and action** (a read-only link and its Copy button) share a border and square
  their inner corners.
- **Toggles** 28x16 to 33x20. The on colour is a semantic green or near-black, not the brand accent.
- **Disabled** controls stay legible and say why they are disabled: the study's disabled Save
  (#cecece on #f4f4f4, about 1.4:1) and its plan-locked panel at 30% opacity are flagged as
  unreadable.

### 8.2 Settings layout

- Each section is a row: a label column (about 450px: a 16/500 title and a 14px grey description)
  beside a panel (about 700px, #fafafa, radius 16, padding 16, no border, no shadow). The title's cap
  top aligns with the panel's first line, 6-8px below the panel's top.
- Sections are split by a full-width 1px rule with 32px above and below.
- A switch row: a 14/500 title, a status badge 8px after it, a 14px grey description, and the toggle
  on the right, 16px from the edge and centred on the title line.
- A plan-gated feature: a 48px tinted strip with a 32px button directly above the locked panel; the
  panel stays readable.
- Narrow containers stack: label, 10px, field, then 26px to the next pair.
- Do not leave the label column empty beside a tall panel (a 250px void is flagged): put the section's
  help or a summary of its current values there, or narrow the column.
- **Destructive actions** (house rule; none in the study) go last, in a section of their own, as a
  text or outlined button in the negative colour, with the consequence in words and a confirmation
  that names the object.
- **One view per area** (house rule, from testing). When settings span several areas (general,
  members, billing, notifications), each sidebar item opens its own view (a hash route is enough in
  a static build), with the area's name as the h1. One long page of every area, with the sidebar
  scrolling to anchors, put the first area the brief named four screens down and was marked down.
- **One save model per view**: every control saves itself and shows a quiet "Saved" stamp, or one
  Save per panel at the panel's bottom right. Never a mix of both on one view.
- **Show what the setting produces.** Beside the control that changes it, a small preview of the
  result (the invite email, the notification as it will arrive, the plan's next invoice) is the
  product-specific substance a settings screen otherwise lacks.
- A settings view opens with a usage strip only when usage is its question (a plan or quota view),
  and its figures stay smaller than its h1.

### 8.3 Buttons

Controls run 27-40px tall (median 34; 32 is the commonest), with a 6-8px radius in the tight styles
and full pills in the soft ones. One filled primary per screen, usually near-black; a secondary is
white with a 1px border; a tertiary is text. The controls in one row share one height.

## 9. Type and colour

### 9.1 Type

- **One grotesk.** All 23 screens use a neo-grotesk, and 14 use that one family alone. A second face
  always has one job: a monospace for every number or for IDs only, a serif for one greeting, a brand
  face kept to the wordmark or the switcher. Never three. Axis ticks, chart notes and labels stay in
  the text face even when IDs are in a monospace.
- **App type is not display type.** The h1 stays at 18-32px and no figure reaches 48px: in testing, a
  50px serif title and a 76px balance made a budgeting screen read as a landing page (the inspector
  warns at 34px and 48px).
- **Sizes**:

| Role | Range in the study | Median |
| --- | --- | --- |
| Page title | 14-48 | 24 |
| KPI value | 16-40 | 24 |
| Card title (500-600) | 14-24 | 16 |
| Body, nav, cells | 13-16 | 14 |
| Labels, meta | 11-14 | 12 |
| Axis ticks | 11-16 | 12 |
| Pills, badges | 9-14 | 11-12 (house floor 11) |

  A screen uses 4-9 sizes, median 6. The cleanest scales in the study are 12/14/18/24, 12/14/16/30
  and 12/14/16/24.
- **Weights**: a median of 3 per screen; 10 of 23 never go above 600; 700 does one job per screen
  (money, the greeting or the title).
- **Figures**: tabular on every number in a table, in a KPI, or anywhere it updates. Check that the
  face really has tabular figures: `font-variant-numeric: tabular-nums` must change its digit widths;
  if it does not, take another face.
- **Case**: sentence case on about 19 of 23; uppercase content labels on 4 only. Tracking 0, and
  negative only on large type (-0.02em at 32px). Line height 20px for 14px text, 24px for 16px.
- **Dark** screens set body at 13px on three, and titles at 18-20/600 or 26-28/700.

### 9.2 Colour on light screens

- **Ground models**: white cards on an off-white ground (10 screens; #f3f5f7 to #fdfdfd, median
  #f9f9f9-#f9fafb); grey panels on white (6); a framed sheet (7).
- **The sidebar is never dark or saturated** (0 of 17): it matches the page, sits 1-3 levels off it,
  or is white on a grey page.
- **Lines in three steps.** Card edges median #eaeaea, the working band #e5e7eb-#ededed (about 1.2:1 on
  white). Row rules one step lighter, #efefef-#f4f4f4. Control borders one step darker, median
  #d1d5db. #cbcecd on cards reads heavy; #f5f5f5 disappears.
- **Text in three tiers.** Primary about #111 (range #000 to a tinted #272446). Secondary about #606060
  (#3a3b3b-#8e8e8e). Tertiary in the study runs #969696-#9f9d9b and placeholders #9ca3af-#b0b2b6,
  which is 2.5-3:1 on white and under the house floor. House rule: every tier that carries words
  reaches 4.5:1 on the surface it sits on, placeholders included; #6b6b6b is about the lightest grey
  that holds on white and on #f9fafb.
- **Accent placement**: on data only with greyscale chrome (4 screens), on actions only (2), or no
  accent at all (2). The primary button is usually near-black. Every screen with more than one accent
  family is flagged.
- **Status**: positive greens in the study run #16a34a-#40a670, which is under 4.5:1 as text; use a
  darker green such as #247b50 (5.2:1) for text, and the pale pairs for pills: positive #dcfae5 with
  #04250e, negative #ffedea with #831c1e, a notice #fffbeb with a #fde68a border and #92400e text, a
  count badge #c4242a-#dc3532.

### 9.3 Colour on dark screens

- **Ground**: #000 (3 screens), #111 (2), #171719 (1). No navy and no slate.
- **Surfaces**, three ways: a lighter step with no border (#111 on #000, nested panels at
  #222-#2a2a2a); the ground's own fill with a 1px line (#2e2e2e on #111); darker than the ground with
  a soft shadow (once: #131217 on #171719 with 0 12px 28px rgba(0,0,0,.2)).
- **Lines**: structural lines 22-31 levels above the ground (#1a1a1a on #000, #2e2e2e on #111,
  #222224 on #171719); interactive outlines brighter, #373737-#4c4c4c, so an outlined button reads as
  clickable without a fill.
- **Text**: #fff; secondary about #aaa (#82848a-#bbb); tertiary about #888. #4c4c4f (about 2:1) is
  flagged. The house floor of 4.5:1 holds here too. Two greys at most, and far enough apart to read
  as two: about #b4b4b4 for secondary text and #949494 for meta (in testing, #aaaaaa, #969696 and
  #bbbbbb on one screen read as one grey used three ways).
- **Accent** on data only, or on one selected state. Active nav by brightness. Emphasis without
  colour: an inverted white pill, a grey-filled button (#212121), an outline.
- **Status** as a hue on a dark tint of itself: #3fc07a on #172a1d; #44a282 on #1b2828. **When the
  accent is green or yellow-green, `--positive` is the accent** (a lime accent: lime on a dark lime
  tint), or the sign carries the meaning in neutral ink; a mint "+£3.81" beside a lime "Sold" read as
  two greens. The same holds on light screens.
- **Colour share** at or under 3% on all six.

### 9.4 Surfaces, radius, icons, spacing

- **Edges.** A 1px border separates surfaces on 12 of the 17 light screens; three use a fill step
  alone and one a shadow alone. A border or a shadow, never both. The shadows that exist are faint
  (alpha .06 or less at 0 2px 8px; once 0 1px 4px at .12). Real shadows are for what floats: a menu,
  a popover, a tooltip, a composer.
- **Radius is bimodal**: tight, 4-8px (9 screens, plus square cards on one), or soft, 16-24px (10);
  11-12px on only 3. Controls follow the cards: 6px with tight cards, full pills with soft ones.
  Nested radii step down (20, 10, 8, 5, 4). The inspector warns over three panel radii.
- **Group, don't box.** A set of KPIs is one card split by 1px dividers, or a bare strip with 48-52px
  vertical dividers.
- **Icons**: outline on 22 of 23; nav icons 14-20px (median 16) at about 1.5px; inline icons 12-14px;
  about 10px to the label; grey (#454545-#73737f in a light nav, white or #81858a-#999 on dark). Never
  in a saturated circle (0 of 23). A container, if any, is neutral (a #f0f0f0 disc) or codes a node
  type in a builder.
- **Spacing** on a 4px base: card gap 6-24px (median 16); card padding 12-34px (median 20); section
  gap 24-64px; control height 27-40px (median 34).

### 9.5 Default tokens

Each style in `library/app.json` carries its own tokens. These are the study's medians for anything a
style does not set, with the greys moved to pass 4.5:1.

The names match the sheet `draw.mjs --app` prints and `assets/app.css`, a neutral start set to these
defaults (shell, title row, panels, the divided KPI strip, tables, pills, controls, tabs, empty states
and the phone fold). `assets/charts.js` draws the charts of §6 in SVG from your data (line, bars,
diverging columns, ring, bar list, plan, bullet, timeline, cover matrix and time strip), with these
rules built in and its colours read from these tokens.

```css
:root {
  /* light */
  --ground: #f9fafb;  --surface: #ffffff;  --raised: #f9f9f9;  --active: #eeeeee;
  --border: #e8e8e8;  --rule: #f2f2f2;     --control: #d1d5db;
  --text: #111111;    --muted: #5f6368;    --faint: #6b6b6b;    /* placeholders use --faint */
  --primary: #1d1f20;                      /* near-black fill, white label */
  --accent: /* one hue, about 3% of pixels: the data, plus at most one control */;
  --positive: #247b50;  --pill-positive-bg: #dcfae5;  --pill-positive-ink: #04250e;
  --negative: #c4242a;  --pill-negative-bg: #ffedea;  --pill-negative-ink: #831c1e;
  --pill-warning-bg: #fffbeb;  --pill-warning-ink: #92400e;  /* notice border #fde68a */
  --grid: #e5e5e5;  --chart-span: 0.06;  /* the opacity of a shaded span in a chart */

  --radius-card: 8px;  --radius-control: 6px;  --radius-chip: 4px;  /* soft styles: 20 / 14 / 999 */
  --gap: 16px;  --card-pad: 20px;  --pad: 24px;
  --nav-row: 34px;  --control-height: 32px;  --row-height: 48px;
  --font: "Inter", system-ui, sans-serif;                            /* one grotesk */
  --fs-title: 24px;  --fs-section: 16px;  --fs-kpi: 24px;  --fs-body: 14px;  --fs-small: 12px;
}
:root[data-theme="dark"] {
  --ground: #000000;  --surface: #111111;  --raised: #222222;  --active: #222222;
  --border: #1a1a1a;  --rule: #161616;     --control: #444444;  /* on a #111 ground the border is #2e2e2e */
  --text: #ffffff;    --muted: #b4b4b4;    --faint: #949494;  /* two greys that read apart */
  --primary: #ffffff;                      /* inverted: white fill, black label */
  --positive: #3fc07a;  --pill-positive-bg: #172a1d;  --pill-positive-ink: #3fc07a;  /* a green accent replaces these */
  --negative: #e5566f;  --pill-negative-bg: #2c1a20;  --pill-negative-ink: #e5566f;  /* the study's #d4405d, lifted for 4.5:1 */
  --grid: #2b2b2b;  --chart-span: 0.12;
}
.num { font-variant-numeric: tabular-nums; }
td.num, th.num { text-align: right; }
```

## 10. Density

- The studied screens hold 6 to about 120 data points, median about 25.
  - **Sparse** (6-25): homes, settings, a single object. Card padding 20-24px, rows 56-92px, body
    14-16px.
  - **Medium** (25-50): analytics overviews, finance homes, list screens. Rows 44-56px, padding
    16-24px.
  - **Dense** (about 120, one screen): research and monitoring, read in long sessions. A 13px body,
    28px list rows and 42px table rows, framed with generous air: 100px gutters, 64px between bands,
    32px of card padding.
- Density is three settings moved together: row pitch, padding and type size. Do not shrink the type
  and keep the padding.
- **Placed space reads as calm; leftover space reads as unfinished.** In the study, emptiness looks
  deliberate when it comes from a fixed grid and repeated gaps, and accidental when it comes from a
  grid that does not fit the data, a column that is empty in every row (a 300px dead band), a mostly
  empty top bar, or a card stretched to its neighbour's height.
- **Design for the data the account has as well as the data it will have.** Several studied screens
  were built for a busy account and show a new one: a 673x240 plot for a line that sits on zero for
  two thirds of its length. Keep the honest drawing, but size the block to the question, not to the
  data the product hopes for.

## 11. Empty, zero, loading and error states

- **A real zero** is printed in the normal style: "$0", "0", "0%", "0.00%", "0% (0)" (10 screens). A
  zero may be dimmed to grey when the caption beside it becomes the next step ("$0.00", then
  "Automate your savings with up to 3.50% APY").
- **Missing or not applicable** is a grey dash: an em dash, a "-", a 6x2px mark. Never a dash at full
  value size: "--" at 24px reads as loading (flagged).
- **Counts follow the view, and so does the frame.** Counts shown in scope (tabs, KPI tiles, "12
  open") are computed from the rows in the current view or filter and recomputed on every switch. A
  filtered block shrinks to its rows and its row-mate aligns to the top, or the block keeps a fixed
  number of rows with one empty-state sentence; each view's subline is computed for that view.
- **An empty side card fills with the reference data**, not one dashed box in 400px of white: before
  anything has sold out, last Sunday's sell-out times as a quiet list under "Nothing has sold out yet
  today".
- **Red is a signal, not a texture**: one red mark per object at most, on the overdue or failing fact;
  thirteen red strings on one board read as noise, and a list of deals needing attention does not
  repeat the same flags on the board beside it.
- **An empty section keeps its frame and its header** and adds one sentence spanning the block (in a
  row grid, `grid-column: 1 / -1`; a message squeezed into one 34px column broke word by word in
  testing): a 1px dashed box, radius
  8, about 38px tall ("No channel usage in this range."), or a bordered box with an add link
  ("+ Liability account"). In a builder, an empty slot is a dashed 1px box on the canvas colour.
- **An empty chart is drawn**: the zero line, missing bars for empty periods, no padded range, no
  picture.
- **First run** (the whole screen empty): keep the frame and the title, and put the first action where
  the data will appear, with a setup progress at its true percentage if there are steps (one screen
  shows "Complete your setup" at 67%; another a four-cell getting-started strip above its tables).
- **Loading** (house rule; not in the study): the final layout with each value replaced by a flat grey
  bar at its real size (a 24px value gets a 24px-tall bar; rows keep their pitch), labels and headers
  shown at once, no shimmer gradient, and no shift of the layout when the data lands. A refresh of
  data already on screen keeps the old values and updates the freshness stamp.
- **Error** (house rule; not in the study): inside the block that failed, never as a full-screen
  banner. One sentence saying what failed and when the data was last good ("Couldn't load
  deployments. Last updated 09:14."), and a Retry text button. The negative colour only on a small
  icon or in the pill pair.
- **Stale or static data**: a freshness stamp with a refresh button (6 screens). A snapshot that
  cannot refresh says so in one quiet notice (once: #fffbeb, a 1px #fde68a border, 12px #92400e
  text). A notice tint is never a ground; the inspector reads a pale amber ground as cream.
- **Limits and usage**: a bar at the true ratio with both ends labelled ("12.7k of 20.0k").

## 12. The phone

The study has no phone screens. This section is house rule, built from the skill's phone rules
(`layout.md` §9) and from what the desktop screens imply. The inspector fails a sidebar that still
takes more than 30% of a phone's width.

- **Navigation folds.** The sidebar becomes a 48px top bar with the screen's name, a menu button that
  opens a full-screen sheet (nav, workspace switcher, account) and at most one action; or a bottom
  bar of four or five destinations with short labels. An icon rail becomes that bottom bar.
- **Margins** 16-20px; touch targets at least 44px tall; data and labels at 14px, sublines no smaller
  than 12px.
- **The title stays**, at 20-24px. Header actions move into a kebab, except the one primary.
- **Filters** collapse into one row of chips or a Filters sheet; tabs scroll sideways inside their own
  row, never the page. The first list row starts in the top 60% of the phone screen: three rows of
  filter controls above it pushed the list off the first screen in testing.
- **KPIs**: a divided container becomes a 2-column grid, or a list with the label on the left and the
  value on the right. Never four squeezed columns. In a 2-column grid the count after any full-width
  lead is even; otherwise use three narrow columns or a divided list, or let the last cell take the
  full width (`app.css` does that for an odd count). A lone "Sold 3.18 kWh" beside an empty half-row
  was flagged.
- **At most one row of controls above a chart**: an office select and a week switch share one row.
  Two stacked full-width segmented controls pushed the data down in testing.
- **Charts** go full width, 160-200px tall, with 3-4 x labels and a legend of dots above, set in a
  2-column grid (or behind a "Key" disclosure) when it would wrap to three lines. The y ticks keep
  their own lane at the left (or move to the right edge); a tick label never sits on the line.
- **Row actions on a phone** are text actions or one compact trailing button, with one filled bulk
  action for the list ("Approve 3 that fit"); five 44px grey Approve blocks turned a list into a stack
  of buttons.
- **When a strip collapses, drop the item least relevant to the screen's main action**, not the last
  in source order: the live price stays on an energy screen whose plan depends on it.
- **Tables become lists of two-line rows below about 640px**: the identity and the key number on line
  1, the other fields the brief names in 13px grey on line 2 ("fra1 · 1m 38s"), the one action at the
  right. Fields move to line 2; they are never dropped. A table that must stay a table scrolls inside
  its own frame with its first column fixed; the page never scrolls sideways.
- **The tablet (641-1100px) is its own band**: the sidebar folds to the top bar, but tables keep four
  or five columns with secondary fields as sublines, two narrow panels stack once either would be
  under about 340px, and the identity column is never the one truncated while another has room.
- **Blocks that carry an action move up, not down**: a right rail with an action (low stock and its
  Reorder, bills due and Pay) becomes a one-line summary row above the list ("5 items low · Review"),
  never a block under twenty orders.
- **Several panes** (a list and its detail, an inbox): one region at a time. Choosing a row pushes the
  detail; back returns to the list at its scroll position.
- **Settings**: the label column stacks above its panel.
- **A right rail** moves under the main column or into a tab.
- **Builders** show the work read-only, with the palette as a bottom sheet if editing stays.
- Check 390px in the inspector's screenshots every round.

## 13. House rules for app screens

The house rules in `SKILL.md` were written for pages that are read once. An app screen is read every
day, and part of what those rules ban is the structure of a working screen. These relax, with limits:

| Allowed on app screens | Limit |
| --- | --- |
| **1px borders** on cards, panels, tables and controls | About 1.1-1.3:1 against the ground on light (#e5e7eb-#ededed on white); #1a1a1a on #000, #2e2e2e on #111. Controls one step stronger. A border or a shadow, never both. |
| **Row rules** in tables and lists | 1px, 3-7% darker than the surface. No zebra, no header band. |
| **Dashed lines** | In chart grids and empty slots only. |
| **Small labels** above values | 12-14px, sentence case, grey at 4.5:1. Meta and axis text 11-12px. Nothing under 11px. At most one tracked uppercase label on a screen (a switcher's context label). |
| **Rows of identical cards** | Peer metrics or peer objects only: 3-6 across, equal widths, one gap, no orphan, edges aligned across rows, no icon on top. Prefer one divided card; mixed spans beat a uniform grid. |
| **Monospace** | IDs, keys and code; or every figure, as one style's decision. Otherwise tabular figures in the text face. |
| **Pills** | Inside rows and beside a value only: 16-24px tall, a pale tint with dark text of the same hue. Never above a heading. |
| **Index numbers** | Only as data: a rank ("1."), a node number. |
| **Icons** | 14-20px outline, one set, grey: in the nav, before a label, or as a single affordance (kebab, chevron, info). A container only if it is neutral, or node-type tiles in a builder (pass `--allow icon-chips` and give the reason in the report). |
| **Shadows** | On what floats: a menu, a popover, a tooltip, a composer, a dialog. A resting card gets at most a small blur under 8% black. |
| **A second typeface** | With one job: a mono for IDs or figures, a serif for one greeting. |
| **A gradient** | Only as an area fade under one line chart; a flat tint of about 14% is safer. |

Still banned, as on pages, and checked:

| Banned | Instead |
| --- | --- |
| Gradients on grounds, buttons, cards, text and KPI tiles (the study's two gradient buttons are flagged) | Flat fills |
| Glows, glass, backdrop blur | Opaque surfaces one tone step from the ground |
| An icon in a coloured circle or tinted square on every KPI or card (0 of 23) | A label and a number |
| Rainbow charts: more than four hues, a hue per bar, one subject in two hues | One hue per chart, neutrals for the rest |
| Sparklines on KPIs (0 of 23) | The trend in its own chart, or nowhere |
| Emoji in greetings, labels, nav or empty states | Words |
| Lorem ipsum, Acme, John Doe, "User 1"; vanity figures ("10,000+ teams"); totals that do not add up | Believable sample data computed from rows, listed in the report |
| Status colour on a zero or undefined change | Grey, or the change in words ("+4 (new)") |
| A dark or saturated sidebar on a light screen; a navy or slate dark theme | The page ground for the sidebar; true neutral darks |
| The accent on chrome: a tinted nav row with a left bar, coloured tab pills, a brand fill on every button | The accent on data and at most one control; a near-black primary |
| Uppercase tracked table headers, zebra rows, header bands | 12px grey sentence-case headers; rules or space |
| Left-aligned numbers, proportional figures in columns | Right-aligned tabular figures |
| A hero header, a greeting with an emoji, an illustrated welcome banner | A noun title at 24px |
| Pagination under a five-row overview list | Capped rows, "View all", the total |
| Violet and indigo accents; cream with terracotta | The dealt style's palette |
| A landing-page title (over 32px) or a figure at 48px or more | An 18-32px title; figures at 16-40px |
| Icons in boxes repeated down a list or on top of every peer card, tinted or neutral | One plain 16px icon where it helps, or none |
| Every row a different hue, a "left" amount in green on every budget row | Grey for what is fine; colour only on the rows at risk or over |
| A screen that names no product and offers no action | The product's mark and scope in the shell; its main job one click away |

**On violet and indigo.** 10 of the 23 studied screens use violet or indigo, always as a data hue or a
single control. The ban is the owner's taste, not a finding of the study: the library deals other
hues for the styles that came from those screens, and the inspector's palette check stays on.

## 14. Tells of generated dashboards

Each line sets a habit of generated dashboards against what the 23 screens do instead.

| Tell | What replaces it |
| --- | --- |
| A dark navy or tinted sidebar with a coloured active pill and a left bar | The sidebar on the page ground; a neutral grey pill, or the label brightening on dark. A left bar appears once in 23. |
| "Welcome back, Alex" with a waving hand and a subtitle | A noun title at 24/600. Three screens greet; the one emoji goes. |
| Four KPI cards, each with an icon in a tinted circle | One container split by hairlines, label over value. No icons on any of the 23. |
| A green "+12.5%" pill on every KPI | A delta on 9 of 20 screens, in words with its window; grey for zero; "+4 (new)" from zero. |
| A sparkline on every KPI | None on any of the 23. The trend has its own chart. |
| KPI values at 36-48px, bold, in the brand colour | 24-30px at 400-600 in ink; nothing at 48. |
| A gradient area chart with a smoothed spline and a dot on every point | A 2px straight line, no fill on 7 of 10, no markers. |
| A rainbow palette, a hue per bar or per chart | One hue per chart; the accent on data only; four hues at most on a screen. |
| Gridlines every 20px, axis lines on both sides, a legend for one series | 3-5 faint lines, no y-axis line, no legend for a single series; round ticks above the maximum. |
| A fat donut with labels on the slices and a big total in the hole | A thin ring, no slice labels, the legend as a table with the values. |
| Cards with a 16-24px radius, a 1px border and a large soft shadow | A border or a tone step, no shadow; a tight or a soft radius, chosen once. |
| A zebra table under uppercase tracked headers, with a checkbox, an avatar, a status chip and a kebab on every row | Rules and alignment: 12px grey headers, one status mark, one action where it is needed. |
| Left-aligned numbers in proportional digits | Right-aligned tabular figures, fixed decimals, units in grey. |
| Saturated status chips with white text | Pale tints with dark text of the same hue. |
| The accent on every button, tab and link | No filled primary on 12 of 23; near-black where there is one. |
| A top bar with search, a bell and an avatar on every screen, or one left mostly empty | No top bar on 9 of 23; when there is one, it carries search, the switcher or the object's name. |
| Export, Share and a date picker clustered by the title | Actions only when they are real; a freshness stamp instead. |
| Placeholder people and companies, round vanity totals, growth on a week-old account | The product's own data, small numbers at their real size, totals summed from the rows. |
| Pagination under a short list | Capped rows and "View all". |
| An equal four-up grid whatever the data, with orphan cells and stretched cards | A column count chosen by the item count; spans that change by row and land on the same lines. |
| Neon on dark, glowing borders, glass | True neutral darks, lightness steps, colour on the data. |
| The kit's own face: Geist or Inter on #f8f8f8 with white 12px cards and no hue on the data, or lime on black | One face taken from the brief (a ground from the product's world, a figure face with character, the brand's hue on the main data element) and one element only this product has. |
| A coloured icon per category in a list | One grey icon set; colour, if any, on the numbers. |
| An illustration and "No data yet" in an empty chart | The zero line drawn, and one sentence in the frame. |

A screen with every tell removed and nothing put in their place is a tidy template. What fills the
space is the product: its own data at its real size, the screen's one question answered by its
biggest block, and a grid whose sums close.
references/colour.md›
# Colour

The direction sheet deals a palette with named roles. This file is how to apply it so the page looks
decided rather than tinted.

## Contents

1. What the study measured
2. The roles
3. Proportion
4. Surfaces without lines or shadows
5. Colour fields
6. Dark pages
7. Colour and photographs
8. Contrast
9. Banned families, and what "warm" means here

## 1. What the study measured

- 41% of the studied pages are dark, 59% light. A quarter sit on pure white; 12% on pure black.
- Schemes, as the panel classed them: monochrome plus one accent 23%, light neutral 22%, dark neutral
  14%, several flat colours 13%, strictly two-tone 12%, colour carried by photographs 9%, one saturated
  field 6%.
- Buttons are neutral (black, white, grey) on 52% of sites. A coloured button is a choice, not a
  requirement.
- Gradient fills appear on 22% of the top-scored sites and 52% of the weak ones; gradient text on 2%
  against 13%.
- One site in 246 has a terracotta primary button and none has a violet one. Those are colours of
  generated pages, not of the gallery. Warm off-white grounds do occur (32 sites), nearly always with
  a dark or green ink rather than an orange accent.
- Top-scored sites change the page's field colour between sections more often (a median of two section
  grounds against one).

## 2. The roles

```css
:root {
  --bg: …;          /* the ground: most of the surface */
  --surface: …;     /* one tone step from the ground: filled panels, form fields, tiles */
  --ink: …;         /* main text and the strongest marks */
  --muted: …;       /* secondary text; still passes contrast on --bg */
  --accent: …;      /* the one accent */
  --accent-ink: …;  /* text on the accent */
  /* --field-1 … --field-n and their inks, when the palette has section fields */
}
```

Use the tokens for every colour in the build. A raw hex in a component is a second palette starting.
Do not add greys between the roles: if you need a quieter text, it is `--muted`; if you need a quieter
surface, it is `--surface`.

## 3. Proportion

Decide the shares before colouring anything and check them in the screenshot:

- **Ground:** 70–90% of the page.
- **Ink:** text and perhaps one inverted section (ink as ground, ground as ink).
- **Accent:** under 5% of any screen. It marks the action and at most one other recurring thing. The
  moment it appears in five places it stops meaning "here".

Palettes with fields (flat-loud, pastel) work differently: each section is one field at full strength
and the proportion rule applies inside each section (field, its ink, nothing else).

The accent is not for headings, icons, bullets, borders or underlines unless the direction sheet says
so. A headline is ink.

## 4. Surfaces without lines or shadows

With rules and shadows banned, a surface is told apart from the ground by tone alone:

```css
.panel { background: var(--surface); }          /* 2–6 lightness points from --bg */
.field { background: var(--surface); border: 0; } /* inputs: filled, with a clear focus state */
.field:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
```

- One step is enough. If a panel does not read, increase padding or separate with space before
  reaching for a second tone.
- Do not nest a panel inside a panel.
- Images do not need a frame. If a light photograph melts into a light ground, change the crop or set
  it on `--surface`, do not outline it.
- A thick bar (4px or more) in ink or accent is a graphic element, allowed where the direction uses
  it. A 1px line is not.

## 5. Colour fields

A section that changes the whole ground colour is the strongest colour move available, and it replaces
dividers, cards and background decoration at once.

- Fields meet edge to edge with a hard line. Nothing fades.
- Every field has its own assigned ink from the palette. Never put `--muted` on a coloured field; use
  the field's ink at full strength, or at 70% opacity for secondary text if it still passes contrast.
- Neighbouring fields should differ in lightness, not only in hue, or the page vibrates.
- Order them like chapters: open strong, rest, surprise, close dark or very light.
- The page's meta theme colour and the navigation follow the first field.

## 6. Dark pages

- Put `color-scheme: dark` on the root so form controls and scrollbars follow.
- The ground is a deep tone with a slight tint from the palette, and the ink is a soft light, not
  `#fff` on `#000`, unless the palette deals exactly that for a stark two-tone direction.
- Secondary text is where dark pages fail: check `--muted` against the ground (4.5:1).
- Depth on dark comes from a lighter surface step, never from glows or coloured light.
- Use lighter font weights than you would on a light page.

## 7. Colour and photographs

- When photographs carry the colour, the interface stays neutral and takes its ink from the darkest
  tone in the imagery.
- Do not wash a photograph with the brand colour. Either leave it alone, or commit to a real treatment
  for every image on the page (all greyscale, or all untouched).
- Text over a photograph needs a calm region. If there is none, move the text off the image. A neutral
  scrim (black or the ink colour, 30–55% opacity, flat or fading over the text area only) is the one
  gradient the inspector allows.
- A flat field sampled from a photograph (its sky, its shadow) makes a section sit with that image.

## 8. Contrast

- Body text 4.5:1 or better; display type and large numerals 3:1 or better; the dealt palettes meet
  7:1 for ink on ground.
- `inspect.mjs` computes contrast for text on flat grounds and fails anything under 2.6:1. It cannot
  see text over photographs: check those in the screenshots yourself.
- Do not lower opacity on text to make it "soft" unless you have checked the resulting ratio.
- Focus states and hover states increase contrast, never reduce it.

## 9. Banned families, and what "warm" means here

The inspector fails these:

- **Cream with terracotta.** A yellow-cream ground (roughly hue 22–62, light and noticeably
  saturated) with a rust, clay or burnt-orange accent is the house look of AI assistants and of the
  pages they generate. Either half alone is suspect; together they are unmistakable.
- **Violet and indigo.** The first wave of generated pages was purple; it still reads that way.

If the brief wants warmth, get it from a low-chroma stone, bone or putty ground (saturation under
about 15%), from a deep green, brown or oxblood ink, from photographs, or from a full warm field
(mustard, tomato red, pink) used flat and with conviction. Warmth is not beige.

If the brand itself owns one of the banned colours, it is the brand's and you keep it: use
`--keep palette`, pass `--allow palette-purple` (or the relevant rule) to the inspector, and note it in
the report.
references/copy.md›
# Words

Copy is the first design decision. A headline that needs four lines cannot be rescued by typography,
and a page of slogans cannot be rescued by layout. Write the page as plain text before any markup.

## Contents

1. Write the argument first
2. The two-line headline (with the character budget)
3. Headline shapes that work
4. Supporting line, body, buttons
5. Proof without inventing it
6. Things that read as generated
7. Micro-typography

## 1. Write the argument first

Answer in one line each, in a scratch note:

- Who is reading, and what do they already know?
- What is this, said the way its maker would say it to a friend?
- What single thing should the reader believe after one screen?
- What would make them believe it (a picture of the thing, a number, a name, a demonstration)?
- What do they do next?

Then list the page as claims, one per section, each with its proof:

```
1. It is a three-legged oak stool, made to order.             → the stool, large
2. Each seat is cut from one board, so the grain is yours.    → three close-ups
3. It takes six weeks, and you can follow it.                 → the stages, photographed
4. It costs 240 and is delivered by the maker.                → price, button
```

A section with no claim is filler; delete it. This is also how you escape the stock order of
hero, logos, features, testimonial, pricing, call to action: the order of the argument is the order of
the page.

## 2. The two-line headline

A headline is a title. It is held in one glance. On the studied gallery sites the hero headline has a
median of 5 words and 70% sit on one or two lines at desktop width. Every headline on your page, at
every viewport, fits in **two lines or fewer**. `inspect.mjs` measures this at 1440, 768 and 390 pixels
and fails the page otherwise.

The constraint is arithmetic, so do it before you fall in love with a sentence:

```
characters per line ≈ line width in px ÷ (font size in px × k)

k ≈ 0.40  condensed faces
k ≈ 0.50  most grotesks and serifs at display size, tight tracking
k ≈ 0.58  wide / extended faces, all-caps settings
```

| Where | Line width | Size | Per line | Two-line budget |
| --- | --- | --- | --- | --- |
| Phone 390, 20px gutters | 350px | 44px | ~16 | **~32 characters** |
| Phone 390, 20px gutters | 350px | 34px | ~21 | ~42 characters |
| Tablet 768 | 690px | 64px | ~22 | ~44 characters |
| Desktop, 12-col measure | 1100px | 104px | ~21 | ~42 characters |
| Desktop, half width | 620px | 72px | ~17 | ~34 characters |

So a hero headline of **about 32 characters or fewer** (four to six words) survives everywhere at a
confident size. Up to about 42 works if the phone size comes down to the mid-30s. Past that you are
writing a sentence; move the rest into the supporting line.

Controlling the break:

- Give the headline a `max-width` in `ch` so it breaks where you intend, and add
  `text-wrap: balance` so two lines come out even.
- Where the break matters (it usually does), author it: `Stools for<br> small kitchens`. Check the
  phone screenshot; if an authored line wraps there, hide the `<br>` under a breakpoint or shorten.
- Never fix a long headline only by shrinking it. A timid 28px headline of nine words is worse than
  five words at 64px.
- Avoid a single orphan word on line two. Rebalance by cutting a word, not by nudging the width.

Section headings follow the same rule and are shorter: two to five words.

**A statement is not a headline.** An intro sentence or a quotation set large (the manifesto line
after the hero, a testimonial) is a paragraph: mark it up as `<p>` or `<blockquote>`, keep it to about
25 words, and set it at statement size, which the inspector takes as 36px or less on desktop, 32px on
a tablet and 24px on a phone. It may then run to about three lines (four on a phone). Set larger than
that, or marked up as a heading, or placed as the main line of the first screen, it is a headline and
gets two lines. In blind judging, a sentence set big over three or more lines was read as a long
headline every time, whatever its markup; if the words matter that much, cut them to a two-line
headline instead.

## 3. Headline shapes that work

All seen repeatedly on the gallery sites. Pick the plainest one that is true.

- **The name.** The brand or the person, set very large, with a one-line description elsewhere.
  Strongest when the name is short and the subject is visual (studios, restaurants, hotels, people).
- **What it is.** "A vinyl shelf in your pocket." "Furniture for small rooms." Category, stated with
  one specific word.
- **The claim.** "Your bank won't do this." One sentence a competitor could not sign.
- **The instruction.** "Understand your money." Verb first, short.
- **The place or the moment.** "Antarctica." "Lisbon, since 1984." For hospitality, events, travel.

Shapes to avoid, because they are the average of every landing page:

- The mad-lib: "The AI-powered platform for modern teams".
- The tricolon: "Fast. Simple. Secure." or "Plan. Build. Ship."
- The correction: "It's not just a tool, it's a workflow". Also "More than a …".
- "X, reimagined / redefined / made simple". "Welcome to …". "Meet …" for a product nobody has met.
- A question the reader did not ask.
- A headline that needs its colon: "Ledgerly: invoicing for freelancers". Drop the name, it is in the
  navigation.
- One word of the headline styled differently (italic, serif, coloured, underlined, in a pill).

Sentence case. No full stop unless the direction's voice uses them everywhere.

## 4. Supporting line, body, buttons

**Supporting line.** One sentence, at most about 22 words, that says concretely what the thing is and
who it is for. It is where the detail cut from the headline goes. It never repeats the headline in
other words.

**Body.** Short paragraphs of plain statements. Prefer the specific noun and the real number: "ships
in a week" over "fast delivery"; "38 seats" over "intimate". Read every sentence and ask whether a
different company could print it unchanged; if so, sharpen it or cut it.

**Buttons.** Name the action or the thing: "Book a table", "See the plans", "Download for Mac".
One to three words. The primary action keeps one name everywhere on the page. One primary button per
screen; a second action is a plain text link. No arrow glyph on every link and button; at most one
arrow, on the primary action, if the direction uses arrows at all.

**Navigation.** The wordmark, up to five plain links, one action. Link labels are single nouns.

## 5. Proof without inventing it

Never invent customers, quotes from named people, press logos, user counts, ratings or benchmarks.
Invented proof is both dishonest and a recognised tell.

When the brief supplies no proof, use what is true by construction:

- Show the thing itself, large. A real demonstration outranks any quote.
- State how it works, specifically.
- Use facts from the brief (price, place, year, materials, what is included).
- If a testimonial or logo section is explicitly requested and no real ones exist, build the section
  with plainly fictional sample content and say so in your final report so it is replaced before
  launch. Do not put real company names or real people in it.

**Sample details are different from proof, and they are expected.** Where the brief gives the shape of
the content but not every detail (six rooms "from €140", "a short menu", twelve speakers), write the
full set of plausible details: each room with a name, a bed, a view and its own rate; each dish with
its price; each speaker with a talk title. A design can only be judged at its real density, and a page
that shows one rate where six rooms exist looks unfinished. List every sample detail in the report so
the client confirms or replaces it.

## 6. Things that read as generated

`lint.mjs` flags most of these.

- Verbs and adjectives of the genre: unlock, unleash, supercharge, elevate, empower, streamline,
  seamless, effortless, robust, cutting-edge, next-generation, world-class, all-in-one, blazing fast.
- Labels pretending to be editorial: "Field notes", "The details", "Our philosophy", "Quietly trusted
  by". A section heading can simply be "Pricing".
- Furniture text: locale or time strips, "Est. 2024", coordinates, version stamps, "Scroll", "(Scroll
  to explore)", fake photo credits, status lines such as "Available for work" with a dot, strings of
  words separated by middle dots or slashes.
- The em dash as a habit. One or two on a page read as writing; ten read as a model. Use a full stop.
- Explainer captions that narrate the design: "A clean, minimal layout for…".
- Step labels that duplicate numbers: "Step 1", "Phase 02".
- Three parallel items everywhere: three features, three stats, three steps. Use the number the
  content has.

## 7. Micro-typography

- Curly quotes and apostrophes (“ ” ’), a real ellipsis (…), an en dash for ranges (9–5).
- Non-breaking space between a number and its unit, and before the last short word of a headline
  when it would otherwise orphan (`&nbsp;`).
- `font-variant-numeric: tabular-nums` where numbers line up in columns (prices, tables).
- `text-wrap: balance` on headings, `text-wrap: pretty` on paragraphs.
- No all-caps for anything longer than three words; if you set caps, add 0.02–0.06em tracking.
- No text under 13px. No centred paragraphs longer than three lines. No justified text.
references/critique.md›
# Critique — judging the render

`inspect.mjs` passing means nothing banned or broken was measured. It says nothing about whether the
page is good. This file is how you find out. Use it on the screenshots, never on the code: you wrote
the code, so you will read your intentions into it. The pictures only show what is there.

## Contents

1. How to look (fourteen passes, a few minutes)
2. The score
3. Caps
4. Writing the critique
5. What to fix first
6. When to stop

## 1. How to look

Open `desktop-fold.jpg`, then the `desktop-page-*.jpg` sheets, then `mobile-fold.jpg` and the mobile
sheets. Make each pass on its own; do not blend them.

1. **One second, then five.** Glance and look away. What did you see? There should be exactly one
   answer: a word, an image, an object. If the answer is "a website", there is no focal point. Then
   give the first screen five seconds as a stranger: can you say what this is, who it is for and
   what to do next? If the title only makes sense after scrolling, it is not a title.
2. **Squint.** Blur your attention until text becomes grey bars. The page should reduce to two or
   three large masses with clear size differences. Many small similar masses means everything is
   medium and nothing leads.
3. **Cover the logo.** Could this first screen belong to a competitor, or to a different industry?
   If swapping the name and the noun in the headline would produce an equally valid page, the page has
   no idea. Go back to the direction sheet; the idea is written there. Then scroll: does the idea reach
   the pricing, the FAQ and the footer, or does the page turn into stock blocks after the first screen?
4. **Edges.** Follow the left edge of every text block down the page. Count the distinct x positions.
   A composed page has two or three, reused on purpose. Six means things were placed one at a time.
5. **Space and frame.** Are related things close and unrelated things far? Is the gap between sections
   clearly larger than any gap inside a section? Is any region empty by accident (a hole) rather than
   by decision (a rest)? A hole usually sits beside a short column or under a removed decoration.
   Then look at the frame: are the left and right margins of the page the same? Does any section keep
   its text in one half and leave the other half empty? Is there a stretch of nothing taller than half
   a screen? Readers name these first ("it is pushed to the left", "random empty space"), before any
   detail; in the study of 100 Sites of the Day about 1 section in 100 left half the width empty.
6. **Type.** Is the jump from body text to headline decisive (three times or more)? Do you see more
   than five sizes? Any headline with one orphan word on its last line? Any paragraph wider than about
   75 characters or centred over more than three lines?
7. **Colour.** Name the proportions: which colour holds most of the surface, which supports, where is
   the accent? An accent that appears in ten places means nothing. One that appears on the single
   action and one detail means "here".
8. **Images.** Count the real pictures: are there at least four, and is there a moving one? A page of
   drawings only, or of type only, looks unfinished next to one with real photographs and footage.
   Is every image doing a job (showing the thing, setting the place, proving the claim)?
   Do the crops share ratios? Is anything a grey box, a stretched photo, a fake screen made of bars?
   Does the photography share one light and one grade, or is it a stock mix?
9. **Sameness.** Write one word for each section's layout (split, list, full-bleed, grid, statement,
   index …). The same word twice in a row, or three times on the page, is the template showing.
10. **Phone.** Is the mobile screen a composition of its own, with its own crop and its own line
    breaks, or the desktop page squeezed? Is the first screen still one clear thing?
11. **Fullness.** Go through the desktop sheets screen by screen. Is each screen a composed poster, or
    is it a narrow column of small text with dead ground either side? Is any statement held on screen
    for three screens? An austere page is not a restrained one: if the page could be mistaken for an
    unfinished wireframe (grey shapes, grey text, one small column), it is underbuilt. A single motif or
    drawing blown up to fill a whole screen with nothing to read is a hole too, however bold it looks.
12. **Substance.** Does every section the brief asked for deliver its information completely and at a
    readable size (the full menu, each room, every speaker, the price and what it includes)? Would the
    visitor leave knowing what they came to find out? Is the primary action a real button on the first
    screen?
13. **Collisions.** Is anything lying on top of a headline, a statement or a button at any scroll
    position? Does any element run off the edge of the phone screen? A row that scrolls sideways on
    the phone reads as cut off in a screenshot and to most visitors; wrap it unless the cut is
    plainly deliberate (a partial card showing at the edge).
14. **The competitor and the live thing.** Put the nearest competitor's name on the page: does the
    idea still work? Then name the one live thing (what the visitor can do or what differs each visit).
    If either answer is weak, that is the first problem of the round, ahead of any detail.

A note from testing: in blind comparisons a richer page full of template devices beat a cleaner page
that was thin, every time. The house rules remove the devices; passes 11–13 are where you make sure the
page did not lose its substance with them.

## 2. The score

Score like an Awwwards juror: four criteria, 1–10 each, weighted **design 40%, usability 30%,
creativity 20%, content 10%**.

**Design (40%)** — composition, typography, colour, imagery, craft.
- 9–10: every screen holds as a poster. Nothing could be removed or moved.
- 8: confident and coherent; one or two soft spots a designer would notice.
- 7: clean and professional, a clear system, little tension.
- 6: tidy but anonymous. Reads as a kit.
- 5 or less: weak hierarchy, accidental spacing, default type, mismatched images.

**Usability (30%)** — orientation in three seconds, readable text, obvious actions, honest mobile.
- 9–10: you know what it is, who it is for and what to do without scrolling; flawless on a phone.
- 8: clear and comfortable; minor friction (a long section, a small target).
- 7: works, with one real problem (weak contrast somewhere, a cramped phone layout).
- 6 or less: you have to hunt, or something breaks.

**Creativity (20%)** — an idea you can state in a sentence, carried through.
- 9–10: a concept specific to this subject that you have not seen applied this way.
- 8: a clear idea with one memorable move.
- 7: a tasteful execution of a known approach.
- 6 or less: the category default.

**Content (10%)** — words and pictures that are specific, true-sounding and edited.
- 9–10: every line could only be about this subject; imagery is the subject.
- 7–8: clear and concrete, some generic lines.
- 6 or less: slogans, filler, stock.

`overall = 0.4·design + 0.3·usability + 0.2·creativity + 0.1·content`

For calibration: real Sites of the Day land around 7.5–8.0 overall from the jury. An honest 8.5 is
rare. If your first score for your own work is 9, score it again as the juror who did not build it.

## 3. Caps

These override the arithmetic.

- Any `FAIL` still reported by `inspect.mjs`: overall cannot exceed **6.0**.
- A headline of three or more lines at any viewport, an eyebrow label, monospace labels, 01/02/03
  numbering, a decorative gradient, a shadow on a resting element, or a decorative hairline visible in
  a screenshot: overall cannot exceed **6.5**, whatever else is true.
- Pass 3 fails (the page could belong to anyone): creativity cannot exceed **6**.
- The idea passes for the nearest competitor (pass 14): creativity cannot exceed **7**.
- Fewer than four real pictures (photographs, archival images, footage; drawings do not count):
  design cannot exceed **6.5**.
- A page that barely moves (the inspector's `still-page`), or motion that is one identical fade on
  every block: design cannot exceed **7**.
- No live thing (pass 14): creativity cannot exceed **7.5**.
- Any placeholder (grey box, lorem, "Image here"): content cannot exceed **5**.
- Two or more desktop screens that are mostly empty ground, or a page whose running text is small and
  grey: design cannot exceed **6.5**.
- A section the brief asked for that is missing or reduced to a few words: content cannot exceed **6**.
- No real button for the primary action on the first screen: usability cannot exceed **7**.
- The five-second pass fails (no clear title saying what this is on the first screen): usability
  cannot exceed **6**.
- Unequal page margins, a section huddled on one side with the other half empty, or a blank stretch
  taller than half a screen: design cannot exceed **6.5**.
- A section of running text with no heading naming it: usability cannot exceed **7**.
- Anything covering a headline at any scroll position: usability cannot exceed **6**.
- A blank band where a reveal did not fire, or content clipped off-screen: usability cannot exceed **5**.

## 4. Writing the critique

Write it down; do not just think it. Use exactly this shape so rounds can be compared:

```
ROUND n
Seen in one second: …
Idea (one sentence): …
Live thing: …
Layout words by section: hero=… / …=… / …
Scores: design _ · usability _ · creativity _ · content _ → overall _._
Caps applied: …
Top three problems (most visible first), each with the screenshot it shows in and the fix:
1. …
2. …
3. …
```

Name problems the way a designer would to a colleague: "the supporting paragraph starts 40px left of
the headline's edge", "the testimonial section is the third split layout in a row", "the phone hero
crops the bottle out of frame". "Could be more polished" is not a finding.

## 5. What to fix first

Fix in this order; later items cannot rescue earlier ones.

1. **The idea.** If pass 3 fails, change the hero concept, not the details.
2. **Hierarchy.** One dominant element per screen. Make the big thing bigger and the small things
   fewer before touching anything else.
3. **Composition.** Shared edges, section spacing, variety of layout between sections, holes.
4. **Typography.** Scale contrast, line breaks, measure, leading.
5. **Imagery.** Replace weak pictures, unify crops and grade.
6. **Colour.** Proportion and accent discipline.
7. **Detail.** States, focus, small alignments, mobile refinements.
8. **Motion.** Last.

When removing something leaves a gap, do not refill it with an ornament. Close the gap, enlarge what
remains, or let a picture or a colour field take the space.

## 6. When to stop

Run at most three rounds: critique → fix the top three → re-inspect → critique. Stop early when a round
finds no problem a stranger would notice. If round three still scores under 7.5, say so plainly in
your report, with the remaining problems listed; do not round up.
references/imagery.md›
# Imagery

Pictures are where generated pages most obviously give up: a grey box, an icon in a square, a fake
dashboard, an abstract blob. This file is how to decide what the pictures are, where to find real ones,
how to crop and place them, and what to do when there are none.

## Contents

1. What the study measured
2. Decide what imagery exists
3. Real pictures from the open collections
4. The dealt photographs
5. Cropping and placing
6. Treatment
7. Showing software
8. Drawing the subject
9. Type and colour alone
10. Markup and credits
11. Video
12. Where pictures and footage go

## 1. What the study measured

- On 60% of the studied sites one piece of media covers at least half of the first screen; on half of
  them it covers nearly all of it. Top-scored sites: 68% and 56%. Weak sites: 48% and 39%.
- Only 15% of heroes have no media at all, and those are deliberate typographic statements.
- Media has square corners on 73% of sites. Where corners are rounded the usual radius is about 12px,
  applied to every image alike.
- Top-scored pages carry far more imagery (a median of 37 images against 5 on weak pages): the strong
  sites show, the weak ones tell.
- What the imagery is, by the panel's classification: a mix of kinds 32%, photography 14%, generative
  or WebGL scenes 13%, real product interface 12%, 3D renders 11%, illustration 9%, video 6%, type only
  3%.
- Among the 54 sites the panel scored under 6.8, legibility (text lost over a busy image, or too
  little contrast) was named as a weakness in 13.
- In the October 2026 study of 91 Awwwards Sites of the Day (section 12), the median page carries 7
  pictures (top sites 8, the lowest 2) and footage appears on 42% of sites (half of the top ones, one
  of the 16 lowest).
- Pictures come in whole screens: 42% of scrolled screens hold none and 19% are 90% picture or more.
- 94% of pictures have square corners, and on a typical site two ratios hold three-quarters of them.

## 2. Decide what imagery exists

In order of preference:

1. **The project's own images.** Product shots, the place, the people, the work. Even ordinary
   photographs of the real thing beat beautiful photographs of something else. Ask what exists; look in
   the repository.
2. **Images you can generate** with an image tool in this session. Write prompts as a photographer's
   brief: subject, angle, light, background, crop ratio, and "no text". Generate the hero first and
   make the rest match its light.
3. **Real pictures of the subject's world from the open collections** (section 3). Archives,
   museums and photographers have published millions of pictures anyone may use with a credit: the
   client's city and street in old postcards, maps of its neighbourhood, its materials, its trade in
   engravings, museum objects of its kind. Whenever an honest one exists, it beats a drawing of the
   same thing: it is real, it has detail no drawing in a page's budget reaches, and it carries history.
   Honest means nobody could take it for the client's own product, room, dish, person or team.
4. **Pictures you draw in code from the subject itself** (section 8), for what no honest picture
   exists of: the product's own objects as flat shapes, the place's tiles and windows, a map of its
   streets, the document a tool produces, the interface of a piece of software. In testing, pages that
   drew their subject with care beat pages that borrowed unrelated photographs every time, and beat
   pages that showed nothing.
5. **The photographs dealt on the direction sheet.** Real photographs of places, light, texture and
   weather. Use them only where an honestly generic picture fits (the open sea for a house on the sea,
   fog for a quiet retreat). They cannot stand in for a specific product, dish, room, person or
   screen: a foggy motorway on a restaurant page reads as stock at once, and jurors notice.
6. **Type and colour alone,** done on purpose (section 9).

Tell `draw.mjs` the truth with `--have`. If you have no product photographs and no image tool, do not
pass `product-photo`; the draw will avoid the directions that depend on showing the object. Pictures
found in the open collections are not the project's own: do not pass `photography` for them.

## 3. Real pictures from the open collections

`scripts/assets.mjs` searches four open sources without keys and prints, for each find, a direct image
URL, the page it comes from, its size, its creator, its licence and a credit line:

- **Openverse**: one index over Flickr, Wikimedia Commons, museums and libraries.
- **Wikimedia Commons**: archival photographs, old maps, scans of prints and books, places as they
  look now.
- **The Art Institute of Chicago** and **The Metropolitan Museum of Art**: public-domain paintings,
  prints, drawings, photographs, textiles and objects, in large scans.

```bash
node <skill>/scripts/assets.mjs "ghent postcard"
node <skill>/scripts/assets.mjs "fig" --source museums --orientation portrait
node <skill>/scripts/assets.mjs "basalt texture" --min-width 2400 --source openverse,commons
node <skill>/scripts/assets.mjs --get commons:36649959,met:707892     # download, record the credits
node <skill>/scripts/assets.mjs --credits                             # footer line and report list
```

By default it keeps only licences that allow commercial use (CC0, public domain, CC BY, CC BY-SA) and
pictures at least 1000 pixels wide, and checks that every image it prints actually loads.
`--license pd` keeps only pictures that need no credit; `--orientation`, `--min-width` and `--limit`
narrow the list; `--source` picks the sources; `--json` gives the raw results. Each result has a ref
(`commons:36649959`) that `--get` takes.

**What to search for.** The inventory (SKILL.md step 1), not the category: "bakery" finds other
people's bakeries. The subject's world is specific:

- **Its place:** the city, the street, the harbour, the valley, in old postcards and photographs
  ("ghent postcard", "rotterdam harbour 1920") and as it looks today ("gent korenmarkt").
- **Its maps:** the street plan, the chart of its bay, the geology of its valley ("ghent old map",
  "nautical chart lisbon").
- **Its materials,** close enough to read as a surface: the stone of its quay, the grain of its oak,
  its glaze, its tiles ("azulejo tiles", "basalt cobblestones", "oak grain").
- **Its trade's history:** the old tools, the craft in engravings and trade catalogues, patent
  drawings ("bread oven engraving", "potter's wheel", "bicycle patent drawing").
- **Museum objects of its kind,** with `--source museums`: a coffee set from about 1810 for a studio
  that makes coffee sets, a woodblock print of a wave for a surf school, a porcelain plate painted
  with figs for a fig orchard.
- **Scientific imagery:** botanical and zoological plates ("botanical engraving fig"), star charts,
  microscope photographs, satellite views of its coast, geological sections.

Search in the place's own words (azulejo, not "blue tile"; Korenmarkt, not "market square"), add a
medium (postcard, engraving, old map, lithograph, aerial photograph) or a decade, and try two or three
queries per idea. The museum sources match best on one or two words ("fig", "harbour", "teapot").

**What makes a good find.**

- It belongs to this subject's world and would not fit the nearest competitor's page.
- It still reads at the crop the slot needs. Look at it before deciding: `--get` downloads it, then
  open the file. A title is not a picture.
- It is large enough: twice the width it is shown at for anything full-bleed (the script prints the
  largest size available). A 1024-pixel Flickr photograph is a half-width picture at most.
- It belongs to one family with the others on the page: all engravings, or all photographs from one
  era, or all close surfaces in one light. A 1900 postcard beside a modern stock photograph beside a
  botanical plate reads as a scrapbook.
- No watermark, no burned-in text or logo, no frame or scanner edge you cannot crop away, and no
  recognisable living person who could be taken for the client's staff or customers.

**Use it creatively.** Found pictures are material, not decoration:

- **One archival photograph as the hero,** full-bleed: the street as it was a century ago, behind the
  name of the business that stands in it today, with the year in the caption.
- **A museum engraving enlarged until its line becomes texture,** cropped tight and returning at
  different scales as the page's one motif (the whole fig plate in the hero, a single leaf of it
  beside the menu).
- **An engraving or a map on white paper set with `mix-blend-mode: multiply` on the page's colour
  field,** so the ink sits on the ground (section 6).
- **The real map of the street as the contact section,** the door marked, the north and the scale
  taken from the original.
- **A material close-up as a whole field:** the quay's stone behind the opening hours.
- **A run of old postcards or plates as the gallery,** one size, one treatment.

Treatments stay within section 6: untouched, greyscale for the whole set, or multiply on a field.

**Stay honest.**

- **A found picture is never the client's own.** It may show the client's city, its materials, its
  history, the kind of thing it makes; it may never stand in for the client's product, room, dish,
  person, team or customers. If a visitor could take it for the client's own (a bakery counter, a
  plate of pasta, a smiling barista, a guest room), do not use it.
- **Caption from the source** when a caption helps: what it is and when, as the archive describes it
  ("Gand, unloading a steamer, 1905"). Never write a caption that ties it to the client.
- **Record and credit every picture.** `--get` writes the source, creator, licence and page of each
  download to `assets/credits.json`. CC BY and CC BY-SA require a credit with the title, the creator,
  a link to the source and a link to the licence; `--credits` prints them as one line of HTML for
  the footer (section 10). Public-domain pictures need no credit; give one anyway.
- **Respect the licence.** CC BY-SA: an altered version (recoloured, combined with other pictures)
  must be shared under the same licence. Non-commercial (NC) and no-derivatives (ND) licences are out
  for a client's site, which is why the script leaves them out unless asked. Read the `note` lines:
  some pictures carry personality rights or a trademark, are public domain only in the United States,
  or come with an archive's statement instead of a licence.
- **Download, do not hotlink.** Save what you use into the project: `--get` puts it in `assets/` at
  1920 pixels wide (pass `--width 1280` for a picture shown at half the screen, `--width 3840` for a
  very large hero where the source has it). The page then works offline, does not break when an
  archive moves a file, and does not load from someone else's server. Every licence the script
  allows by default permits copying.
- **List them in the report:** file, title, creator, licence, source page, and where the credit sits.
  `--credits` prints that list too.

If two or three honest queries per idea find nothing that fits, draw (section 8). A drawing made for
the subject is better than a found picture that is merely nearby. If the script cannot reach any
source (no network), draw, and say so in the report. For moving pictures, the same script searches
real footage with `--video` (section 11).

## 4. The dealt photographs

- The sheet lists eight from one set so they share a subject and a light. Use three to six.
- Each line says where the calm area is. Put text there or keep text off the image.
- More from the same or another set: `node scripts/draw.mjs --photos list`, `--photos <set>`, or
  `--photos "fog coast"` to search descriptions.
- URLs have the form `https://picsum.photos/id/<id>/<width>/<height>`. Request the exact pixel size you
  render at twice the CSS size for sharpness, capped around 2400 wide. Add `?grayscale` for
  black-and-white.
- These are photographs from Unsplash served by Lorem Picsum under the Unsplash licence. List the ids
  you used in your report; they are meant to be replaced by the project's own pictures.

## 5. Cropping and placing

- **Choose a small set of ratios and stick to them:** for example 4:5 for portraits, 16:10 for
  landscapes and 1:1 for details. Random ratios look like a stock dump.
- **Go large.** A photograph at full width, or at half the screen and full height, has presence. Six
  small ones in a grid have none. If the image is good, give it the room; if it is not, cut it.
- **Crop with intent.** Use `object-fit: cover` with an `object-position` chosen per image so the
  subject sits where the composition needs it (off-centre, on a third, against the text side).
- **Let images touch edges.** Full-bleed, or bleeding off one side, reads as designed. An image
  floating with equal margins on all sides reads as placed by default.
- **Overlap sparingly and on purpose:** a headline whose last line runs over the top edge of the
  picture, a small image overlapping a large one by a fixed 40–80px. One overlap per screen at most.
- **Square corners** unless the direction or the one-motif shape says otherwise. If rounded, one radius
  for every image on the page.
- **No frames:** no borders, no shadows, no device mock-ups, no browser chrome around pictures.
- **Captions** are one short line in the small size and soft ink under the image, left-aligned to its
  edge. Most images need none.
- Where each picture goes on the page, how large, and what the phone does with it: section 12.

## 6. Treatment

One treatment for the whole page:

- **Untouched.** The default. Pick images whose light already agrees.
- **Greyscale.** `filter: grayscale(1) contrast(1.05)`, or `?grayscale` on the URL. Unifies a mixed
  set instantly and suits editorial and typographic directions. Apply to every image or none.
- **Multiply on a field.** On a light flat ground, `mix-blend-mode: multiply` makes a white studio
  background vanish so a product sits directly on the colour.
- **Darkened for a dark page.** `filter: brightness(0.85) contrast(1.05)` so the image edges sink into
  the ground.

Not treatments: colour washes over photographs, duotone overlays, blur, grain overlays, vignettes,
tilted "polaroid" frames with shadows.

For text on a photograph, a neutral scrim covering only the text area: a solid block at 40–55% black,
or a fade from 55% black to transparent over the lower third. Never a coloured overlay.

## 7. Showing software

- Build a real fragment of the interface in HTML at its true size, with believable content, instead of
  a shrunken screenshot or a decorative mock-up. Crop to the part that makes the point: one control,
  one row, one panel.
- Believable means real labels, real numbers that add up, one item hovered or selected, proper
  alignment. Grey bars standing in for text are worse than no image.
- Put the fragment directly on the page or on one flat stage colour. No laptop, no phone frame unless
  the product is a phone app and the frame is a plain rounded rectangle.
- Inside a fragment the product's own interface conventions apply (its small radii, its 1px control
  borders, a shadow on its open menu). Put the attribute `data-ui` on the fragment's root element: the
  inspector and the linter then leave what is inside alone, and hold the page around it to the house
  rules. `data-ui` is for cropped fragments only; wrapping a heading or a whole section in it fails.
  Write the fragment's CSS under a `[data-ui]` selector (`[data-ui] .row { … }`) so the linter can
  tell it apart from page styles.
- If the user supplies real screenshots, show them large and uncropped at least once.

## 8. Drawing the subject

When nothing real can be photographed and the open collections hold no honest picture of it, draw it,
and draw it well. Inline SVG and CSS are enough. Drawings and found pictures can share a page: the
found ones show the world around the client, the drawings show the client's own things.

- **Draw the subject's own things,** not decoration: the stool's three legs and the grain of its seat,
  the enamel sign over the bakery door, the hut's two windows and its stove pipe, the street plan, the
  invoice the tool produces, the festival's stage. If you cannot name what a drawing depicts in the subject's world, it is decoration.
- **One graphic language for the whole page:** flat filled shapes, no outlines or one consistent
  stroke weight, the same corner and curve vocabulary everywhere, drawn in the palette's real colours
  (fields and accent at full strength, not greys). A set of drawings that obviously belong together
  reads as an identity; a mix of styles reads as clip art.
- **Colour is the material.** A product sold on its finish is drawn in that finish's colours; a sea house in the
  sea's blues and the sand; never a page of grey placeholders.
- **Draw with three levels of detail.** Jurors called simple drawings "basic" every time. Give each
  drawing big shapes that read at a glance, middle shapes that describe the thing (the handle, the
  window frame, the step), and small details that reward a second look (the maker's mark, the house
  number, the chipped tile, the hinge, a bird on the roof). Make light with flat tone steps (a lit face
  and a shaded face of each volume), not gradients. A drawing that would pass as a placeholder icon at
  full size is not finished.
- **Draw large and let it work:** the drawing is the hero's dominant element, a full-width band, a set
  of tiles, the footer mark. Small doodles scattered around read as filler.
- **Let it carry the idea through the page,** the way a motif does: the same drawn object returns at
  different scales, the tile grid becomes the opening hours, the map becomes the booking section.
- **Honesty:** say plainly, in small text near the first drawing or in the footer, that the pictures
  are drawn until photographs exist, if the client might otherwise be taken to be showing real
  products. In your report, list which drawings are placeholders for photography.
- **Not this:** gradient-shaded 3D-looking blobs, isometric clip art, generic line illustrations of
  people at laptops, abstract floating shapes, icons in squares. Those are the generated look.

## 9. Type and colour alone

This is a legitimate design, not a gap to disguise. Only 7 of the studied sites use no imagery at all,
and they hold their own: a median panel score of 7.5 against 7.2 for the whole set.

- Make the typography the image: one statement at 10–14vw, a list set at 5vw, numerals at display size.
- Use colour at scale: a full field, an inverted section, a two-field split.
- Use the subject's own shape as one flat motif if the direction calls for it (a circle, an arc, a
  notch), drawn in CSS, cropped by the viewport.
- Do not add: icons in squares, abstract gradients, dot grids, wave dividers, floating shapes, emoji,
  generic line illustrations, or an empty box where a picture "will go".

## 10. Markup and credits

A found picture, downloaded into the project with `--get`, used as the hero:

```html
<img src="assets/fig-engraving.jpg" width="1920" height="2807" fetchpriority="high" decoding="async"
     alt="Hand-coloured engraving of a fig cut open, with its flowers and seeds, in black, pink and green">
```

A dealt photograph further down the page:

```html
<img src="https://picsum.photos/id/626/2400/1500" width="2400" height="1500"
     alt="Red and white lighthouse in a turquoise sea below a chalk cliff" loading="lazy" decoding="async">
```

The credits, as `--credits` prints them, in the footer:

```html
<p class="credits">Pictures: <a href="https://commons.wikimedia.org/wiki/File:Fig_(Ficus_carica_L.);_57_segments_of_flowers_and_fruits_at_Wellcome_V0043000.jpg">“Fig (Ficus carica L.): 57 segments of flowers and fruits”</a>, <a href="https://creativecommons.org/publicdomain/mark/1.0/">public domain</a>, via Wikimedia Commons.</p>
```

- Always `width` and `height` (or `aspect-ratio` in CSS) so nothing jumps while loading.
- `loading="lazy"` on everything except the hero image, which gets `fetchpriority="high"`.
- `alt` describes the picture in plain words. Decorative repeats get `alt=""`. The script's suggested
  alt is the archive's title; rewrite it to say what the picture shows.
- A coloured `background-color` on the image container (the ground or surface tone) so the layout is
  whole before the image arrives.
- The credit line is part of the footer's design, at full contrast, with its links. Set it at the body
  size; one step smaller only on a page with plenty of running text, because a long credit line in
  small type can become the page's most common text size and fail the inspector's body-size check.
  One line for all pictures; no credit captions laid over the pictures themselves. Credit only the
  pictures the page still uses.
- A broken image fails the inspector. Check every picture in the screenshots.

## 11. Video

A moving picture of the subject's world can carry a section the way a photograph does: the river at
the end of the street, the roaster's drum turning, a timber frame going up. `assets.mjs --video`
searches real footage without keys: Wikimedia Commons (every video there comes with web-sized
transcodes; the best source for short clips), the Internet Archive's Prelinger Archives (industrial,
educational, city and home films from the 1920s to the 1970s, mostly public domain and mostly long)
and NASA (the Earth from orbit, launches, aircraft, laboratories). The rest of the Internet Archive is
left out: anyone can upload there, and the licence claims are unchecked.

```bash
node <skill>/scripts/assets.mjs "coffee roasting" --video
node <skill>/scripts/assets.mjs "river" --video --source commons,nasa --orientation landscape
node <skill>/scripts/assets.mjs --get commons:49769620        # the clip, a poster frame, the credit
```

It takes a rendition of 720p or smaller (`--res` changes that), lists short clips first, prints each
clip's length and file size, and flags anything over 25 MB or longer than a minute. `--get` refuses a
clip over 25 MB unless you pass `--max-mb`, and saves the source's still frame beside it as the
poster (Commons and NASA; the archive's own stills are too small). An archive film of ten minutes is
too heavy as it stands: trim a few seconds from it with ffmpeg if it is installed, or choose a
shorter clip. Licences, credits and honesty work exactly as for pictures (section 3).

**Choosing a clip.**

- **Short:** 5 to 20 seconds that loop without a visible jump: water, smoke, a turning drum, traffic,
  leaves, a crowd from far away. Watch it to the end before choosing.
- **Light:** under about 5 MB for a section and 10 MB for a hero. In the study sources are 1920×1080
  (the commonest) or 1280×720, median length 10 s; 720p is plenty behind type. For a full-bleed hero,
  cut a tall 720×1280 version for the phone, or set `object-position` on the subject.
- **Clean:** no slate, title card, logo, watermark or burned-in caption. NASA and archive films often
  open with a slate, and the source's poster frame may be that slate. No recognisable person who could
  be taken for the client's staff or customers.
- **Never the client's own:** a found clip of a roastery is not this roastery.
- **Found clips: one per page, two at most.** A found clip shows the world around the client; a
  third makes a theme of it. The project's own footage can carry more: in the study a site with
  footage carries a median of three or four clips. Either way, **one moving picture per screen**;
  two loops running side by side are noise.

**On the page.**

- Muted, looping, inline, with a poster and a fixed size, as `--get` prints it:
  `<video autoplay muted loop playsinline preload="metadata" poster="assets/river-poster.jpg"
  width="854" height="480" aria-hidden="true"><source src="assets/river.webm" type="video/webm"></video>`.
  No sound and no controls on a decorative loop. `aria-hidden` because it is decoration: whatever the
  clip says that matters belongs in the text beside it.
- The poster shows before the clip plays, on slow connections and under reduced motion, so it must
  work as a picture on its own. If the source's still is a slate or a blur, take a frame from the clip
  yourself or use a found picture.
- In the study only 31% of clips had a poster, and the panel recorded the blank and grey boxes
  before they played as faults. Here every clip has one.
- Play a clip only while it is in view, and none under `prefers-reduced-motion: reduce`, where the
  poster shows instead:

  ```html
  <script>
    const calm = matchMedia('(prefers-reduced-motion: reduce)');
    const clips = [...document.querySelectorAll('video[autoplay]')];
    const inView = new Set();
    const sync = (v) => {
      if (calm.matches) return;
      if (inView.has(v)) v.play().catch(() => {}); else v.pause();
    };
    const settle = () => {
      for (const v of clips) {
        if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); }
        else if (inView.has(v)) v.play().catch(() => {});
      }
    };
    const watch = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) inView.add(e.target); else inView.delete(e.target);
        sync(e.target);
      }
    }, { threshold: 0.2 });
    clips.forEach((v) => watch.observe(v));
    settle();
    calm.addEventListener('change', settle);
  </script>
  ```

- Crop and place it like a photograph (sections 5 and 6): `object-fit: cover`, one ratio, square
  corners, no frame, a ground-coloured `background-color` while it loads.
- Never under body text. On a clip put a headline, one line and one action at most, and check the
  contrast where they sit across the whole loop, not only on the poster: footage that is dark at
  one second can be bright at the next. If any frame drops below 4.5:1 (3:1 for 24px and up), use the
  scrim in section 6 or move the text off the clip. Nothing lies on top of a headline or a statement.
- It is motion: let it be the page's one moving thing, or sit in a section where nothing else moves.
- Credit it with the pictures: `--credits` prints one footer line for both ("Pictures and footage:")
  and the list for the report, with each clip's length.
- Where footage goes (hero background, inline loop, the frame that grows, a docked card), and what
  the study measured about it: section 12. Nothing that matters plays on hover only, and a full film
  with sound sits behind a play button that opens a player with controls.

## 12. Where pictures and footage go

What strong pages do with their pictures, measured on the latest 100 Awwwards Sites of the Day
(10 July to 17 October 2026, captured at 1440×900 and 390×844). 91 were usable. A panel described where
every picture sat and scored each site the Awwwards way, and a script measured how much of each screen
is picture. "Top" means a panel score of 7.5 or more (36 sites), "lowest" under 7.3 (16 sites). The
scores sit close together (6.7 to 7.9) and a single score carries about half a point of noise, so read
the links between a placement and the score as leanings, not laws.

### How much of each screen is picture

- **All or nothing.** Counting photographs, footage and picture backgrounds (not canvases), the median
  scrolled screen in the study is 16% picture, but few screens sit near it: 42% hold no picture and
  19% are 90% picture or more. Only 19% fall between 20% and 60%. A picture either leads its screen or
  stays out of it.
- **About a third, with rests.** On the 67 sites that put pictures in the page, the average screen is
  36% picture (middle half 18–69%). Top sites sit at 30% and leave 28% of their screens with no picture
  at all: screens of type are the rests between picture screens.
- **The first screen.** A picture covers at least half of it on 41% of the sites and nearly all of it
  on 32%. A full-bleed photograph or film is the whole first screen on 29%; footage is the main hero
  picture on about a quarter.
- **A scene is not a picture.** 66 of the 91 keep a WebGL canvas over at least 30% of the screen, so
  counting canvases the median screen is entirely covered. On the lowest-scored sites that canvas is
  often all there is: the five lowest usable sites (6.7 to 7.0) draw everything in it and put two
  pictures at most in the page.
- **Text is small in area.** The median screen is 8% text. Type is set large and sparse around the
  pictures, not packed beside them.
- **Off-centre.** Measured per section across the crawled sites, pictures sit to the left or right
  939 times, centred 273 times, and full-bleed or behind text 269 times.
- **The phone carries more picture,** not less: 19% of a phone screen at the median, and 27% of the
  first phone screen against 17% on desktop. Full-bleed pictures re-crop to portrait and fill the
  screen; splits put the picture first at full width.

### How many

- **Seven pictures per page** at the median (middle half 1–25). Top sites 8 (middle half 4–27), lowest
  2 (0–4). The floor in SKILL.md step 6, four and usually six to twelve, is the strong half of the
  study.
- **Footage on 42% of sites:** on half of the top sites and half of the middle band, and on one of the
  16 lowest. A site with footage carries a median of three or four clips.
- **Three to five placements.** Sites that use two or fewer of the placements below average 7.31;
  sites that use three or more average about 7.5. The median top site uses four, the median lowest
  site one. Where the pictures go changes from section to section on a designed page.

### The placements, ranked

| Placement | Sites (of 91) | Top (of 36) | Lowest (of 16) | Mean score with / without |
| --- | --- | --- | --- | --- |
| Split: picture in a half or two-thirds | 32 | 17 | 2 | 7.50 / 7.38 |
| Horizontal gallery | 32 | 13 | 4 | 7.44 / 7.41 |
| Full-bleed band between sections | 31 | 14 | 3 | 7.48 / 7.40 |
| Full-bleed picture or film as the first screen | 26 | 10 | 3 | 7.45 / 7.41 |
| Uneven mosaic | 25 | 11 | 1 | 7.49 / 7.40 |
| Picture held while text scrolls | 23 | 12 | 1 | 7.52 / 7.39 |
| Footage behind text | 22 | 8 | 2 | 7.46 / 7.41 |
| Even grid | 20 | 11 | 2 | 7.48 / 7.41 |
| Scattered collage | 19 | 6 | 3 | 7.47 / 7.41 |
| Panel sliding over a held picture | 19 | 8 | 2 | 7.48 / 7.41 |
| Inset frame that grows | 17 | 7 | 1 | 7.52 / 7.40 |
| One picture centred on a field | 14 | 5 | 3 | 7.38 / 7.43 |
| Cut-out object on a flat field | 13 | 7 | 1 | 7.57 / 7.40 |
| Index with hover preview | 12 | 6 | 1 | 7.49 / 7.41 |
| 3D gallery | 12 | 4 | 4 | 7.30 / 7.44 |
| Small card docked in a corner | 11 | 5 | 1 | 7.47 / 7.42 |
| Picture inside or in front of display type | 11 | 7 | 1 | 7.50 / 7.41 |
| One small picture beside a statement | 8 | 5 | 0 | 7.56 / 7.41 |
| Small pictures between words | 6 | 4 | 0 | 7.52 / 7.42 |

- Found on top pages more than their share and almost never on the lowest: the split, the picture held
  while text scrolls, the uneven mosaic, the inset frame that grows, the cut-out on a flat field, and
  pictures set with the type (the last three rows together: 24 sites, 16 of them top, 1 lowest).
- The horizontal gallery is common in every band and lifts nothing. Use it where browsing is the point.
- The 3D gallery is the one placement that goes with lower scores, and with the lowest usability in the
  table (5.9 against 6.35 for all sites).

### Each placement

Each entry gives when to use it, the geometry at 1440×900, the phone version and how it fails.

**Split: picture in a half or two-thirds**

- *When.* A claim and the picture that proves it. The commonest way strong pages pair words and a
  picture.
- *Geometry.* The picture takes 42% of the width at the median (middle half 33–49%), up to two-thirds,
  at the full height of its section: 680×850 and 720×900 are typical. 38% of these pictures are
  portrait, mostly 4:5 or 3:4. The text starts on the 50% line (x = 720) or on the line where the
  picture ends, top-aligned or docked low, not centred in its half. Let the picture touch the viewport
  on its outer side: only about 30% do in the study, and the ones with margin on every side read as
  placed by default (section 5). Alternate sides only across three or more rows.
- *Phone.* Picture first at full width (4:5, or 4:3 for a landscape), text under it.
- *Fails.* The same split in three sections running; a picture floating in the middle of its half; a
  text column narrower than about 30 characters.

**Full-bleed band**

- *When.* One picture carries a whole screen between type screens: the place, the work, the object at
  full size. One every three or four screens at most.
- *Geometry.* 100% wide and one to one and a half screens tall (median 900px at 1440×900, middle half
  890–1,170px; ratios 1.4 to 1.9). For a slow parallax the picture is oversized in its frame by 4–10%
  (32–72px a side in the study) and travels no further than that. Text is a headline and one line, or
  nothing, docked 32–64px in from a corner over the calm part of the picture.
- *Phone.* A portrait re-crop that fills the screen, with `object-position` set on the subject; the
  text moves to the bottom quarter.
- *Fails.* 11–14px white text over a bright area; three bands with nothing between them; a band that
  only decorates.

**Picture held while text scrolls**

- *When.* A list, steps or a long paragraph that all belong to one picture. If the picture changes for
  each item, it is the index with a preview, below.
- *Geometry.* A 100vh sticky stage inside a section two to six screens long (runways of 1,800–5,400px
  are typical in the study). The picture holds one half (685×880 is a typical portrait), two-thirds, or
  the whole screen with the text passing over it on a solid panel. Text rows about 150px apart, so two
  or three are in view at once.
- *Phone.* Release the stick: picture first, text under it. If the picture swaps per item, keep it as
  a band in the top 40% of the screen.
- *Fails.* Long runways where the text has faded out and the held picture is all that is left (the
  panel recorded several empty stages on phones); body copy set on the held picture.

**Inset frame that grows**

- *When.* Once per page, for the film or picture the page is about, as the move from type into
  picture.
- *Geometry.* The frame starts centred at 35–50% of the width (549×366, 710×480 and 864×540 in the
  study) or as a 16px inset round a full screen, and grows to the full width and height over
  720–2,700px of scroll. Any corner radius goes to 0 as it reaches the edges. A headline sits above it
  before it starts; nothing else on the screen moves while it grows.
- *Phone.* Start at full width inside the 16px margins and grow to a full-bleed portrait, or skip the
  growth.
- *Fails.* The growth is the only thing on screen for three scrolls; a loading spinner or a blank box
  in the first frame (give it a poster, start loading before it enters); used twice.

**Uneven mosaic**

- *When.* A body of work, or one place seen several ways: five to twelve pictures.
- *Geometry.* Two columns, or rows of three on a 24-column grid. Two or three picture widths (for
  example 32%, 48% and 65% of the width, or 5, 7 and 12 columns), neighbours offset vertically by
  120–190px, 130–450px between rows, one or two ratios, a caption under each.
- *Phone.* One column alternating full width and about 70% width, or two columns of 120–180px tiles
  that keep the offsets.
- *Fails.* Random ratios with random offsets, which read as a stock dump. Captions in 9–12px mono with
  index numbers, the award-site habit: here the caption is one line in the text face at body size, with
  no number.

**Horizontal gallery**

- *When.* A set people browse in any order: products, rooms, events, a team.
- *Geometry.* Cards 25–35% of the width (435×326 with 20px gaps at 1440: three in full and a fourth
  cut by the edge), or a strip at one fixed height with mixed widths (576px tall in the study), or a
  slider whose centre slide is about 73% of the width with 170–180px of each neighbour showing. Always
  cut by the right edge so it reads as more. A marquee of pictures moves slowly: 60–120s per loop.
- *Phone.* One card at 75–80% of the width (290–300px of 390) with the next one showing; swipe.
- *Fails.* Arrows and no drag or swipe; a marquee of things people need to read; a carousel holding the
  page's one important picture.

**Panel sliding over a held picture**

- *When.* A change of chapter: the picture holds, and the next section rises over it.
- *Geometry.* The picture is sticky at 100vh; the next section's ground slides up over it at full width,
  its top edge straight or shaped from the subject (the study has the arch of a building's windows).
  Here it is a solid panel with square corners: no rounded sheet tops, no frosted glass.
- *Phone.* Unchanged.
- *Fails.* Decks of five or six cards whose earlier cards linger as slivers; stacking for its own sake.

**Even grid**

- *When.* An archive only (layout.md §5): a shop range, the team, a menu, the full list of work.
- *Geometry.* Two, three or four across, equal cells, 8–16px gaps, one ratio (4:5 is the commonest),
  the caption under each picture.
- *Phone.* Two across for products, one for work and people.
- *Fails.* Three equal cards as "features". Cards with a dark gradient at the foot so the label can sit
  on the picture, common in the study: here the label goes under the picture.

**Index with hover preview**

- *When.* A list of eight or more projects or clients where the names matter as much as the pictures.
- *Geometry.* Rows on a 45–80px pitch, names at 30–60px. One picture at a time, about a third of the
  width at 16:9 or a portrait 300–450px tall, at a fixed place (the right half from the 50% line, or
  the centre), swapped within 0.3s. A version that needs no mouse: the names scroll across a picture
  fixed at the centre and the picture shows whichever name is crossing it. In the study the picture
  sits on top of the names; here nothing lies on top of large type, so the names pass in front of it.
- *Phone.* No hover. Give each row a thumbnail about 100px wide, or use the scroll version.
- *Fails.* Pictures that never appear on a touch screen. Rows divided by hairlines, the award-site
  habit: here rows are separated by space, and the active row by ink or a change of ground.

**One picture on a field, or one small picture beside a statement**

- *When.* A pause in the page: a single portrait or object. Or a small picture that gives a statement
  a face.
- *Geometry.* A single centred picture at 3:4 and 75% of the viewport height, on a ground taken from
  the picture. Or a small picture 7–26% of the width (108–374px) beside, above or between the lines
  of a 48–136px statement, aligned to one of its lines.
- *Phone.* The picture above the statement at 60–100% of the width.
- *Fails.* Used as the default for every picture: a picture with equal margins on all sides reads as
  placed by default (section 5).

**Small pictures between words**

- *When.* A statement about the things themselves (materials, people, products), on a screen of its
  own.
- *Geometry.* Two to four chips in the statement, from the cap height to three-quarters of the type
  size tall and one to one and a half times as wide (50×46px in a 60px line; 113×75px between the two
  words of a 150px name), all from one shoot, square corners. Give them a width and height so the lines
  do not jump while they load.
- *Phone.* Keep them inline at the height of the text.
- *Fails.* More than four; chips from different shoots; a chip that carries information the words do
  not.

**Picture inside or behind display type**

- *When.* The object is the subject: a product, a building, a person.
- *Geometry.* A small picture window sits inside a block of 150–200px display type and later opens
  into the next screen; or the object stands behind the type, showing between and around the letters;
  or it touches the edge of the word without covering a letter. In the study the object more often
  stands in front and covers one or two letters, or a line of type runs behind a square picture; here
  nothing lies on top of a headline (the inspector fails large text a fifth or more hidden), so the
  type stays in front.
- *Phone.* Keep it, with the type at about 15vw, and check the word still reads.
- *Fails.* An object over the letters; type displaced or glitched by the picture.

**Cut-out object on a flat field**

- *When.* Products and objects. The highest mean score in the table, on 7 of the 36 top sites.
- *Geometry.* The object at 35–60% of the width on a flat ground taken from its own colours, standing
  on the bottom edge or running off one side, with the type behind or beside it.
- *Phone.* The object at 80–90% of the width under the headline.
- *Fails.* A render floating in a void with no ground; a drop shadow added to a cut-out (no resting
  shadows here; keep a contact shadow only if it was photographed).

**Scattered collage**

- *When.* Archives, ephemera, a festival of many small things. Sparingly: it is about as common on the
  lowest pages as on the top ones.
- *Geometry.* Six to sixteen pictures of 100–300px at a few sizes around a centred heading, never over
  it.
- *Phone.* Re-stack as overlapping pairs in one column.
- *Fails.* Tilted prints and stickers with offset shadows, common in the study and not a treatment
  here (section 6); pictures crossing the text.

**Small card docked in a corner**

- *When.* A standing pointer to the reel or to the next thing.
- *Geometry.* About 216×144 to 240×140, 30–43px from the corner; one per page.
- *Phone.* Drop it, or move it into the flow.
- *Fails.* Content scrolling under it; a second one.

**3D gallery**

- *What.* Pictures on a ring, sphere, helix or tunnel in a WebGL scene, turned by the wheel or by
  dragging.
- *In the study.* 12 sites, 4 of them among the 16 lowest; mean score 7.30 against 7.44, usability 5.9
  against 6.35.
- *Here.* Do not build one. Show the same pictures as an index with previews or a horizontal gallery.

### Footage

**What the study measured.**

- Footage on 38 of 91 sites (42%): 18 of the 36 top sites, 19 of the 39 in the middle, 1 of the 16
  lowest. A site with footage carries a median of three or four clips (middle half 1–8).
- Footage covers at least half of the first screen on about a quarter of the sites, and is full-bleed
  on nearly all of them.
- Clips are short: median 10s, middle half 5–14s; 61% run 10s or less and 88% 20s or less.
- Every decorative clip is muted, loops, plays inline and has no controls. The autoplay attribute is on
  44% of clips; the rest are started by script, mostly as they come into view.
- Only 31% of clips have a poster (13 of the 38 sites). The rest show a blank or grey box until they
  play, and the panel recorded those as blank first paints. Here a poster is required (section 11).
- Sources: 1920×1080 is the commonest, then 1280×720; tall clips at 450×800 or 540×960 for phones and
  portrait tiles. Median source width 1,080px.
- On the page: 16:9 41%, square 19%, portrait (4:5 to 9:16) 25%, 4:3 or 3:2 12%. Corners square on 75%
  (the rest 8px or less, or 20–32px). `object-fit: cover` on 84%, contain on 16% (product strips, letterboxed
  films).

**Where it goes.**

- **Hero background:** full-bleed, cover, often oversized by 1–25% so a drift or zoom never shows an
  edge. Either one slow take or a montage cutting every second or two.
- **Inline, as a picture:** in splits, tiles and cards, at about a third of the width (median 33%,
  middle half 24–45%); a third of these clips are under a quarter of the width and fewer than one in
  ten is full width. Slow, near-still subjects (water, skin, light, a turning object) read as living
  photographs and sit well among stills.
- **The frame that grows** (above), for a reel.
- **A docked card** (above): one small clip in a corner.
- **On hover:** only two or three sites play clips on hover, all of them in an index or list. Nothing
  that matters should play on hover only; phones have no hover.
- **Full films:** about ten sites offer a full film behind a play button that opens a player with
  controls. None autoplays it.
- **Sound:** six sites offer sound behind a toggle; no clip starts with sound.

**How text sits on it.**

- Of about 30 sites with a full-bleed picture or film behind text on the first screen, about 14 set the
  type straight on it and rely on the footage being graded dark where the type sits; about 10 dim it
  (to half brightness, under a 30–40% dark layer, or with a dark fade along the bottom 180–590px);
  about 6 keep the text off the picture, above it or on a solid card.
- What goes on footage in the study is the name or a headline at 47–136px and one short line, docked
  bottom-left or centred. Where body text at 11–14px went on footage or a busy photograph, the panel
  named it as a fault, on top-scored sites too.
- Here: one headline, one line and one action on footage at most, and never body text. Check contrast
  where the type sits across the whole loop, not only on the poster; if any frame drops below 4.5:1
  (3:1 for type of 24px and up), use the scrim in section 6 or a solid band.

**On the phone.** Footage re-crops to a full-screen portrait (a separate tall source, or
`object-position` on the subject); inset frames go full-bleed. One site in the study adds a 40% scrim
on the phone only, where the type covers more of the picture.

### Crops and treatment

- **Ratios.** Of 1,356 pictures and clips measured: 4:5 21%, 16:9 19%, 1:1 16%, 16:10 10%, 3:2 7%, 2:3
  5%, 4:3 5%, 5:4 4%, 3:4 3%, wider than 2:1 6%, 9:16 or taller 3%. Landscape 52%, portrait 33%,
  square 15%.
- **Two ratios do most of the work.** On sites with four pictures or more, the two commonest ratios
  hold 76% of the pictures. The others (five ratios in all at the median) are exceptions with a reason:
  one tall portrait in a run of landscapes, one wide band.
- **Square corners** on 94% of pictures; 62 of the 68 sites with pictures in the page keep at least 80%
  of them square. Where corners are rounded, it is 5px or less on two-thirds of them, or 20–32px on
  card interfaces, one value per site.
- **Cover.** `object-fit: cover` on 78%, contain on 10% (a product on a tile, a letterboxed film).
- **Untouched.** CSS filters touch pictures on only 9 sites, mostly for hover states. A handful use
  black-and-white photography as a set; two multiply scanned paper onto the ground. Several keep the
  interface black, white and grey and let the photographs carry all the colour.
- **Common in the study, not treatments here** (section 6): a fixed grain layer over the whole page
  (about a third of the sites), duotone or tinted photographs, blurred copies behind a picture as a
  glow, tilted prints and stickers with offset shadows.
- **Oversize only by the distance it moves.** 22 sites move pictures with the scroll, oversizing them by
  1–25%. Oversize by the travel and no more, on one picture per screen.
- **Resolution.** The study serves pictures at a median 1.9 times their CSS width, but 17% are served
  smaller than they are shown and full-width pictures at a median of only 1,440px. Here: twice the CSS
  width, capped at about 2,400px (section 4).

### Text over pictures

- In the study, text sits on pictures often and is checked rarely: about 28 notes record text set
  straight on a picture with no scrim, and the faults of 16 sites include text lost over pictures: 8–14px
  labels and body over photographs, and navigation in a difference blend or with no backing passing
  over them.
- Where it works, the text is display size and sits over the calm part (sky, a dark wall, the shadow
  side), docked in a corner or along one edge. Two other forms in the study: a solid card in the
  ground colour inset over the photograph and carrying the heading, and text on the white of a picture
  that fades into the page.
- Here: on a picture put only display text, one short line and one action. Body text, captions and
  labels go beside or under it. Measure contrast at the text's own position (4.5:1, or 3:1 for 24px and
  up). If it fails, move the text to a calmer part, put it on a solid card in the ground colour, or use
  the scrim in section 6. A fixed navigation that passes over pictures gets a solid band, or hides
  while scrolling down.

### Rules for a generator

1. **About a third of the page is picture.** Across the scrolled screens, pictures fill 30–40% of the
   area, and at least one screen in four carries no picture (top sites: 30% and 28%).
2. **A picture leads its screen or stays small.** A leading picture takes at least 60% of its screen;
   an accent stays under 10% of it. Avoid screen after screen of pictures at 20–40%: in the study 42% of
   screens are 0% and 19% are 90% or more.
3. **Three to five placements per page,** and no two neighbouring sections with the same one. Sites
   with two or fewer average 7.31, sites with three or more about 7.5.
4. **Six to twelve pictures, never fewer than four.** Study median 7; top sites 4–27; lowest 2.
5. **Two ratios for at least 75% of the pictures** (76% in the study), a third only for a deliberate
   exception. Defaults: 4:5 for portraits, 16:9 or 3:2 for landscapes.
6. **Square corners on every picture** (94% in the study). If the direction rounds them, one radius of
   4px for every picture and clip.
7. **Split pictures take 42–50% of the width, or two-thirds,** at the full height of the section and
   touching the viewport on their outer side; the text starts on the 50% line. On the phone the
   picture goes first, full width.
8. **Full-bleed bands are 100% wide and 100–150vh tall,** one every three or four screens, oversized by
   10% at most for parallax, with text only in one corner at least 32px in. On the phone, re-crop to a
   full-screen portrait.
9. **One inset frame that grows per page,** from 35–50% of the width to the full screen over
   720–2,700px of scroll, with nothing else moving on that screen.
10. **Horizontal galleries show three cards and a cut fourth:** cards 25–35% of the width with 16–20px
    gaps; on the phone one card at 75–80% of the width with the next one showing.
11. **One moving picture per screen.** Clips of 5–15s (median 10s), muted, looped, inline, with a
    poster, paused out of view; a hero source of 1920×1080 under 10MB, inline clips at 1280×720 or
    smaller under 5MB.
12. **Only display text on a picture:** a headline, one line and one action at most, with contrast
    measured at the text's position across the whole loop. Body text, 16px or more, goes beside or
    under the picture, never on it.
references/layout.md›
# Layout

How the page is put together: margins, the grid, where text sits, how sections differ, navigation,
endings and the phone. The direction sheet gives the specific compositions; this file gives the habits
behind them, taken from the panel's notes on the 114 studied sites that scored 7.3 or higher (counts
below are hand tallies from those notes, so read them as "about").

## Contents

1. What strong pages do
2. Margins and the grid
3. Where text sits
4. Empty space
5. The frame: margins, balance and titles
6. Sections: carry the idea, vary the arrangement
7. Navigation
8. Endings
9. The phone is a second composition
10. How pages fail

## 1. What strong pages do

- **Tight margins, content to the edges.** About 45 of 114 use outer margins of 32px or less; only
  about 8 use 80px or more. The centred 1100–1300px container is the minority among strong sites and
  the usual frame of the ones the panel called stock.
- **Text hangs from an off-centre line.** About 22 anchor text to one fixed line that is neither the
  centre nor the margin: a third across, 42%, the midpoint.
- **Two or three type sizes with nothing between** (about 30): one very large with one small, or
  everything small with the scale given to the image.
- **One thing per screen** (about 20): one statement, one object, one project.
- **Sections told apart by their ground.** About 20 change the whole background colour at each
  section; about 12 alternate light and dark; about 10 never change and separate by space alone.
- **Sections recomposed, not repeated.** About 14 rearrange every section; about 12 alternate a
  full-bleed image screen with a near-empty type screen.
- **The system broken once, on purpose** (about 14): one centred line on a split page, one paper card
  on a dark page, colour held back until the last screen.
- **Often no navigation bar** (about 45), and endings that are a designed scene rather than link
  columns.
- **Few words.** Median 575 on the whole study; some of the best carry under 200.

## 2. Margins and the grid

```css
:root {
  --margin: clamp(1rem, 2.2vw, 2rem);   /* 16–32px: tight, so content reaches the edges */
  --gutter: clamp(0.75rem, 1.4vw, 1.5rem);
}
.wrap { padding-inline: var(--margin); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
```

- One pair of margins and one grid for the whole page. Every left edge is a column line.
- Do not wrap the page in a `max-width: 1200px; margin: auto` container by reflex. Let large things
  (type, images, colour fields) run the full width, and constrain only the measure of running text
  (`max-width` in `ch` on the paragraph, not on the page).
- On very wide screens cap the layout at about 1760–1920px, or let type sized in `vw` stop growing with
  a `clamp()`.
- Directions that call for wide margins (uneven photographs, the quiet instrument) say so on the
  sheet; follow the sheet.
- Never draw the grid. Column lines, crosshairs and guides are banned ornaments here even though they
  are fashionable; the grid is felt through alignment.

## 3. Where text sits

- **Pick the anchor line and reuse it.** If the hero headline starts at column 5, the section
  headings, the pricing figure and the footer links start there too. Two or three anchor lines on a
  whole page; six means nothing was decided.
- **Off-centre beats centred.** A heading in the left third with its paragraph in the far-right
  column on the same baseline and nothing between; a headline in the bottom third under a deliberate
  void; text docked in corners with the middle given to the picture.
- **Opposite ends.** Two short texts at opposite ends of one baseline (name left, one-line description
  right) is the commonest alternative to the centred stack.
- **Centred is a decision.** When a strong page centres, it uses one axis with wide empty flanks and a
  single element per screen, not a centred stack of heading, paragraph and two buttons.
- **Rows for long copy:** heading in the left third, body in the right two-thirds, the same split for
  every row on the page.
- Stagger sparingly: a second line indented by a third so a two-line headline steps across the screen.

## 4. Empty space

Empty space is placed, not left over. In the study of 97 Sites of the Day captured in October 2026,
readers judged every large empty area either a rest, which something closes, or a hole, which nothing
closes. Strong sites have plenty of rests. About half of them still leave at least one hole, and the
holes are what visitors name ("big random blank spaces").

**A void is placed when something closes it.** In the readers' notes the closer is, most often:

- the picture itself (sky, ground, the object, the film) with the type docked at its edge;
- a picture across the void: the photograph at the far side of an empty band, the full-width row
  rising under a statement, the opposite picture in a stagger;
- the type: a headline large enough to span the space, or two short texts at the two ends of one
  baseline;
- on a one-screen page, the wordmark, the navigation and one fact in the corners, like a mount around
  a print.

About one closer in five in the study is a hairline or grid line. That device is banned here; close the
void with the picture, the type, a step in ground tone or a field of flat colour.

**Where holes come from.** In order of frequency in the study:

1. A gap between sections stretched too far: bands of 250–760px of plain ground.
2. Text in one half of a section and nothing in the other.
3. Content that arrives late: a reveal not yet fired, so the visitor scrolls onto bare ground while the
   words fade in.
4. A short block stretched to a full screen: a footer padded to the viewport height, a section title
   alone on a whole screen.
5. A pinned scene that holds almost nothing for several screens.
6. Pictures of different heights top-aligned in a row, leaving a hole under the short one.

**Rules**

- Decide where the void is and name what closes it. If nothing does, it is a hole: move the section's
  picture into it, make the main element bigger, or take the space out.
- Keep the void clean: no stray caption, no decorative shape, no faint watermark, no grid lines.
- Sections sit 120–240px apart on desktop, counted from the last line of one to the first line of the
  next (the median in the study is about 230–250px), and 72–112px on a phone. A change of ground
  colour lets the gap be a little smaller. More than about 40% of a screen of plain ground between two
  sections is a hole.
- The gap between sections is clearly larger than any gap inside one. Related things sit close and
  unrelated things far; a heading has at least twice as much space above it as below.
- No stretch of the page taller than about half a screen holds nothing at all. In the study, runs of a
  screen or more with nothing in the page were almost always scroll distance for a pinned scene; the
  pinned stage must then hold something at every scroll position.
- Content is in place on the first paint. An entrance moves what is already there (see `motion.md`);
  it never leaves a screen of bare ground while the words arrive.
- Size a section to its content. `min-height: 100vh` belongs to a section that fills the screen, not
  to a footer or a lone heading.
- In a row of pictures of different heights, align the bottoms or put something under the short one,
  or crop the row to one ratio.
- A rest is one thing on a screen: a single line, one object, one picture or a field of colour. Once
  per page at most. A screen of nothing is never a rest, and the inspector fails two near-empty screens.
- When a section looks empty, the answer is almost never "add something". Make the main element
  bigger, or cut the section.

## 5. The frame: margins, balance and titles

The frame is what readers judge before any detail: "no good margin", "everything is shifted to one
side", "no clear title", "random unclear sections". The inspector measures each of these. The numbers
are from the study of 97 Sites of the Day (October 2026).

**One pair of equal margins.**

- The left and right margins are the same, at every width. On the studied sites with scrolling
  sections the two sides match to a few pixels: 16 and 16, 20 and 21, 32 and 32, 140 and 137. No
  strong page in the study was pushed to one side.
- The usual margin is tight: median about 28px at 1440, half the sites between about 18 and 45px, a
  few at 80px or more. Here it is 16–32px (section 2), and never under 16px at any width, on the phone
  included. About one award site in seven sets text 8–15px from the edge; that studio habit is
  excluded here.
- The navigation sits on the same margins as the content. The commonest small fault in the study is a
  header on one inset and the page on another (40px against 20px, or 34px on the left against 10px on
  the right).
- A bleed is deliberate and starts from the margin: a row of pictures that runs off the right edge
  begins on the left margin; a display line that bleeds starts or ends on one. A bleed never moves the
  margin.
- Asymmetry belongs inside the frame: an off-centre anchor line (a third across, 42%, the centre line)
  on symmetric margins.

**Use the width the page has set up.**

- In the study about one text section in a hundred leaves half the width empty. Text that sits in one
  half is common, in roughly one site in three, but the other half then holds the section's own
  picture (most often), a full-bleed picture behind the text, a second column, a list or a figure.
- When the text takes one side, put the picture, the second column, the list or the large number on
  the other. Never leave the other half to plain ground.
- The middle counts too: a card at the left edge and a lone button at the right with the middle third
  empty is the same hole.
- The first screen counts too. When the sentence and the button take the left third above a name
  docked at the foot, the upper right holds something (the picture or film, one fact, the action) or
  the name grows up to meet them; in testing, 60% of a first screen left as plain ground under the
  navigation read as a hole, and the inspector now warns when one empty block takes 28% of the first
  screen or more.
- A display line fitted to the width is fitted by measurement (a small script that sets its size from
  the content box, or SVG text with `textLength`), never by a size guessed for one face: a name that
  starts on the margin and runs a few pixels off the other edge reads as a mistake.
- A centred composition has one axis, equal flanks and one thing on it, not a stack of heading,
  paragraph and two buttons.

**A title that says what this is, on the first screen.**

- Asked whether a stranger could say what the page is within five seconds, readers found that 40 of
  the 97 first screens say it in words, 42 say it only through pictures, small print or a bare name,
  and 15 do not say it at all. The last group lost usability and content points, not design points.
- Size alone does not do it: 66 of the 97 first screens carry a display-size line, but 26 of those are
  a name, a slogan or a place with nothing saying what is offered.
- The first screen has one `h1`, at display size (32px or more on desktop; on the clear first
  screens in the study mostly 50–150px), two lines at most, that says what this is: the name with
  what it does, the product with what it is for, the event with its date and place. If the `h1` is
  the name, the line under it, at 18px or more, says what the thing is.
- It is there on the first paint. About a third of the studied pages still showed a loader or nothing readable 4.5
  seconds.

**A heading on every section.**

- 82% of the text sections on the studied sites open with a heading: median 57px (40–96px for the
  middle half), about three and a half times the body size, one or two lines.
- Headings start on the outer margin or on the page's inner anchor line (in the study about a third
  each) and in the same place for every section. About a quarter of the headings in the study are
  centred; centre one moment per page here.
- Where a studied section has no heading, the usual stand-in is a small label: caps, mono or a number
  in brackets. All three are banned here. Put the label's words in a heading in the heading face at the
  section size ("The rooms", "How it works", "Prices"), set them on the anchor line, and let the first
  sentence carry the rest.
- A statement can open a section as its heading only when it names what the section holds.

## 6. Sections: carry the idea, vary the arrangement

The most common failure in every score band is a distinctive first screen followed by stock blocks:
icon cards, a three-up row, an accordion, a link-column footer. About 25 of the studied sites lose
points this way, including several above 7.5.

- **The idea survives to the last pixel.** Ask of each section, pricing and FAQ included: what does
  the page's idea do here? If the page is a menu, the prices are menu lines. If the name is a
  monument, the footer is the name again. If the object's shape is the motif, the button and the image
  masks carry it.
- **No two neighbours share a layout.** Use the shape of each dealt blueprint (list, split, statement,
  full-bleed, mosaic, table, strip, pinned …). Count them in the screenshot; a shape used three times
  is the template showing.
- **Alternate dense and empty, image and type, light and dark** so the scroll has rhythm.
- **A repeated module is for archives only** (a work index, a menu, a schedule). Anywhere else,
  repetition reads as a kit.
- **Break the system once.** After the page has established its rule, one planned exception (a single
  centred line, one inverted section, one image at a different scale) is what makes the rule visible.
- **Short pages are fine.** A quarter of the studied sites are one or two screens. Do not add sections
  to seem complete; each one must carry a claim (see `copy.md` §1).

## 7. Navigation

- The plain form: the wordmark at one end, three to five text links, one action, all at text size on
  the page ground with no bar, border or background. Median link count in the study is 3.
- It is also normal to have less: small links in the corners, a single action, or the navigation
  folded into the content (a list of section titles that is itself the index).
- If it stays fixed, it must never sit on top of content: check every scroll position in the
  screenshots, especially on the phone. Floating pill bars are where the panel most often recorded a
  nav covering text.
- On a phone: wordmark and one action in view; the links go in a simple full-screen sheet opened by a
  text button ("Menu"), or move to the footer when there are only three.
- Navigation is small furniture, however it is drawn. On desktop it takes no more than about a tenth
  of the first screen; on a phone it is one row. A blueprint that builds the navigation as a column of
  colour blocks or a stack of tabs gets that treatment on desktop only, and collapses to one row on
  the phone so the headline, the supporting sentence and the action stay in the first screen. In
  blind tests a nav that ate the hero cost a whole point of usability.

## 8. Endings

- The page should end on a designed scene. In the study: the name at full width (about 18 of 114), the
  hero repeated in inverted colours (about 12), a closing scene of its own such as the one contact
  fact set as a headline (about 14), or simply one quiet line (about 22). Link-column footers appear
  in the panel's notes only as criticism.
- The full-width name is strong but already a studio habit; use it when the direction is name-led,
  otherwise prefer the blueprint dealt for the footer.
- Put the practical facts (address, hours, email, legal line) in the text face at a readable size, on
  the same anchor lines as the rest of the page.

## 9. The phone is a second composition

Design the 390px screen as its own page, not the desktop page squeezed. Strong sites that neglect this
lose usability points; weak ones are often just broken.

- Each blueprint on the sheet has its own phone paragraph. Build it.
- Keep one dominant thing on the first screen. A wordmark that was 20vw wide on desktop might stack on
  two lines at 26vw on the phone and be even stronger.
- Re-author line breaks; re-crop images to portrait (`object-position` per breakpoint, or a different
  crop size in the image URL); keep unevenness with alternating widths rather than making everything
  full width.
- Margins 16–20px. Body text 16–18px. Touch targets at least 44px high.
- Check 390px in the inspector's screenshots for every round: sideways scroll and three-line headlines
  usually appear here first.

## 10. How pages fail

From the panel's notes on the 54 sites scoring under 6.8 (counts out of 54):

- The whole page is the category's stock template; swap the logo and it could be any competitor
  (about 20).
- Centred headline, pill badge, two buttons, screenshot or tile grid below (about 9).
- A distinctive hero, then stock modules in the usual order (about 8, and about 17 more in the middle
  band).
- A default typeface with no character (about 13).
- Text too small or too low in contrast (about 13); legibility lost over busy photographs (about 9).
- An undersized headline and no focal point (about 9).
- The page never says what the thing is, or shows no work (about 12 and 7).
- Thin content stretched over many screens, or a single gag (about 11).
- Gates, preloaders and entry pop-ups before any content (about 9).
- A phone layout that is the desktop shrunk, or broken (about 8).
- Fixed elements sitting on content; careless details such as colliding lines (about 7 and 5).

And the faults of the best sites, which are not to be copied: body text at 9–13px (about 24 of the 90
sites at 7.5 or more), information hidden behind the concept (about 20), the idea running out
mid-page (about 15), text over footage with no scrim (about 12), gates and blank first paints (about
12), floating elements colliding with content (about 11), and a first screen with no statement of what
this is and no action (about 9). Say what the thing is, and offer the action, on the first screen.
references/motion.md›
# Motion

Read this before adding any animation. The static page must already be finished and passing
`inspect.mjs`; motion is the last pass, never a way to rescue a weak layout.

## Contents

1. The budget
2. What strong pages do with motion
3. Four questions before any animation
4. Tokens
5. Scroll behaviours, ranked
6. Recipes (copy, then adapt)
7. Signature moves
8. Reduced motion and performance
9. What gives generated motion away
10. Rules for motion

## 1. The budget

A page that does not move reads as a mock-up; the inspector counts what moves while the page loads and
scrolls, and fails it under eight elements. The motion layer has five parts:

- **Two or three scroll scenes:** the big, scroll-driven moments award sites are remembered for (a
  camera flying through a field of pictures, a picture that grows from a window to the whole screen,
  type that crosses a held stage, cards that deal out and stack). The direction sheet deals them from
  `library/scenes.json` when the library has scenes for the brief; `references/scenes.md` says how to
  fill and fit them. They are where the page is bold; everything else stays quiet so they land.

- **One signature moment:** the thing a visitor would describe an hour later: the headline that rises
  out of a mask, the image that opens like a shutter, the wordmark that scales with the scroll. The
  direction sheet from `draw.mjs` names one. Build that one well; it is the only move allowed to be
  loud.
- **Section entrances, choreographed per section:** the parts of each section arrive in reading order
  (heading, text, picture, details), staggered 60–90ms, rising 16–32px with opacity, once. Vary the
  manner with the content instead of stamping one preset everywhere: a list arrives row by row, a
  picture opens from a mask or settles from a slight scale, a figure counts up once, a statement rises
  line by line.
- **Feedback under the hand:** buttons that give on press, links that answer the pointer, cards and
  pictures that respond on hover, a menu that opens from where it was clicked.
- **One moving picture:** a found clip as a muted loop with a poster frame, a slow drift or scale on
  a large photograph, or a scroll-linked move on the hero picture.

What is still banned: the same fade-and-slide-up with the same timing on every block, which is the most
cited motion tell of generated pages; motion that hides content until a script runs, with no fallback;
bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
anything; choreographed motion with one protected moment says somebody did.

## 2. What strong pages do with motion

Measured on the latest 99 Awwwards Sites of the Day (July to October 2026), at 1440 and 390, with
the transitions, tweens and scroll triggers recorded while each page loaded and was scrolled. Where
the gallery does something the house rules ban, the house version follows.

- **The stack.** Two in three use a smooth-scroll library and three in five ship Three.js; GSAP is
  named on 56. The quiet layer below (reveals, line rises, the ink fill) needs none of it: CSS scroll
  timelines and view transitions do that work, though only 3 and 6 of the sites use them. The big
  scroll scenes are built on GSAP ScrollTrigger, as on the award sites, through the shared stage in
  `assets/scenes/` (`references/scenes.md`).
- **Curves.** The curves that really ran are strong ease-outs: expo out (`.16,1,.3,1` and
  `.19,1,.22,1`) ran on 14 sites, cubic out on 13, quint out on 7, and by number of transitions the
  quint and expo outs lead every chosen curve. In-out curves are kept for wipes and things that
  travel (cubic and quart in-out, about 15 sites each). Ease-in appears only for exits, short, before
  a slow arrival. Colour changes use plain `ease` (46 sites). Overshoot sits in 19 stylesheets and
  ran on 2.
- **Durations.** Hover colour 200-300 ms; small interface moves 300-450 ms; reveals and line rises
  600-1,000 ms; picture openings and wipes 1,100-1,600 ms. The median of everything that ran on a
  site is 420 ms.
- **Staggers.** 20-40 ms between characters, 75-120 ms between lines, 50-100 ms between list items.
- **Scroll-linked motion is locked to the scrollbar.** Of the sites that scrub, 14 of 18 lock it with
  no smoothing; the other four smooth it by 0.1-3 s. Reveals fire when the element's top is 80-95% of the way
  down the viewport, once.
- **How much moves.** A median of 130 elements are moved by script on a page, but they come from a
  few gestures: one split heading is 20-60 elements. The inspector's floor (8, warning under 16) is a
  floor, not a target.
- **Waiting is the most common cost.** 60 of 99 open on a loading screen, 26 of them with a counter.
  Half the usable pages were still unreadable 2.5 s after the browser said the page had loaded, a
  third at 4.5 s. The preloaders were not covering slow pages (their documents loaded faster than the
  rest) and the readers' design marks were the same whatever the wait; only usability fell. House
  version: no preloader, the message readable on the first frame, the load sequence over by 1.2 s.
- **Pins cost length.** Two in three scrolling pages hold something sticky or pinned. Those pages
  are twice as long (a median 16.6 screens against 7.5) for fewer words per screen (27 against 46).
  House version: at most two held stages, about one screen per step, three screens per stage.
- **Reduced motion is mostly missing.** 35 of 99 have any `prefers-reduced-motion` rule, 16 have
  three or more; 5 of 26 scroll-scrubbed scenes have one. Every recipe here keeps the finished state
  as the reduced state.

## 3. Four questions before any animation

1. **How often will someone see it?** Once per visit (hero load, a section reveal): it can take its
   time. Many times per visit (hover, menu, accordion): fast and small. Hundreds of times (keyboard
   actions, typing): no animation.
2. **What is it for?** Valid answers: it shows where something came from, it confirms an action, it
   keeps a change from being jarring, it explains how the product works, it is the signature moment.
   "It looks nice" is only valid for the signature moment.
3. **Which easing?** Entering or leaving: ease-out, so movement starts the instant the eye arrives.
   Moving between two places on screen: ease-in-out. Colour and opacity on hover: `ease`. Constant
   travel (marquee, progress): `linear`. `ease-in` makes interfaces feel slow because the first frames
   barely move; do not use it. The CSS keyword curves are weak, so use the tokens below.
4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
   drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).

## 4. Tokens

```css
:root {
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);        /* entrances, moving hovers, UI under 600ms */
  --ease-expo: cubic-bezier(0.16, 1, 0.3, 1);        /* new: reveals of 800ms and longer, line rises, openings */
  --ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);     /* wipes, panels, things that travel while visible */
  --ease-soft: cubic-bezier(0.33, 1, 0.68, 1);       /* slow scale and drift on photographs */
  --ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* new: something leaving for good, short */
  --t-press: 140ms;
  --t-hover: 180ms;
  --t-ui: 240ms;
  --t-reveal: 800ms;
  --t-open: 1200ms;                                  /* new: picture openings and clip wipes */
  --stagger-line: 90ms;                              /* new */
  --stagger-item: 70ms;                              /* new */
}
```

How the study bears on each:

- `--ease-out` (quint out) is in 28 stylesheets with its near twin and is the single most frequent
  chosen curve among transitions that ran. Keep it.
- `--ease-expo` is new. Expo out (`.16,1,.3,1` and `.19,1,.22,1`) is in 28 stylesheets and ran on 14
  sites, more than any other chosen family, and it is the curve the long reveals use: line rises,
  picture openings, card entrances of 0.9-1.6 s. Use it for anything at `--t-reveal` or longer; it
  replaces `--ease-out` in the masked headline and the reveal-on-scroll recipes.
- `--ease-in-out` (quart in-out) matches its family (14 stylesheets, 7 ran). Keep it for wipes,
  shutters, menus and page transitions.
- `--ease-soft` (cubic out) matches the cubic-out family (21 stylesheets, 13 ran), but the study uses it
  for interface moves of 0.3-0.8 s, where `--ease-out` already serves. Narrow it to slow scale and
  drift on photographs.
- `--ease-exit` is new. Ease-in is right for exactly one job: something that leaves for good (an
  outgoing caption, a replaced slide, a closing panel), short, at about 60-70% of the matching
  entrance. Never for anything the visitor is waiting to see.
- Not adopted: `.4,0,.2,1` (38 stylesheets) is a framework default, and overshoot curves (19) stay
  banned on interface elements.
- `--t-hover` stays at 180ms. The study's colour hovers run 200-300ms; a little quicker reads as
  more exact. A hover that moves something (a label roll, an arrow) takes `--t-ui` to 400ms.

## 5. Scroll behaviours, ranked

| Behaviour | Pages of 99 | What it serves | House version |
| --- | --- | --- | --- |
| Smooth-scroll library | 67 | A feeling of weight; makes scrubbed scenes easier to build | Optional, through `Stage.smooth()`: Lenis at lerp 0.1-0.14, off for touch and reduced motion |
| Held (sticky or pinned) stage | 44 | One picture or object carries several claims; chapters | Yes: the scroll scenes (`references/scenes.md`), two or three per page, one to four screens each; other sticky stages three screens at most |
| Split text | 40 | Lines and words arriving as they are read; hover rolls | Lines for headings and statements; words only for the ink fill; characters only for a heading of one to three words |
| Colour by chapter | 34 | Tells chapters apart without padding bands | Yes, as hard cuts at section edges; the header ink follows in 300ms |
| Scroll-scrubbed scene (camera, object, image sequence) | 26 | Explains a product by moving it | Yes, from the scene library, built on the shared stage with a finished composition for reduced motion |
| Parallax depth | 24 | Depth in collages; pictures drifting inside oversized frames | Travel under 8% of the element, pictures held at 106-110% in their frames |
| Marquee | 21 | Life on a static band | Existing "Edge-to-edge marquee": one per page, pausable |
| Masked line or word rise | 18 | Headings that arrive like set type | Yes: "Masked line reveal" on load, "Lines that rise on scroll" below the fold |
| Wheel drives a scene, no page scroll | 16 | Browsing one object | Not used (see 2.6) |
| Blur-in | 15 | Soft arrival | At most 8px of blur, 600ms, gone once settled; never on body text |
| Picture from inset frame to full-bleed | 14 | The hand-over from a type screen to the big picture | Yes: "Opening window", about one screen of scroll |
| Grey-to-ink text fill | 14 | Paces one statement | Yes, once per page, starting at 45% ink: "Ink fill" |
| Sheet slides over a held layer | 11 | Page-turn rhythm between chapters | Yes: new move "Sheet over a held picture" |
| Scramble or decode | 11 | A "code" voice | Not used (see 2.6) |
| Header ink by chapter | 10 | The fixed header stays legible | Yes, with colour by chapter |
| Horizontal track pinned to vertical scroll | 8 | Walking through rooms or a collection | Native drag row first; pinned only when the row is under three screens long, with keyboard support: "Sideways gallery" |
| Curtain footer | 8 | The page ends as a scene | Existing move |
| Rolling digits | 6 | Figures that arrive | Yes, for pages whose case is a handful of numbers: "Rolling figures" |
| Index with hover preview | 5 | Navigating work by name | Existing move, upgraded recipe |

Text, split by unit: characters on about 20 pages, lines on about 14, words on about 11. Character
splits serve short headings and button rolls; on long statements they leave fragments mid-scroll.

## 6. Recipes

All recipes keep the content visible when JavaScript is off, put every hidden state inside
`prefers-reduced-motion: no-preference`, and use the tokens above. Two notes that apply to several:

- **Scroll timelines need a scroller to watch.** `animation-timeline: view()` takes the nearest scroll
  container, and `overflow: hidden` makes one. A picture inside an `overflow: hidden` frame therefore
  never animates. Use `overflow: clip` on frames, or name the timeline on the section
  (`view-timeline-name: --hero`) and point the children at that name. This also applies to the
  existing "Scroll-linked progress" recipe.
- **Scroll-linked means linear.** The scroll is the easing. 14 of the 18 scrubbing sites in the study
  lock motion to the scrollbar, which is what CSS scroll timelines do.

All recipes keep content visible when JavaScript is off or slow: the hidden state only applies under
`html.js`, which a one-line inline script sets.

```html
<script>document.documentElement.classList.add('js')</script>
```

### Load sequence in 1.2 seconds

Everything is in the HTML and painted on the first frame; the sequence only adds arrival. Nothing
waits for the load event, and nothing in the navigation animates.

```html
<header class="hero">
  <h1 class="display lines">
    <span class="line"><span data-line style="--i:0">Stools for</span></span>
    <span class="line"><span data-line style="--i:1">small kitchens</span></span>
  </h1>
  <p class="lead" data-load style="--d:420ms">…</p>
  <a class="button" data-load style="--d:480ms" href="#stools">See the stools</a>
  <figure class="hero__picture" data-open><img src="stool.jpg" alt="…" fetchpriority="high"></figure>
</header>
```

```css
.hero__picture { overflow: clip; margin: 0; }
@media (prefers-reduced-motion: no-preference) {
  .js .hero [data-line] { transform: translateY(105%); animation: load-line 800ms var(--ease-expo) forwards; animation-delay: calc(100ms + var(--i) * var(--stagger-line)); }
  .js .hero [data-load] { opacity: 0; transform: translateY(12px); animation: load-rise 500ms var(--ease-out) forwards; animation-delay: var(--d); }
  .js .hero [data-open] { clip-path: inset(0 0 100% 0); animation: load-open 1000ms var(--ease-in-out) 150ms forwards; }
  .js .hero [data-open] img { transform: scale(1.08); animation: load-settle 1100ms var(--ease-expo) 100ms forwards; }
}
@keyframes load-line { to { transform: none; } }
@keyframes load-rise { to { opacity: 1; transform: none; } }
@keyframes load-open { to { clip-path: inset(0); } }
@keyframes load-settle { to { transform: none; } }
```

The timeline: the headline's lines rise from 100 to 990 ms, the picture opens from 150 to 1,150 ms,
the sentence and the button arrive from 420 to 980 ms. All settled by 1.2 s, four things at most.
Preload the display font (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) so the lines
rise in the real face, and give the picture `fetchpriority="high"` and a ground colour sampled from it,
so a slow image opens onto its own colour rather than a blank box. Study: 22 of 91 pages were readable
at the first intro frame and 11 more by 1.2 s; the intros the readers called respectful finished in
about 1-1.2 s; a third of the pages were still unreadable at 4.5 s.

### Masked line reveal (the classic gallery headline)

Each line slides up from behind its own baseline. Because headlines are capped at two lines, you set
the line breaks yourself and wrap each line.

```html
<h1 class="lines">
  <span class="line"><span data-line style="--i:0">Stools for</span></span>
  <span class="line"><span data-line style="--i:1">small kitchens</span></span>
</h1>
```

```css
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.lines [data-line] { display: block; }
@media (prefers-reduced-motion: no-preference) {
  .js .lines [data-line] {
    transform: translateY(105%);
    animation: line-up 900ms var(--ease-out) forwards;
    animation-delay: calc(var(--i) * 110ms + 150ms);
  }
}
@keyframes line-up { to { transform: none; } }
```

The `padding-bottom` / negative margin pair stops descenders (g, y, p) being clipped by the mask.
On a narrow phone the two authored lines may each wrap; check the mobile screenshot, and if they do,
author a shorter mobile break with a `<br class="only-wide">` pattern or reduce the size.

### Reveal on scroll (once)

For the two or three sections that deserve it, not for every block.

```css
@media (prefers-reduced-motion: no-preference) {
  .js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
  .js [data-reveal].is-in { opacity: 1; transform: none; }
}
```

```js
const io = new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
```

Never attach reveals with a scroll listener, never replay them when the element re-enters, and never
leave anything at `opacity: 0` if the observer fails to fire (the `html.js` gate handles no-JS; the
`unobserve` handles replays).

### Image shutter

An image opens from one edge while scaling down slightly inside its frame. Gallery staple; use it for
the one hero or showcase image.

```css
.shutter { overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
  .js .shutter { clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
  .js .shutter img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
  .js .shutter.is-in { clip-path: inset(0); }
  .js .shutter.is-in img { transform: none; }
}
```

### Press and hover

```css
.button { transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
.button:active { transform: scale(0.97); }
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
@media (hover: hover) and (pointer: fine) {
  .button:hover { background-color: var(--accent-strong); }
  .link:hover { text-decoration-color: currentColor; }
}
```

Hover styles go behind `(hover: hover)` because touch devices fire hover on tap and leave it stuck.
Hover should raise contrast, never lower it. Do not change font weight on hover (the text reflows).
Do not lift or scale every card on hover; one consistent, small response is enough.

### Scroll-linked progress without JavaScript

For a wordmark that scales as the hero leaves, an image that drifts a few percent, or a progress bar.
Wrap in `@supports` so browsers without scroll timelines get the static state.

```css
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .drift { animation: drift linear both; animation-timeline: view(); animation-range: entry 0% exit 100%; }
    @keyframes drift { from { transform: translateY(-4%); } to { transform: translateY(4%); } }
  }
}
```

Keep the travel small (under 8% of the element). Large parallax makes text hard to read and looks
dated.

### Pinned story

A section whose heading stays put while items pass. Use `position: sticky`; do not hijack scrolling.

```css
.story { display: grid; grid-template-columns: 5fr 7fr; gap: var(--gutter); align-items: start; }
.story__title { position: sticky; top: 18vh; }
@media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
```

### Index with preview, anchored to the row

Rows of names; pointing at a row or tabbing to it opens one frame level with that row. The frame
follows the row, never the pointer.

```html
<section class="index">
  <ul class="index__list">
    <li><a class="index__row" href="/work/harbour-house" data-preview="/img/harbour-house.jpg">
      <span class="index__name">Harbour House</span><span class="index__year">2025</span>
      <img class="index__thumb" src="/img/harbour-house-small.jpg" alt="" loading="lazy" width="640" height="400">
    </a></li>
    <!-- … -->
  </ul>
  <div class="index__frame" aria-hidden="true"><img alt=""></div>
</section>
```

```css
.index { position: relative; }
.index__list { list-style: none; margin: 0; padding: 0; }
.index__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0 var(--gutter); padding-block: 0.3em; font-size: var(--step-head); color: inherit; text-decoration: none; transition: opacity var(--t-hover) ease; }
.index__thumb { order: -1; flex-basis: 100%; width: 100%; height: auto; margin-bottom: 0.4rem; }
.index__frame { display: none; }
@media (hover: hover) and (pointer: fine) {
  .index__list { max-width: 62%; }
  .index__thumb { display: none; }
  .index__list:has(.index__row:hover, .index__row:focus-visible) .index__row:not(:hover, :focus-visible) { opacity: 0.4; }
  .index__frame { display: block; position: absolute; top: 0; right: var(--margin); width: 30%; aspect-ratio: 4 / 3; z-index: 1; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path 320ms var(--ease-out), transform 360ms var(--ease-out); }
  .index__frame.is-open { clip-path: inset(0); }
  .index__frame img { width: 100%; height: 100%; object-fit: cover; }
}
@media (prefers-reduced-motion: reduce) { .index__frame { transition: none; } }
```

```js
(() => {
  const root = document.querySelector('.index');
  if (!root) return;
  const frame = root.querySelector('.index__frame');
  const picture = frame.querySelector('img');
  const fine = matchMedia('(hover: hover) and (pointer: fine)');
  let warmed = false;
  const show = (row) => {
    if (!fine.matches) return;
    if (!warmed) { warmed = true; root.querySelectorAll('[data-preview]').forEach((a) => { new Image().src = a.dataset.preview; }); }
    picture.src = row.dataset.preview;
    const y = row.offsetTop + row.offsetHeight / 2 - frame.offsetHeight / 2;
    const max = root.offsetHeight - frame.offsetHeight;
    const opening = !frame.classList.contains('is-open');
    if (opening) frame.style.transitionProperty = 'clip-path';   // open in place, do not slide in from the top
    frame.style.transform = `translateY(${Math.min(max, Math.max(0, y))}px)`;
    if (opening) { frame.getBoundingClientRect(); frame.style.transitionProperty = ''; }
    frame.classList.add('is-open');
  };
  const hide = () => frame.classList.remove('is-open');
  root.querySelectorAll('.index__row').forEach((row) => {
    row.addEventListener('pointerenter', () => show(row));
    row.addEventListener('focus', () => show(row));
  });
  root.addEventListener('pointerleave', hide);
  root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) hide(); });
})();
```

On touch screens each row shows its own picture above the name. Study: 5 pages navigate work this way;
frames from about 470x270 to large plates behind the list; the other rows dimmed to about 0.3 (0.4 here, the floor for dimmed text). The
image trails and cursor-borne previews seen elsewhere in the study are the gimmick version.

### Lines that rise on scroll

For a section heading or a statement whose line breaks you do not control. Splits after the fonts
load, reveals once, then gives the plain text back so it rewraps normally. Plain text only (no links or
emphasis inside).

```html
<h2 class="head" data-lines>Turned by hand in one small workshop</h2>
```

```css
[data-lines] .line { display: block; overflow: clip; padding-bottom: 0.12em; margin-bottom: -0.12em; }
[data-lines] .line > span { display: block; }
@media (prefers-reduced-motion: no-preference) {
  .is-split .line > span { transform: translateY(110%); }
  .is-split.is-in .line > span { transform: none; transition: transform 900ms var(--ease-expo) calc(var(--i) * var(--stagger-line)); }
}
```

```js
(() => {
  const els = [...document.querySelectorAll('[data-lines]')];
  if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const restore = (el) => { el.textContent = el.dataset.text; el.classList.remove('is-split', 'is-in'); };
  const split = (el) => {
    const text = el.textContent.trim().replace(/\s+/g, ' ');
    el.dataset.text = text;
    el.textContent = '';
    const words = text.split(' ').map((word) => {
      const s = document.createElement('span');
      s.style.display = 'inline-block';
      s.textContent = word;
      el.append(s, ' ');
      return s;
    });
    const lines = [];                                   // group the words by the line they landed on
    for (const w of words) {
      const last = lines.at(-1);
      if (last && Math.abs(last.top - w.offsetTop) < 4) last.words.push(w.textContent);
      else lines.push({ top: w.offsetTop, words: [w.textContent] });
    }
    el.textContent = '';
    lines.forEach((line, i) => {
      const mask = document.createElement('span');
      const inner = document.createElement('span');
      mask.className = 'line';
      inner.style.setProperty('--i', i);
      inner.textContent = line.words.join(' ');
      mask.append(inner);
      el.append(mask, ' ');
    });
    el.classList.add('is-split');
  };
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      e.target.classList.add('is-in');
      e.target.querySelector('.line:last-of-type > span')
        .addEventListener('transitionend', () => restore(e.target), { once: true });
    }
  }, { rootMargin: '0px 0px -12% 0px' });
  document.fonts.ready.then(() => els.forEach((el) => { split(el); io.observe(el); }));
  let width = innerWidth;                               // a width change before the reveal: give the text back
  addEventListener('resize', () => {
    if (innerWidth === width) return;
    width = innerWidth;
    els.filter((el) => el.matches('.is-split:not(.is-in)')).forEach((el) => { io.unobserve(el); restore(el); });
  });
})();
```

Study: masked rises from 100-120% below the line, 0.6-1.2 s, 75-120 ms between lines, triggered when
the block's top is 80-95% down the viewport. Reduced motion: the script does not run; the text is plain.

### Opening window

The hand-over from a type screen to the one big picture: the picture first shows as a window in the
middle of a held stage and opens to full-bleed over about one screen of scroll.

```html
<section class="opening">
  <div class="opening__stage">
    <img src="workshop-wide.jpg" alt="The workshop at first light, benches cleared" width="2400" height="1500">
  </div>
</section>
```

```css
.opening__stage { height: 100svh; overflow: clip; }
.opening__stage img { display: block; width: 100%; height: 100%; object-fit: cover; }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .opening { height: 200svh; view-timeline-name: --opening; --inset-y: 20%; --inset-x: 28%; }
    .opening__stage { position: sticky; top: 0; animation: opening-window linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
    .opening__stage img { animation: opening-settle linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
  }
}
@media (max-width: 700px) { .opening { --inset-y: 18%; --inset-x: 8%; } }
@keyframes opening-window { from { clip-path: inset(var(--inset-y) var(--inset-x)); } to { clip-path: inset(0); } }
@keyframes opening-settle { from { transform: scale(1.15); } to { transform: none; } }
```

The section is two screens tall; the stage is held for one, and the window is fully open after three
quarters of it, so the full-bleed picture rests for a moment before the page moves on. Study: 14 pages;
start windows 38-60% of the width; runways 600-1,800 px, and the readers asked for one to two screens.
Without scroll timelines, and under reduced motion, the picture is a full-bleed screen.

### Held picture, passing captions

The pinned story built for photographs: the picture holds on one side while short captions pass on
the other; the caption at the middle of the screen is lit and its picture is shown.

```html
<section class="steps">
  <figure class="steps__media">
    <div class="steps__pictures">
      <img src="seasoning.jpg" alt="Ash boards stacked to season" class="is-on">
      <img src="turning.jpg" alt="A leg being turned on the lathe">
      <img src="finish.jpg" alt="Oil rubbed into a finished seat">
    </div>
    <figcaption class="steps__count" aria-hidden="true"></figcaption>
  </figure>
  <ol class="steps__list">
    <li class="steps__item"><h3>Seasoned for a year</h3><p>…</p></li>
    <li class="steps__item"><h3>Turned by hand</h3><p>…</p></li>
    <li class="steps__item"><h3>Finished with oil</h3><p>…</p></li>
  </ol>
</section>
```

```css
.steps { display: grid; grid-template-columns: 7fr 5fr; column-gap: var(--gutter); }
.steps__media { margin: 0; align-self: start; }           /* a stretched grid item cannot stick */
.steps__pictures img { display: block; width: 100%; height: auto; }
.steps__list { list-style: none; margin: 0; padding: 0; }
.steps__item { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; }
.js .steps__media { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 0.75rem; }
.js .steps__pictures { position: relative; aspect-ratio: 4 / 5; max-height: 80svh; }
.js .steps__pictures img { position: absolute; inset: 0; height: 100%; object-fit: cover; opacity: 0; transition: opacity 500ms ease; }
.js .steps__pictures img.is-on { opacity: 1; }
.js .steps__item { opacity: 0.4; transition: opacity var(--t-ui) ease; }
.js .steps__item.is-active { opacity: 1; }
@media (max-width: 800px) {
  .steps { display: block; }                               /* the section, not a grid area, contains the sticky picture */
  .js .steps__media { height: auto; z-index: 1; background: var(--bg); padding-block: 0.5rem; }
  .js .steps__pictures { aspect-ratio: 3 / 2; max-height: 42svh; }
  .steps__item { min-height: 60svh; }
}
@media (prefers-reduced-motion: reduce) { .js .steps__pictures img, .js .steps__item { transition: none; } }
```

```js
(() => {
  const root = document.querySelector('.steps');
  if (!root || !('IntersectionObserver' in window)) return;
  const items = [...root.querySelectorAll('.steps__item')];
  const pictures = [...root.querySelectorAll('.steps__pictures img')];
  const count = root.querySelector('.steps__count');
  const show = (i) => {
    items.forEach((el, k) => el.classList.toggle('is-active', k === i));
    pictures.forEach((el, k) => el.classList.toggle('is-on', k === i));
    if (count) count.textContent = `${i + 1} of ${items.length}`;
  };
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) if (e.isIntersecting) show(items.indexOf(e.target));
  }, { rootMargin: '-45% 0px -45% 0px' });                 // a band across the middle of the screen
  items.forEach((el) => io.observe(el));
  show(0);
})();
```

Without JavaScript the pictures and captions are two plain columns. Study: inactive items at 20-40%
(dims of 10-15% read as missing text, so the floor here is 40%), crossfades of 400-600 ms, about one
screen per step; the readers asked for a visible count on any held sequence of more than two steps.
The count is a plain sentence in the body face, not a zero-padded numeral.

### Sideways gallery

A row of pictures that the vertical scroll moves sideways while the stage is held, for a short
collection (eight items or fewer). The native drag row is the base; the pinned version is added only
on wide screens with motion allowed, and only when the row is under three screens long.

```html
<section class="gallery">
  <div class="gallery__stage">
    <h2 class="head">Eight kitchens, one stool</h2>
    <ul class="gallery__track">
      <li class="gallery__item"><a href="/kitchens/leith"><img src="leith.jpg" alt="A narrow galley kitchen in Leith" width="1200" height="1500"><span>Leith</span></a></li>
      <!-- … -->
    </ul>
  </div>
</section>
```

```css
.gallery__track { display: flex; gap: var(--gutter); list-style: none; margin: 0; padding: 0 var(--margin); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--margin); }
.gallery__item { flex: 0 0 min(72vw, 28rem); scroll-snap-align: start; }
.gallery__item img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
.gallery.is-pinned .gallery__stage { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 2rem; overflow: clip; }
.gallery.is-pinned .gallery__track { overflow: visible; scroll-snap-type: none; }
.gallery.is-pinned .gallery__item { flex-basis: calc(62svh * 0.8); }
```

```js
(() => {
  const root = document.querySelector('.gallery');
  if (!root) return;
  const track = root.querySelector('.gallery__track');
  const wide = matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
  let dx = 0, queued = false;
  const place = () => {
    queued = false;
    if (!root.classList.contains('is-pinned')) return;
    const p = Math.min(1, Math.max(0, -root.getBoundingClientRect().top / dx));
    track.style.transform = `translate3d(${(-p * dx).toFixed(1)}px, 0, 0)`;
  };
  const measure = () => {
    root.classList.add('is-pinned');                     // measure the row as it would be when pinned
    track.style.transform = '';
    dx = track.scrollWidth - track.clientWidth;
    const pin = wide.matches && dx > 0 && dx < innerHeight * 3;
    root.classList.toggle('is-pinned', pin);
    root.style.height = pin ? `${innerHeight + dx}px` : ''; // one pixel of scroll moves the row one pixel
    place();
  };
  track.addEventListener('focusin', (e) => {             // keyboard: bring the focused item into view
    if (!root.classList.contains('is-pinned')) return;
    const item = e.target.closest('.gallery__item');
    const x = Math.min(dx, Math.max(0, item.offsetLeft - parseFloat(getComputedStyle(track).paddingLeft)));
    scrollTo({ top: root.getBoundingClientRect().top + scrollY + x, behavior: 'instant' });
  });
  addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(place); } }, { passive: true });
  addEventListener('resize', measure);
  addEventListener('load', measure);
  wide.addEventListener('change', measure);
  measure();
})();
```

Study: 8 pages pinned a horizontal track; their pages ran a median 25 screens, and the readers' worst
cases spent 9 screens on three cards and 10,000-23,000 px of wheel on one track. Here the speed is 1:1
and the hold ends when the last picture reaches the margin. Phones, narrow windows, reduced motion and
long rows all get the native row with snapping, which also works with touch, trackpads and the
keyboard. Never put the only copy of a call to action inside the moving row.

### Receding hero

As the first screen leaves, its picture sinks a little and darkens while the words lift away a little
faster, so the hero recedes instead of simply scrolling off.

```html
<header class="hero">
  <img class="hero__picture" src="kitchen.jpg" alt="…" width="2400" height="1600" fetchpriority="high">
  <div class="hero__copy"><h1 class="display">…</h1><p class="lead">…</p></div>
</header>
```

```css
.hero { position: relative; min-height: 100svh; overflow: clip; display: grid; align-items: end; view-timeline-name: --hero; }
.hero__picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hero::after { content: ''; position: absolute; inset: 0; background: var(--ink); opacity: 0; pointer-events: none; }
.hero__copy { position: relative; z-index: 1; padding: var(--margin); }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .hero__picture { animation: hero-sink linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
    .hero::after { animation: hero-dim linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
    .hero__copy { animation: hero-lift linear both; animation-timeline: --hero; animation-range: exit 0% exit 70%; }
  }
}
@keyframes hero-sink { to { transform: translateY(8%) scale(1.06); } }
@keyframes hero-dim { to { opacity: 0.35; } }
@keyframes hero-lift { to { transform: translateY(-6%); opacity: 0; } }
```

The `exit` range runs from the moment the hero's top meets the top of the screen until its bottom
does, which for a first screen is the first screen of scroll. Study: hero pictures that sink and dim as
they leave, a heading scrubbed from 140 to 60 px, a title card that scales down on the phone; the
readers called it quiet and the page "one sheet". Travel stays under 8%; the overlay is a flat ink at
up to 35%, not a gradient. Static where unsupported or reduced.





### Page to page

Navigation between pages of the site with the browser's own view transitions: the old page fades
quickly, the new one rises in, and the picture the visitor clicked travels into place as the new
page's hero. No library, no full-screen curtain.

```css
/* on every page */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
::view-transition-old(root) { animation: vt-leave 200ms var(--ease-exit) both; }
::view-transition-new(root) { animation: vt-arrive 380ms var(--ease-out) 60ms both; }
::view-transition-group(*) { animation-duration: 480ms; animation-timing-function: var(--ease-in-out); }
::view-transition-image-pair(*.picture) { overflow: clip; }
::view-transition-old(*.picture), ::view-transition-new(*.picture) { height: 100%; object-fit: cover; }
@keyframes vt-leave { to { opacity: 0; } }
@keyframes vt-arrive { from { opacity: 0; transform: translateY(16px); } }
```

```html
<!-- on the index: the card picture of one project -->
<img src="/img/leith.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
<!-- on that project's page: its hero picture -->
<img src="/img/leith-wide.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
```

Each name may appear once per page. Browsers without cross-document view transitions simply navigate.
Study: 16 pages load a page-transition library and run full-screen curtains of 0.7-2.4 s; 6 use view
transitions. The new page should be readable within half a second.

### Ink fill

One statement that fills from part-ink to full ink as it rises through the screen, line by line. It is
readable before it fills; the fill only sets the pace.

```html
<p class="statement head" data-fill>Nine people, one workshop, no catalogue: every stool is made for the kitchen it goes to.</p>
```

```css
[data-fill] { --fill: var(--ink); color: var(--fill); }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .is-filling > span { display: inline-block; color: color-mix(in oklab, var(--fill) 45%, transparent); animation: ink-fill linear both; animation-timeline: view(); animation-range: cover 20% cover 45%; }
  }
}
@keyframes ink-fill { to { color: var(--fill); } }
```

```js
(() => {
  if (!CSS.supports('animation-timeline: view()') || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  document.querySelectorAll('[data-fill]').forEach((el) => {
    const words = el.textContent.trim().split(/\s+/);
    el.textContent = '';
    words.forEach((word, i) => {
      const s = document.createElement('span');
      s.textContent = word;
      el.append(s, i < words.length - 1 ? ' ' : '');
    });
    el.classList.add('is-filling');
  });
})();
```

Each word watches its own position, so the words of a line fill together as the line moves from three
quarters of the way down the screen to just past half way. Set `--fill` on a dark chapter to its ink.
Study: 14 pages scrub a statement from grey to ink; unread words started at about 25-30% and settled
frames still caught statements half pale, which reads as disabled text. Here: one statement per page,
three to five lines, 32px or larger, starting at 45% (about 3:1 against the ground, enough for large
text). Full ink without scroll timelines or under reduced motion.

### Rolling figures

For pages whose case is a handful of numbers: each digit rolls up to its value once, the columns a
beat apart. The real number stays in the text for screen readers and search.

```html
<p class="figure"><span class="display" data-roll>1987</span> the year the first stool was sold</p>
```

```css
.roll__digits { display: inline-flex; font-variant-numeric: tabular-nums; line-height: 1; }
.roll__col { display: inline-block; height: 1em; overflow: clip; }
.roll__strip { display: flex; flex-direction: column; transform: translateY(calc(var(--d) * -1em)); }
.roll__strip > span { height: 1em; }
@media (prefers-reduced-motion: no-preference) {
  .roll:not(.is-in) .roll__strip { transform: none; }
  .roll.is-in .roll__strip { transition: transform 1100ms var(--ease-expo) calc(var(--i) * var(--stagger-item)); }
}
```

```js
(() => {
  const els = [...document.querySelectorAll('[data-roll]')];
  if (!els.length) return;
  els.forEach((el) => {
    const value = el.textContent.trim();
    const said = Object.assign(document.createElement('span'), { className: 'sr-only', textContent: value });
    const shown = Object.assign(document.createElement('span'), { className: 'roll__digits' });
    shown.setAttribute('aria-hidden', 'true');
    let i = 0;
    for (const ch of value) {
      if (!/\d/.test(ch)) { shown.append(ch); continue; }
      const col = Object.assign(document.createElement('span'), { className: 'roll__col' });
      const strip = Object.assign(document.createElement('span'), { className: 'roll__strip' });
      strip.style.setProperty('--d', ch);
      strip.style.setProperty('--i', i++);
      for (let n = 0; n < 10; n++) strip.append(Object.assign(document.createElement('span'), { textContent: n }));
      col.append(strip);
      shown.append(col);
    }
    el.classList.add('roll');
    el.replaceChildren(said, shown);
  });
  if (!('IntersectionObserver' in window)) { els.forEach((el) => el.classList.add('is-in')); return; }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
  }, { rootMargin: '0px 0px -15% 0px' });
  els.forEach((el) => io.observe(el));
})();
```

Study: 6 pages roll digit columns (stats, a year, coordinates), about 0.9-1.1 s per column on an
expo-out, staggered by column. Use it for three or four figures at display size, never in running text
and never for a value that changes after load. Reduced motion: the figure is set at its value.

### Accordion

```css
.acc__panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 320ms var(--ease-out); }
.acc__panel > div { overflow: hidden; }
.acc[open] .acc__panel, .acc__item.is-open .acc__panel { grid-template-rows: 1fr; }
```

Animating `grid-template-rows` from `0fr` to `1fr` opens to the content's real height without measuring
it in JavaScript.

### Menu or dialog

```css
.menu { transform-origin: top right; transition: opacity var(--t-ui) var(--ease-out), transform var(--t-ui) var(--ease-out); }
.menu[hidden], .menu.is-closed { opacity: 0; transform: scale(0.97) translateY(-6px); pointer-events: none; }
```

Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
`scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.

## 7. Signature moves

The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.

| Move | What happens | Build with |
| --- | --- | --- |
| Masked headline | Lines rise from behind their baselines on load | Masked line reveal recipe |
| Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
| Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
| Pinned story | A heading holds while its items scroll past | Sticky recipe |
| Index with preview | Text rows; the hovered row shows its image | Index with preview, anchored to the row |
| Curtain footer | The last section is revealed from under the page as you reach the end | `position: sticky; bottom: 0` footer under a main with a background and `z-index` |
| Slow crossfade | Hero image crossfades through 3–4 photographs, 5–6s each | Stacked images, opacity keyframes, pause when the tab is hidden |
| Edge-to-edge marquee | One line of very large type travels sideways, slowly | Duplicate the content, `translateX(-50%)` linear loop, pause on hover and under reduced motion |
| Drag row | A horizontally scrollable row of large images with scroll snapping | `overflow-x: auto; scroll-snap-type: x mandatory`, no library |
| Colour by chapter | Each chapter is its own full-bleed colour field, so the ground changes at section edges; a fixed header takes the ink of the chapter beneath | Section backgrounds in CSS; `base.js` sets `--header-ink` from `data-ink`. Never recolour the whole page from an observer |
| Opening window | A type screen hands over to the big picture: a window in the middle of a held stage opens to full-bleed over about one screen | Opening window recipe |
| Ink-fill statement | One statement fills from 45% to full ink as it rises through the screen | Ink fill recipe |
| Sheet over a held picture | A full-bleed photograph holds for one screen; the next chapter slides up over it as an opaque sheet | A 100svh `position: sticky; top: 0` figure followed by a section with its own background and a higher `z-index`; no script |
| Sideways gallery | A short row of pictures moves sideways while the vertical scroll holds the stage | Sideways gallery recipe |
| Pushed line | One line of very large type is pushed sideways by the scroll, reading whole at the middle of its run | `animation-timeline: view()` on the line, `translateX` from 12% to -12%, linear; static where unsupported |
| Rolling figures | The few figures that make the case roll up to their values once | Rolling figures recipe |
| Carried picture | The picture clicked in a list travels into place as the next page's hero | Page to page recipe |

Pick by subject. A portfolio wants the index with preview; a restaurant wants the slow crossfade; a
product wants the shutter on its hero object. If the move says nothing about the subject, it is
decoration and the page is better without it.

Pick by subject, as before: the opening window for a product or a place with one great photograph; the
sheet for chapters that each own a picture; the sideways gallery for a short set of rooms, kitchens or
pieces; the pushed line for a brand whose claim is a word; rolling figures for a report, a fund or a
practice that leads with numbers; the carried picture for a portfolio whose index and case pages are
both built.

## 8. Reduced motion and performance

- `prefers-reduced-motion: reduce` means fewer and gentler, not none: keep opacity and colour changes,
  drop travel, scale, parallax and autoplay. All recipes above are written inside
  `no-preference` blocks so the reduced state is simply the finished state.
- Animate `transform`, `opacity` and `clip-path`. Animating `width`, `height`, `top`, `left`, `margin`
  or `padding` forces layout on every frame. `grid-template-rows` for accordions is the accepted
  exception because it is short and rare.
- Do not drive many children from one changing CSS variable on a parent; set the transform on the
  element that moves.
- `will-change` only on an element that is about to animate, removed afterwards.
- Pause loops when off screen or when the tab is hidden.
- No scroll hijacking and no custom cursor. They cost usability, which is 30% of an Awwwards score and
  the line most winners score lowest on. Smoothing only through `Stage.smooth()`, which stays off for
  touch and reduced motion.

## 9. What gives generated motion away

- The same fade-up on every block.
- A dozen staggered entrances in the hero.
- Hover lift or zoom on every card and image.
- Bounce or elastic easing on interface elements.
- Infinite loops that mean nothing: pulsing dots, floating shapes, shimmering text, rotating badges.
- A reveal that never fires and leaves a blank band in the page. Run `inspect.mjs` after the motion
  pass and look at every screenshot for missing content.

### Seen in the latest Sites of the Day

Seen in the study, with how often, and what the house does instead.

- **Preloaders and counters (60 of 99; 26 with a counter).** Half the usable pages were unreadable
  2.5 s after load, a third at 4.5 s; the preloaders sat mostly on pages that had already loaded, and
  25 of the 29 that loaded in under 3 s still showed nothing readable at 4.5 s. Counters stalled,
  outlasted the load or were tweens with nothing behind them. House: no preloader. If a scene must
  load, the HTML first screen (headline, picture, action) is there at once and the scene fades in
  behind it when ready.
- **Gates (10 of 99): enter, sound, age or gesture before any content.** House: none. Sound is a
  control on the page, off by default.
- **Scroll hijacking (16 wheel-driven scenes, 9 of them endless, plus 14 single screens where the wheel
  does nothing).** The visitor cannot tell where they are, how much is left or how to get out, and the
  keyboard and screen readers get nothing. Usability 6.01 against 6.50 for pages that scroll. House:
  native scroll; any scene is driven by scroll position on a sticky stage that ends, with a count.
- **Smooth-scroll libraries (67 of 99).** They make the scrubbed scenes feel weighted, and they also
  put a delay between the hand and the page; with a long ease the page keeps sliding after the wheel
  stops.
- **Custom cursors (28 of 99).** Followers, image trails, a lens that smears the headline it sits on,
  labels that only exist on hover; 10 of the 28 have any reduced-motion rule. House: the system cursor;
  labels are on the page.
- **Scramble and typewriter text (11 and 5).** The headline unreadable for four seconds, body copy
  garbled mid-scroll, actions held back until the typing ends. House: not used. Real words arrive whole.
- **Long held runways.** Pages with pins average twice the length for fewer words per screen; 17 pages
  are 20 screens or longer, some with whole screens that hold only a slow rotation. House: about one
  screen per step, three per stage, every held screen carries a line of text.
- **Content held at zero opacity.** Reveals that keep text invisible until the scroll settles, inactive
  items dimmed to 10-15%, statements left half pale. House: reveals only below the fold, once, never
  longer than 900 ms in view; dims no lower than 40%; fills start at 45%.
- **Motion over words.** Marquees and drifting objects that cross statements, a sliding line that crops
  its own words, blur left on text mid-move. House: nothing moves across text the visitor is reading;
  a moving line must read whole at some point of its run.
- **Overshoot and bounce (19 stylesheets).** House: still banned on interface elements.
- **Endless loops with no off switch.** Grain, pulsing dots, equalisers, spinning badges, running with no
  reduced-motion rule on most pages. House: loops pause off screen and stop under reduced motion.

Where the study's work does something the house bans, the house version: hairline progress bars and
tick rails on held stages become a plain count in the body face ("2 of 4"); zero-padded counters
("01/08") become a plain count or nothing; crossfading gradient grounds become flat chapter colours with
hard cuts; monospace HUD labels become small text in the text face.

## 10. Rules for motion

1. **First frame.** The headline, the sentence and the picture are in the HTML and painted on the first
   frame. The load sequence starts without waiting for the load event and is settled by 1.2 s, with
   four elements at most.
2. **No preloader, no counter, no gate, no custom cursor, no wheel capture.** Smoothing only through
   `Stage.smooth()` (Lenis at lerp 0.1-0.14, off for touch and reduced motion): weight, never a page
   that keeps sliding after the hand stops.
3. **Curves.** `--ease-out` for anything under 600ms; `--ease-expo` for reveals of 800ms or more;
   `--ease-in-out` for wipes and things that travel; `--ease-exit` only for things leaving for good;
   `linear` for scroll-linked motion and loops; plain `ease` for colour. No overshoot.
4. **Durations.** Press 140ms; hover colour 180-200ms; hover that moves 300-400ms; menus 240ms; reveals
   700-900ms; picture openings 1,100-1,300ms; exits 60-70% of the matching entrance.
5. **Staggers.** Lines 80-110ms; list items 50-90ms; characters 20-40ms and only in a heading of one to
   three words; four steps at most in any one sequence.
6. **Reveals.** Fire once, when the element's top is 85-90% of the way down the viewport; rise 12-32px
   or from a line mask; nothing in view stays hidden for more than 900ms; nothing above the fold waits
   for scroll.
7. **Scroll scenes are where the page is bold.** The big moves come from the scene library
   (`references/scenes.md`): the direction sheet deals two or three per page, each built on
   `assets/scenes/stage.js`, each held for one to four screens, each with a phone version and a
   finished composition. Inside a scene the scene's own numbers apply (a picture may grow from a
   window to the whole screen, type may cross the stage, a camera may fly through a field of
   pictures); rules 8 and 10 still hold for the text a visitor is reading.
8. **Outside the scenes, scroll-linked travel stays quiet.** Linear and locked to the scrollbar;
   parallax under 8% of the element; scale between 0.94 and 1.15; built with scroll timelines inside
   `@supports`, static without them. Other held stages: sticky, about one screen per step, three
   screens at most, a line of text on every held screen.
9. **One of each.** One signature move (a dealt scene can be it); one moving picture; one marquee at
   most. A quiet section between two scenes, so each one lands.
10. **Text stays readable.** Fills start at 45% ink; dims no lower than 40%; blur at most 8px and gone
    once settled; nothing moves across text being read.
11. **Colour by chapter** as hard cuts at section edges; the fixed header's ink follows in 300ms.
12. **Horizontal motion.** The native drag row first; the pinned sideways gallery only for rows under
    three screens long, with keyboard support.
13. **Page to page.** View transitions, 480ms at most, off under reduced motion.
14. **Reduced motion.** Every hidden state lives inside `prefers-reduced-motion: no-preference`; the
    reduced page is the finished page; loops stop. Two in three award pages skip this, which is a
    reason to do it, not a licence to skip it.
references/research.md›
# The study

Everything numeric in this skill comes from here. Cite these figures; do not round them up or invent
others.

## Contents

1. Method and its limits
2. Scores
3. Where the idea comes from
4. Headlines
5. Type
6. Devices: what separates strong from weak, and what does not
7. Colour and shape
8. Imagery and page structure
9. What the inspector says about the gallery itself
10. The 25 highest-scored sites and their ideas
11. How the skill was tested
12. The October 2026 study: 100 Sites of the Day
13. The app-screen study

## 1. Method and its limits

- **Sample.** 248 sites collected on 5 October 2026: 156 from One Page Love (the latest gallery pages
  and the category listings) and 92 Awwwards Sites of the Day (the three most recent listing pages).
  247 loaded; 246 were re-measured with the final probe.
- **Capture.** Headless Chrome at 1440×900 and 390×844. For each site: the first viewport, five more
  frames while scrolling with real wheel input, the mobile first viewport, and a measurement pass in
  the page (computed styles, text geometry, colours, fonts actually loaded).
- **Panel.** 21 model reviewers each studied about 12 sites from the screenshots and the measurements,
  described them (idea, hero, type, colour, imagery, layout, sections) and scored them the Awwwards
  way: design 40%, usability 30%, creativity 20%, content 10%. These are model judgements from still
  frames, not human jurors and not the real Awwwards scores. Batches mixed both sources so each
  reviewer saw a spread.
- **Usable sets.** 235 sites were usable for the panel (12 showed only a preloader, an entry gate or a
  bot wall). 202 were measurable for typography (a page with at least 25 words and a detectable
  headline). "Top" below means panel score 7.5 or higher (82 measurable sites); "weak" means under 6.5
  (23 measurable sites).
- **Limits.** One capture per site on one day. Some heroes were caught mid-animation or behind a cookie
  notice. WebGL-heavy sites are under-described by text measurements. The eyebrow detector counts any
  small short label directly above a large heading, so it over-counts against the panel's stricter
  reading (60% measured against 35% observed). Motion was inferred from frames, not watched. The sample
  is whatever the two galleries had recently featured; it is fashion as much as quality.

## 2. Scores

| | All 235 | One Page Love (151) | Awwwards (84) |
| --- | --- | --- | --- |
| Median overall | 7.2 | 7.1 | 7.5 |
| Middle half | 6.8–7.6 | | |
| 8.0 or higher | 25 sites (11%) | | |
| 7.5 or higher | 90 sites (38%) | | |
| Under 6.5 | 29 sites | | |

Median by criterion: design 8, usability 7, creativity 7, content 7. Among the top 25: design 9,
usability 7, creativity 8, content 8. Usability is the one line that does not rise with the rest: the
best sites are no easier to use than the average one. Do not copy their gates, preloaders, tiny text
or hidden navigation.

## 3. Where the idea comes from

The clearest pattern in the panel's notes: high scores go with a concept taken from the subject, and
low scores go with its absence. The weakest entries' ideas were recorded in words like "none beyond the
standard dark SaaS launch formula", "no visual idea beyond clarity", "no strong idea". The strongest
were sentences only that subject could own (section 10).

Recurring engines among the 7.3+ sites, each the basis of a direction in the library:

- **The product demonstrates itself.** A colour picker's loupe reads the page's own background; a list
  tool is sold with tiny working lists; a photo app's page is a wall of its own output.
- **The page is an artefact from the subject's world.** A wedding site that is the invitation; an
  archive as tabbed folders; a coupon with a cut line; a portfolio as a contact sheet.
- **The name is the hero.** Wordmarks as wide as the screen, a photograph set inside the name like a
  letter, a name split to opposite corners with the page between.
- **One motif from the subject drives everything.** A tool's octagonal section as button, mask and
  loader; two circles for two accounts; a café's stripes.
- **Dressed as another category.** A money app as a streetwear drop; an industrial roaster as a design
  object; a parcel service as a fairground.
- **The place carries it.** One full-bleed view and a name along the bottom edge.
- **A fixed stage.** A title or object stays put while content swaps beneath it.
- **One section, one colour.** Flat fields as chapters.
- **Radical understatement.** A 27px headline and tiny live demonstrations, carried through completely.

Hero types, by the panel's classification (top sites against weak sites): full-bleed media with type
over it 16% / 10%; giant wordmark 14% / 0%; typographic poster 13% / 7%; text beside media 10% / 3%;
**centred statement with media below 7% / 24%**. The centred headline over a screenshot is the weak
sites' signature layout.

## 4. Headlines

| Hero headline | All | Top | Weak |
| --- | --- | --- | --- |
| Words, median | 5 | 5 | 5 |
| Words, middle half | 3–9 | 2–8 | 3–8 |
| Characters, median | 32 | | |
| One line at 1440 | 47% | | |
| Two lines or fewer at 1440 | 70% | 71% | 83% |
| Two lines or fewer at 390 | 57% | | |
| Three lines or fewer at 390 | 82% | | |

Awwwards heroes are shorter (median 4 words, 76% within two lines) than One Page Love's (6 words, 66%).
Section headings have a median of 3 words. Length does not separate strong from weak; nearly everyone
in the gallery writes short headlines. The two-line limit in this skill is a house rule that most of
the gallery already meets on desktop and that takes care to meet on a phone.

## 5. Type

| | All | Top | Weak |
| --- | --- | --- | --- |
| Hero size, median | 72px (5.0vw) | 6.0vw | 4.7vw |
| Hero size, middle half | 3.0–7.6vw | 2.5–9.7vw | 2.7–6.1vw |
| Largest text on the page, median | 6.4vw | 8.4vw | 5.0vw |
| Largest text, upper quarter starts at | 10.4vw | 12.5vw | 6.7vw |
| Hero on a phone, median | 40px | | |
| Hero tracking, median (middle half) | -0.01em (-0.03 to 0) | | |
| Hero leading, median (middle half) | 1.0 (0.95–1.1) | | |
| Body size, median (middle half) | 16px (14–18) | | |
| Headline ÷ body, median | 4.3 | 4.7 | 4.0 |
| Typefaces carrying 3%+ of text | one 31%, two 45%, three 22% | | |
| Distinct font sizes, median | 8 | | |

Hero weight: regular 48%, medium 18%, semibold 6%, bold or heavier 22%, light 6%. Capitals 24%.
Centred 39% overall, 52% on weak sites. Display classes by the panel: neo-grotesk 48%, transitional
serif 11%, geometric sans 10%, condensed 6%, extended 6%, old-style serif 4%, custom lettering 4%.

Strong sites push the extremes: their biggest type is bigger and their range is wider. Weak sites
cluster in the middle.

## 6. Devices: what separates strong from weak, and what does not

Measured in the page unless marked (panel).

**More common on weak sites (the data supports avoiding them):**

| Device | Top | Weak |
| --- | --- | --- |
| Icon-topped feature cards (panel) | 1% | 28% |
| Rows of identical cards with a small icon (measured) | 7% | 26% |
| Pill badges (panel) | 14% | 34% |
| Pill-shaped label above a heading (measured) | 5% | 26% |
| Tiny uppercase letter-spaced labels, any | 16% | 57% |
| Tiny uppercase letter-spaced labels, three or more | 6% | 43% |
| Gradient fills (not counting scrims) | 22% | 52% |
| Gradient text | 2% | 13% |
| Shadows on resting elements, any | 24% | 48% |
| Shadows, five or more | 10% | 30% |
| Emoji in the page | 10% | 26% |
| Eyebrow labels (panel) | 32% | 45% |

**No real difference:**

| Device | Top | Weak |
| --- | --- | --- |
| Backdrop blur anywhere | 32% | 39% |
| Italic-serif accent word (panel) | 4% | 7% |
| Thin rules, any | 61% | 52% |

**More common on strong sites (banned here anyway, as house style):**

| Device | Top | Weak |
| --- | --- | --- |
| Monospace labels (panel) | 44% | 24% |
| Monospace anywhere outside code (measured) | 45% | 26% |
| Index numbering (panel) | 38% | 21% |

Monospace captions, index numbers and ruled grids are the present fashion of award sites. They are
excluded from this skill by the owner's choice, not by the data: they are the devices generators copy
first, and a page that cannot lean on them has to find structure in space, tone and scale.

## 7. Colour and shape

- Dark page ground 41%; pure white 25%; pure black 12%; a warm off-white 13%.
- Scheme (panel): monochrome plus one accent 23%, light neutral 22%, dark neutral 14%, several flat
  colours 13%, two-tone 12%, photographic 9%, one colour field 6%.
- Primary buttons are neutral (black, white, grey) on 52% of sites. One site in 246 has a terracotta
  button; none has a violet one.
- Median number of distinct section grounds: 2 on top sites, 1 on weak sites.
- Button shape: top sites square (0–2px) 52%, fully round 23%, small radius 19%, medium (9–16px) 6%.
  Weak sites fully round 59%, medium 24%, square 18%.
- Media corners are square on 73% of sites; where rounded, the median radius is 12px.
- Buttons in the first viewport: none on 55% of top sites and 39% of weak ones.

## 8. Imagery and page structure

- Hero media covers at least half the first viewport on 60% of sites (top 68%, weak 48%) and nearly
  all of it on 50% (top 56%, weak 39%). No hero media: 15%.
- Video is present on 49% of sites and a canvas element on 49% (top 62%, weak 35%).
- Images per page, median: 24 overall, 37 on top sites, 5 on weak ones.
- Imagery kind (panel): mixed 32%, photography 14%, generative or WebGL 13%, product interface 12%,
  3D render 11%, illustration 9%, video 6%, type only 3%. Median panel score is lowest for
  illustration-led (6.9) and product-interface-led (7.1) pages and highest for video and type-only
  (7.5).
- Page length, median: 8.7 screens and 575 words (middle half 1.6–14.4 screens, 250–956 words). A
  quarter of the sites are a single screen or two.
- Navigation: a median of 3 links; fixed or sticky on 66%.
- Density (panel): airy 46%, balanced 45%, dense 9%. Alignment: centred 33%, mixed 31%, left-anchored
  17%, modular grid 13%, free 6%.
- Asked to describe each site's most interesting sections, the panel picked a pricing, testimonial or
  FAQ section only 7, 12 and 9 times across 235 sites. Where strong sites have them at all, they are
  not where the design effort goes, and they avoid the stock form.

**From the panel's written notes on the 114 sites scoring 7.3 or more** (hand tallies, approximate;
`layout.md` turns these into practice):

- Outer margins of 32px or less on about 45; 80px or more on about 8. A centred 700–1300px container
  on about 15, several of them the pages the panel called stock.
- Text anchored to a fixed off-centre line on about 22. Text docked in corners or at opposite ends of
  a baseline on about 25 and 15.
- Two or three type sizes with nothing between on about 30; no note describes a graded five-step
  scale.
- One thing per screen on about 20. A full background colour per section on about 20; alternating
  light and dark on about 12.
- Every section recomposed on about 14; one planned exception to the page's own system on about 14.
- No conventional top navigation bar on about 45; small corner links on about 22; a single link or
  button on about 10.
- Endings: the name at full width about 18, the hero repeated inverted about 12, a closing scene of
  its own about 14, no footer or a single line about 22. Link-column footers are mentioned only as
  criticism.
- Motion (inferred from frames): autoplaying video about 50; pinned or fixed stages about 40; text
  reveals about 30 (blur-in 10, masked line rises 8, scroll-tied word colouring 7); horizontal strips
  about 27; marquees about 26; media swapped behind a fixed element about 15; scroll-linked scaling
  about 12. Rare: hover effects (4 mentions), parallax (6), accordions (5), bounce or spring motion
  (none). About 10 strong sites show little or no motion at all.
- How weak sites fail (54 sites under 6.8): the category's stock template about 20; centred headline,
  pill badge, two buttons and a screenshot about 9; stock modules after a distinctive hero about 8,
  plus about 17 in the middle band; a characterless default typeface about 13; text too small or too
  faint about 13; never saying what the thing is about 12.
- Faults of the best sites (90 at 7.5 or more), not to be copied: text at 9–13px about 24;
  information hidden behind the concept about 20; the idea running out mid-page about 15; text over
  footage with no scrim about 12; gates, preloaders and blank first paints about 12.

## 9. What the inspector says about the gallery itself

Running this skill's own `inspect.mjs` rules over the 202 measurable gallery sites: the median site
has 4 `FAIL`s and only 4% have none. The rules that fire most: headline over two lines somewhere on
the page 60%, eyebrow-like label 60%, thin rules 50%, monospace 37%, shadows 32%, gradients 29%, index
numbering 29%.

So the house style is deliberately stricter than the gallery. It is not a description of award sites;
it is a narrower target inside what they do: their scale, their ideas, their imagery and restraint,
without the ornaments that have since become a uniform.

## 10. The 25 highest-scored sites and their ideas

The panel's one-sentence reading of each. Notice that every one names something only that subject
could own. (Several use devices this skill bans; take the idea, not the ornament.)

| Site | Source | Score | Idea |
| --- | --- | --- | --- |
| PP Neue Montreal | Awwwards | 8.6 | A typeface specimen dressed as a vintage city travel guide: the font named after Montreal is sold through guidebook titling, postcards and old tourist footage. |
| Seasats | Awwwards | 8.4 | Calm, dusk-lit seascapes sell autonomous boats as serene instruments, and the navigation is a column of stacked colour swatches taken from that sea-and-sand palette. |
| AI in Design Report 2026 | Awwwards | 8.4 | A research report laid out like a colour-blocked print annual: a video collage on top, an oversized tight grotesk title, and flat orange, lilac, sage and black blocks carrying stats, quotes and chapters. |
| House of Honey | Awwwards | 8.4 | A block-heavy wordmark pierced by a fine script: the bold-against-delicate contrast is repeated at every scale, with giant pink script behind quiet serif statements. |
| White Desert | Awwwards | 8.4 | One word, the destination, set a third of the screen tall over footage like a film title card; sheer scale stands in for remoteness. |
| Applied Imagination | One Page Love | 8.3 | An essay conceived on a flight is read as a descent: an altimeter ticks from 30,000 ft to zero while you scroll down through clouds to mountains at ground level. |
| Cursor Compile | One Page Love | 8.3 | The event name drawn as seven looping one-line curves in seven colours, more signature than logotype, on an otherwise whisper-quiet off-white page. |
| The New Industrials | One Page Love | 8.3 | A long-read essay laid out inside a drafting grid, where colour fields shift as you read and machine parts drift through the margins and finally assemble into the publisher's initials. |
| Wild | One Page Love | 8.3 | A studio essay in which everything decorative is drawn on one square-pixel grid: the hero heatmap, the icons, the diagrams, the lettering and the footer. |
| Hildén & Kaira | Awwwards | 8.3 | The agency name is cast in liquid chrome across the full width, and the same chrome becomes the objects that float through the page, tying the pitch to one material. |
| PX Push | Awwwards | 8.2 | A design subscription presented as a retro broadcast: the entire site is seen through a CRT screen, with a chrome logo token as its mascot. |
| Illoca | Awwwards | 8.2 | Software for architects sold as a drafting table: a two-colour illustrated studio on graph paper that the scroll pushes into until the drawing fills the screen. |
| Aspen Search | Awwwards | 8.2 | The page is a tile system with no margins: the two-word name is split across cells that alternate type, dithered image, black and one mint green. |
| Dragonfly Redux | Awwwards | 8.2 | A crypto fund presented as a terminal artefact: every picture is rendered in characters, framed by four letters of the name pinned to the viewport corners. |
| Monolog | Awwwards | 8.2 | A studio pitch run as an asymmetric sheet: a narrow left column carries the sales talk while a wide right column stacks a very heavy headline and the proof. |
| The Queue | One Page Love | 8.1 | An essay on queueing where the queue is the ornament: a line of drawn figures stands along the bottom of the hero and returns between chapters in changing period dress. |
| Paul Kalkbrenner | Awwwards | 8.1 | Swiss poster typography for a techno artist: the name as one black line with a photo set inside it like a glyph, then every chapter gets its own flat colour. |
| Vero New York | Awwwards | 8.1 | A keepsake treated like couture: a slow close-up film opens, then a white sculpture of a dress stays pinned at centre while one sentence passes over it. |
| Depo Luxe | Awwwards | 8.1 | A film production roster shown as a full-screen reel in which every step is one film with only a title, a director and a client, set like credits at body size. |
| Longbow | Awwwards | 8.1 | The page is built on a visible four-column construction grid, and cars, cards and a giant condensed marquee are slotted into its cells like objects on an artboard. |
| Mosby's Files | Awwwards | 8.0 | An archive of architects presented as a physical stack of coloured index-tab file folders that you flip through. |
| Sarah & Matt Wedding | One Page Love | 8.0 | The site is the invitation: a letterpress-style card opens the page, and its ornaments and small typographic parts become the components for everything after it. |
| Zara Webb | One Page Love | 8.0 | A one-woman newborn-care practice presented like a hand-made keepsake: a mascot rabbit, scalloped and flower-shaped cut-outs, candid film photographs. |
| Noho | Awwwards | 8.0 | Furniture sold through people misusing it (wearing, balancing on, hiding behind chairs on flat colour backdrops) while the interface stays quiet. |
| Juggle | One Page Love | 8.0 | The headline literally drowns: a flat blue field rises to half height and the lines below the waterline change colour. |

Full notes on any studied site: `node scripts/draw.mjs --site <id>` (ids appear on direction sheets).

## 11. How the skill was tested

Six client briefs (an invoicing tool, a restaurant, a designer's portfolio, a ceramic product, a
guesthouse, a design conference), each with no photographs. For every brief a fresh agent built the
page three ways: with no skill (baseline), with another published design skill (atelier, three briefs)
and with this skill. Every build was photographed by `inspect.mjs` in the same way and judged blind
by a separate agent with the juror brief (Awwwards weights, anonymous A/B/C folders, screenshots
only). The skill was revised after each round from the jurors' reasons and the builders' notes.

| Round | Briefs | This skill | Baseline | Atelier | Wins |
| --- | --- | --- | --- | --- | --- |
| 1 | 4 | 6.6 | 7.7 | 6.8 | 1 of 4 |
| 2 | 3 | 7.1 | 8.0 | | 0 of 3 |
| 3 | 3 | 7.5 | 7.5 | | 1 of 3 |
| 4 (unseen briefs) | 3 | 6.9 | 7.9 | 7.5 | 0 of 3 |
| 5 (same three) | 3 | 7.7 | 7.8 | 7.2 | 2 of 3 |

Under the inspector, the final builds measured 0 FAIL each; the baselines measured 8 to 13 FAIL each
(eyebrows, monospace labels, 01/02/03 numbering, hairlines, gradients, shadows, three-line headlines,
text under 13px).

What the jurors rewarded, in the order they named it: an idea that belongs to this client and not to
the category (the competitor test); one live thing on the page; full content at a readable size; a
first screen with one dominant element and a real button; drawings of the subject with detail. What
they punished: a quiet page that read as empty, a small headline, text sliding over pictures or cut
at the top, a page recoloured mid-section, rows cut at the right edge, a large sentence over three or
more lines (read as a long headline whatever its markup), navigation that took over the first screen.
Each of these is now a rule in SKILL.md or a check in the inspector.

Limits: one juror per brief per round, so single scores carry about half a point of noise; the round-5
briefs had been seen in round 4; jurors are models, not people.

## 12. The October 2026 study: 100 Sites of the Day

A second study, made for v2.1, of the latest 100 Awwwards Sites of the Day (10 July to 17 October
2026), with a different aim from the first: where the pictures and footage sit, how pages move, and
the frame (margins, balance, empty space, titles). About three in four of these sites were also in
the first study.

- **Capture.** Headless Chrome at 1440×900 and 390×844 on 18 October 2026: four frames of the first
  4.5 seconds after load, every screen while scrolling with real wheel input (one frame at rest per
  step; the mid-step frames were cut from the top of the document instead of the viewport, so they
  were not used), five phone screens, and a measurement pass in the page: every picture and
  clip with its box, ratio, fit and corners; every section's content edges and pictures; blank runs;
  the CSS that loaded (curves, durations, keyframes, breakpoints, feature counts); the animations,
  transitions and script-driven moves that actually ran; GSAP tweens and scroll triggers where the
  page exposed them. Nothing a site owns (code, copy, pictures) is reproduced in the skill; only
  measurements and descriptions in our own words.
- **Panel.** One model reader per site studied its frames and measurements, described the idea, the
  first screen, the frame, every section, where each picture sits, the motion and what not to copy,
  and scored it the Awwwards way. 91 of 99 captures were usable (the rest showed a gate, a bot wall or
  a firewall page). Scores ran 6.7 to 7.9 (median 7.45); "top" means 7.5 or more (36 sites), "lowest"
  under 7.3 (16).
- **What it changed.** Where pictures and footage go: `imagery.md` §12. What strong pages do with
  motion, new tokens, recipes and the gimmicks to avoid: `motion.md` §2, §4–6 and §9–10. Empty space
  and the frame: `layout.md` §4–5. New compositions in `library/heroes.json` and `sections.json` and
  notes on media and motion in `library/sites.json` (`draw.mjs --site <id>`).
- **Headline numbers.** Pictures fill a median 16% of a scrolled screen, but in whole screens: 42% of
  screens hold none and 19% are 90% picture or more. The median page carries 7 pictures (top 8,
  lowest 2) and 42% carry footage (half of the top sites, one of the 16 lowest). 94% of pictures have
  square corners. Strong ease-outs are the curves that ran (expo and quint out); the median of all
  that ran on a site is 420ms. 60 of 99 open on a loading screen, and a third of the usable pages were
  still unreadable 4.5 seconds after load, with no gain in the design marks. About 1 text section in
  100 leaves half the width empty; the outer margins of strong pages match to a few pixels; 82% of
  text sections open with a heading; 15 of 97 first screens never say what the thing is, and those
  lose usability points.
- **Limits.** One capture per site on one day; a WebGL scene can render blank in a headless browser;
  the scroll-linked frames are partly an artefact of smooth-scroll libraries; one reader per site, so
  single scores carry about half a point of noise.

## 13. The app-screen study

For dashboards and app screens (v2.1), a separate study of 23 real product screens chosen by the
owner (17 products, 17 light and 6 dark, at 1440–1512px): each screen measured from its pixels by a
reader with full-resolution crops, six cross-cutting reports (shell, data and charts, tables and
forms, visual language, taste, styles), and a synthesis into `references/app-screens.md` and
`library/app.json` (seven styles, five shells, screen patterns). The screenshots stay private; the
skill carries only the measurements and the rules. Blind test rounds of app screens built with and
without the skill then shaped the warnings at the top of that file.
references/scenes.md›
# Scroll scenes

The big moments of a page: stretches of scroll where the page stops being a column of sections and
becomes a scene. A camera flies through a field of pictures, a picture grows from a window to the whole
screen, a word buckles under the scroll, a set of photographs orbits and lands in a grid. These are what
visitors remember from award sites, and what a page built from tidy sections alone never has.

The scene library is taken apart from recent Awwwards Sites of the Day (October 2026: 43 sites recorded
frame by frame while scrolling, 261 scenes described, 30 types), rebuilt from scratch in our own code,
tested frame by frame at desktop and phone width and under reduced motion, and reviewed against the
scenes it came from. Nothing a site owns is in them. Until `library/scenes.json` ships the draw deals
none; a page that needs a scene builds one on the stage (section 8), with every rule here.

## Contents

1. What a scene is
2. Putting a scene on a page
3. Filling it
4. Fitting it to the page
5. The phone, reduced motion, no script
6. Checking it
7. Rules for scenes
8. When no scene in the library fits
9. The catalogue

## 1. What a scene is

A tall section whose stage holds still while the page scrolls past it. The stage is
`position: sticky`; the section is (hold + 1) screens tall, so the stage is held for `hold` screens;
a GSAP timeline is scrubbed from 0 to 1 by that scroll. Nothing captures the wheel: the scrollbar always
tells the truth, the keyboard and the trackpad work as everywhere else, and the scene ends.

What a scene is not: a fade-up, a small parallax, a sticky footer, a marquee. Those are the quiet layer
(`references/motion.md`). A scene changes the whole screen.

The direction sheet deals two or three (section 9 of the sheet), from different families, each placed
on the section of the argument it can carry. Build them in step 6, with that section: a scene is
composition, not decoration added at the end.

## 2. Putting a scene on a page

Copy the shared stage and the dealt scene's files into the project (`assets/scenes/stage.css`,
`stage.js`, `<id>.css`, `<id>.js`). Each scene file's header comment lists the markup it expects and
its options; `assets/scenes/demo/<id>.html` is a complete working page to read beside it.

```html
<head>
  <script>document.documentElement.classList.add('js')</script>
  <link rel="stylesheet" href="scenes/stage.css">
  <link rel="stylesheet" href="scenes/<id>.css">
</head>
<body>
  …
  <section class="scene" data-scene="<id>" data-options='{"hold": 3}' aria-label="…">
    <div class="scene-stage">
      <!-- the markup from the scene file's header, filled with this page's content -->
    </div>
  </section>
  …
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollTrigger.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
  <script src="scenes/stage.js"></script>
  <script src="scenes/<id>.js"></script>
  <script>Stage.smooth(); Stage.init();</script>
</body>
```

- Load SplitText (`[email protected]/dist/SplitText.min.js`) only when a scene file says it needs it.
- `Stage.smooth()` adds weighted wheel scrolling (Lenis at lerp 0.12). It stays off for touch and for
  reduced motion. Leave it out on a quiet page; the scenes work either way.
- `Stage.init()` starts every `[data-scene]` on the page. A page with two scenes loads both files.
- The versions are pinned. Do not change them to `@latest` or an unpinned major.

## 3. Filling it

A scene is only as strong as what goes in it. The scene file's header and the sheet's "Content" line
say how many pictures and words it takes; these hold for all of them:

- **Pictures of one family.** Same light, same grade, same kind of subject: a field of mismatched stock
  photographs breaks the illusion of one space faster than any timing error. Real pictures of the
  subject's world (`assets.mjs`), the project's own, or the dealt photographs, in that order
  (`references/imagery.md`). Size each to its largest display size, set `width` and `height`.
- **Words that read in a breath.** A held statement is 4 to 12 words; captions are one to four words in
  the text face, sentence case. One statement on screen at a time.
- **The scene says something about the subject.** A fly-through of the studio's work, the route a
  shipment really takes, the four rooms of the house, the product's change of state. A scene filled
  with unrelated pictures is a gimmick and reads as one.
- **Real content only.** If the scene needs eight pictures of the subject and there are three, take the
  `--redraw scenes`, never pad it with stand-ins.

## 4. Fitting it to the page

- The scene files read the page's tokens: `--ground`, `--ink`, `--muted`, `--accent`,
  `--font-display`, `--font-text` (with fallbacks). Set them on `:root` from the sheet and the scene
  takes the page's colour and type. Check the demo against your page, not the other way round.
- Display type in a scene uses the dealt display face and the two-line headline rule; statements stay
  at statement size.
- Leave a quiet section between two scenes (a text section, a list, a plain picture) so each one lands.
  Two scenes back to back read as one long ride and the second loses.
- A scene on the first screen must be readable at progress 0: the title, the sentence and the action are
  there on the first frame (motion rule 1), and the scene starts moving only when the visitor scrolls.
- Give the section a heading. If the design has no visible heading in the scene, the section still
  carries one (`aria-label` on the section and a real heading in the finished composition).

## 5. The phone, reduced motion, no script

- **The phone.** Each scene has a phone plan in its file: the same scene with changed numbers, a
  simpler version, or `phone: 'rest'`, where the phone shows the finished composition. Touch scrolling
  is never smoothed. Look at the phone sheet as carefully as the desktop one.
- **Reduced motion and no script.** The section is an ordinary section showing the finished
  composition: the same content, laid out to read without motion. Nothing is hidden waiting for a
  script. The inspector's `reduced-hidden` check and the reduced sheet of `scenes.mjs` show it.
- **A failed CDN** falls back to the finished composition too (the stage adds `.is-rest`). The
  inspector fails a scene that rests on desktop (`scene-not-live`), so a broken script cannot pass.

## 6. Checking it

```bash
node <skill>/scripts/scenes.mjs <page>        # every scene at 0, 10 … 100%, desktop and phone, and reduced motion
node <skill>/scripts/inspect.mjs <page>       # the gate; with scenes on the sheet it also films the scroll
```

`scenes.mjs` writes one contact sheet per scene and width to `.taste-skill/scenes/`. Read every one.
At every frame ask: is there one clear thing to look at; can the statement on screen be read; is
anything popping, blank, cut off or crossing the words; is it as bold as it should be? The inspector's
`desktop-film-NN.jpg` shows the whole page's scroll a third of a screen at a time: check that the
scenes and the quiet sections between them make a rhythm, and that nothing between them is empty.

## 7. Rules for scenes

1. **Two or three per page,** from different families, a quiet section between them. The inspector
   warns over four (`many-scenes`) and when scenes hold more than 60% of the page (`scene-heavy`).
2. **Each holds one to four screens:** about one screen per beat of the story; a hold with nothing new
   to show is cut, not padded.
3. **Native scroll only.** No wheel capture, no locking the page, no scenes that only run on a click.
   Smoothing only through `Stage.smooth()`.
4. **Readable at its rest points.** The statement on screen reads whole; nothing passes over text while
   it is being read; text on a picture has a flat scrim or a calm area under it.
5. **Inside a scene the scene's numbers apply** (motion rule 7): a picture may grow from a window to the
   whole screen, type may cross the stage, a camera may fly through pictures. Outside scenes, the quiet
   limits of `references/motion.md` hold.
6. **Transform, opacity, clip-path.** Nothing that forces layout on every frame. Pictures decoded before
   they move.
7. **The house rules hold inside scenes:** no monospace captions, no 01/07 counters (a plain "3 of 7" if
   a count is needed), no gradients (flat scrims), no shadows or glows, no hairline decoration, no
   decorative particles, no violet.
8. **The finished composition is designed,** not an afterthought: it is what reduced motion, a failed
   script and some phones see.

## 8. When no scene in the library fits

Redraw first (`--redraw scenes`). If the brief really needs a scene the library does not have, build it
on the same stage so it inherits the guards:

```js
Scenes['my-scene'] = (root, opts = {}) => Stage.scene(root, {
  hold: opts.hold ?? 2,          // screens the stage is held for
  scrub: opts.scrub ?? true,     // true when Lenis runs; 0.4-0.8 without it reads as weight
  phone: 'same',                 // or 'rest', or { hold, build } for a phone plan
  build(tl, { q, phone }) {      // positions are fractions of the scene, 0-1
    tl.fromTo(q('.picture'), { clipPath: 'inset(30% 30% 30% 30%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.6 }, 0)
      .to(q('.title'), { yPercent: -40, autoAlpha: 0, duration: 0.25 }, 0.7);
  },
});
```

and write its CSS finished composition first, then the live layout under `.scene.is-live`. Check it
with `scenes.mjs` like any other.

## 9. The catalogue

(Generated from `library/scenes.json`; the draw deals from it. `node <skill>/scripts/draw.mjs --list
scenes` prints it.)
references/tells.md›
# Tells — the dated registry

A tell is a device that makes a page read as generated. Tells are not bad design in themselves; most
began as good ideas on good sites. They became tells by being produced by default, everywhere, without
a reason. That is why this registry is dated, and why it never names a replacement font, colour or
component: every published "use this instead" list has become the next wave of tells within months.
Replacements here are procedures (draw from the library, derive from the subject), not products.

`scripts/inspect.mjs` and `scripts/lint.mjs` detect most entries. Status: **banned** means the
inspector fails the page; **warned** means it flags it and you need a reason.

## Contents

1. First wave (2023–2024)
2. Second wave (2025)
3. Third wave (2025–2026): the "fix" that became the look
4. Gallery fashion, banned as house style
5. Structural tells
6. Copy tells
7. Motion tells
8. Why removal is not enough

## 1. First wave (2023–2024)

| Tell | Status | Do instead |
| --- | --- | --- |
| Violet / indigo gradients on hero, buttons and glows | banned | The dealt palette; flat colour |
| Gradient-filled headline text | banned | Solid ink |
| Glass panels: backdrop blur, translucent cards | warned (2 or more) | Opaque surfaces one tone step from the ground |
| Glowing or gradient "Get started" button | banned | Flat button named for its action |
| Soft grey drop shadow under every card | banned | Tone step, or no box |
| Three identical feature cards, each with an icon in a rounded square | banned | The dealt section blueprint |
| Emoji as icons or bullets | banned | Words, or a real picture |
| Centred headline, subtitle, two buttons, screenshot below | no rule; the draw avoids it | The dealt hero |
| The same typeface on every generated page | warned (lint: default-font) | The dealt type system |

## 2. Second wave (2025)

| Tell | Status | Do instead |
| --- | --- | --- |
| Eyebrow / kicker: tiny tracked uppercase label above every heading | banned | Fold it into the heading, or delete |
| Pill badge above the hero headline ("New", "Beta", "Introducing") | banned | Say it in the supporting sentence |
| One word of the headline in italic serif, colour or underline | warned | One face, one colour |
| Bento grid of rounded tiles as the feature section | warned as card rows | A composed section; tiles only in the dense-tiles direction, filled, strict sizes |
| Near-black ground with one neon accent and a glow | banned via shadow, gradient | A dealt dark palette, no glow |
| Floating rounded pill navigation with blur | warned | A plain bar |
| Fake dashboard hero built from grey bars and donut charts | no rule; see imagery.md §7 | A real interface fragment, or no image |
| Decorative status dot ("Available for work", pulsing "live") | lint: bounce | Remove |
| Fade-and-rise on every section | no rule; see motion.md | One signature move |

## 3. Third wave (2025–2026): the "fix" that became the look

The remedy published for the first two waves was "warm it up and make it editorial". Models adopted
the remedy wholesale and it is now the most recognisable generated look of all.

| Tell | Status | Do instead |
| --- | --- | --- |
| Yellow-cream ground + terracotta / rust / clay accent | banned together; terracotta banned alone; cream warned | The dealt palette. Warmth from a low-chroma stone, a deep ink, a photograph, or a full warm field |
| High-contrast or soft serif display as the shortcut to "premium" | no rule | The dealt type system, whatever class it is |
| Oversized italic serif headline | no rule | As dealt |
| Broadsheet cosplay: hairline rules everywhere, zero radius, dense columns | banned via hairline | Structure from space and tone |
| "Field notes", "Quietly trusted by", "On our desks": performative labels | lint: copy | Plain section names |

## 4. Gallery fashion, banned as house style

These are common on award sites right now. In the study they appear *more* often on the top-scored
sites than on the weak ones (panel observations: monospace labels 44% against 24%, index numbering 38%
against 21%; measured thin rules 61% against 52%). They are banned here regardless, by the owner's
decision: they are the first things a generator copies from the gallery, they carry no information,
and a page built without them has to find real structure.

| Tell | Status | Do instead |
| --- | --- | --- |
| Monospace micro-labels, mono as a "technical" voice, mono captions | banned | The text face; tabular figures for data |
| Index numbering: 01 / 02 / 03, (001), "Step 02", numbered section markers | banned | Order by position, scale, or the words; `<ol>` when it is truly a list |
| Thin rules: hairline dividers, ruled grids, 1px outlined boxes and tiles | banned (boxes: 3 or more) | Space, tone steps, filled shapes, a thick bar if a bar is the idea |
| Design-tool chrome as ornament: crosshairs, crop marks, selection handles, dashed guides, rulers | banned via hairline, dashed | Remove |
| Agency micro-meta: local time, coordinates, weather, version stamps, "©2026 — Scroll", middle-dot strings | lint: copy; micro-label | Remove |
| Tiny uppercase letter-spaced captions scattered around the viewport | banned (3 or more) | Sentence case at readable size, placed with the content it describes |

Tiny tracked capitals are the one item in this group the data condemns as well: 16% of top sites
against 57% of weak ones.

## 5. Structural tells

| Tell | Status | Do instead |
| --- | --- | --- |
| Headline as a full sentence at display size, three or four lines | banned (over two lines) | A title of about five words; the rest goes below |
| Stock section order: hero, logos, features, testimonial, pricing, CTA | no rule; see copy.md §1 | The order of the argument |
| Every section the same layout family | critique.md pass 9 | Different composition per section, from the sheet |
| Everything centred | warned over 72% | Left-anchored, one centred moment |
| One medium border radius (12–16px) on everything | none | Square or fully round, chosen once |
| Nine nearly equal font sizes | warned over 9 | Five sizes, far apart |
| Invented social proof: logos, quotes, star rows, "10,000+ teams" | copy.md §5 | Real proof, or none |
| Text or a CSS box where a picture belongs | critique.md cap | A real picture, or a design that needs none |

## 6. Copy tells

Flagged by `lint.mjs`: the genre's verbs and adjectives (unlock, supercharge, seamless, elevate,
effortless, cutting-edge, all-in-one …); the correction ("not just X, it's Y"); tricolon headlines;
"Get started" and "Learn more" as labels; more than a handful of em dashes; lorem ipsum. See
`copy.md` §6.

## 7. Motion tells

Identical fade-up on every block; a dozen staggered hero entrances; hover lift on every card; bounce
easing; infinite loops that mean nothing; reveals that fail and leave a blank band. See `motion.md` §9.

## 8. Why removal is not enough

A page with every tell removed and nothing put in their place is an empty page with good manners, and
that is a tell too (the "avoidance aesthetic": anyone could guess it from the category plus the list of
things to avoid). The tells were filling a gap where an idea should have been. The order of work in
`SKILL.md` exists to fill that gap first: the idea from the subject, the dealt composition, real
pictures or committed type, and only then the checks.
references/type.md›
# Type

Typography is most of what a visitor sees and most of what makes a page look designed or assembled.
The direction sheet deals you a type system (faces, weights, settings). This file is how to use it.

## Contents

1. What the study measured
2. The scale: few sizes, far apart
3. Setting display type
4. Setting text
5. Loading the fonts properly
6. One family or two
7. Details that separate set type from typed text
8. Mistakes the inspector cannot see

## 1. What the study measured

From 202 measurable sites out of the 247 studied (Chrome, 1440×900 and 390×844):

| | Median | Middle half | Note |
| --- | --- | --- | --- |
| Hero headline size | 72px (5.0vw) | 43–109px | Top-scored sites: 6.0vw median |
| Largest type on the page | 6.4vw | up to 10.4vw | Top-scored sites 8.4vw, weak sites 5.0vw |
| Hero headline on a phone | 40px | 32–59px | |
| Hero tracking | -0.01em | -0.03 to 0 | Tighter as size grows |
| Hero leading | 1.0 | 0.95–1.1 | |
| Body size | 16px | 14–18px | |
| Headline ÷ body size | 4.3 | | Top-scored sites 4.7 |
| Typefaces in real use | 2 | 31% use one, 45% two, 22% three | |
| Hero weight | regular 48%, medium 18%, bold or heavier 22% | | Big type does not need to be bold |
| Hero in capitals | 24% | | |
| Hero centred | 39% | | 52% on the weak sites |

Two things to take from it. First, size contrast is the main lever and the best sites pull it harder.
Second, weight is not: half of the heroes are set in a regular weight. Scale and spacing make type
confident; bold is one option among several.

## 2. The scale: few sizes, far apart

The panel described about 30 of the 114 strongest sites as using two or three type sizes with nothing
in between, and none as using a graded five- or six-step scale. So: one display size, one text size
and a small size carry the page; a lead size and a heading size exist for when they are needed. Five
is the ceiling. The common failure is nine sizes that are all near each other (16, 18, 20, 24, 28,
32…), which reads as mush. Choose steps that are clearly different:

```css
:root {
  --step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem);   /* 13–14px  captions, legal */
  --step-body:  clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);    /* 17–19px  paragraphs */
  --step-lead:  clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);      /* 20–26px  supporting line, large body */
  --step-head:  clamp(2rem, 1.3rem + 3.2vw, 4rem);             /* 32–64px  section headings */
  --step-display: clamp(3rem, 1rem + 9vw, 9rem);               /* set by the hero blueprint */
}
```

- `--step-display` belongs to the direction. A monument wordmark is 16–24vw; a quiet instrument's
  headline is 28px. Use the size the hero blueprint gives, as a `clamp()` so it scales with the
  viewport but is bounded on phones and very wide screens.
- Keep the gap: the display size should be at least four times the body size on desktop, and nothing
  should sit between `--step-lead` and `--step-head` unless the design truly needs a sixth size.
- Resist a "subheading" size for every card and label. If two sizes look almost the same, make them
  the same.

## 3. Setting display type

Large type exposes spacing that is invisible at text size. Apply all of these to anything over 40px:

```css
.display {
  font-size: var(--step-display);
  line-height: 0.95;              /* 0.85–1.05; tighter for capitals and condensed faces */
  letter-spacing: -0.035em;       /* -0.02 to -0.05em; use the value on the direction sheet */
  font-kerning: normal;
  text-wrap: balance;
  max-width: 14ch;                /* choose the measure so lines break where you want */
  margin: 0;
}
```

- **Tracking scales with size.** The larger the type, the tighter it needs to be; a value right for
  120px will choke 32px. Set tracking per step, not once for all headings.
- **Leading below 1.0 needs checking.** Look for collisions between descenders and the ascenders or
  accents of the next line. Two-line headlines make this easy to verify by eye.
- **All capitals** want slightly open tracking (0 to +0.03em) even at display size, and leading around
  0.9.
- **Optical alignment.** Big letters with round or diagonal left edges (O, C, A, T, W) look indented.
  Pull the headline left by about 0.04–0.06em (`margin-left: -0.05em`) so it aligns optically with the
  text beneath it.
- **Viewport-wide type.** For a line that must span the screen, size it in `vw` and verify at 390 and
  1440; set `white-space: nowrap` and let the container clip rather than wrap. Calculate the size from
  the character count: `font-size ≈ container width ÷ (characters × k)` with k from `copy.md` §2.
- Never add a text shadow, an outline stroke, or a gradient fill to display type.

## 4. Setting text

```css
body { font-size: var(--step-body); line-height: 1.5; font-kerning: normal; text-rendering: optimizeLegibility; }
p { max-width: 62ch; text-wrap: pretty; margin: 0; }
p + p { margin-top: 0.9em; }
.lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; }
small, .caption { font-size: var(--step-small); line-height: 1.45; }
```

- Measure: 45–75 characters. Wider than that and the eye loses the next line; the inspector warns
  past about 88.
- Leading: 1.45–1.6 for body, tighter (1.3–1.4) for the larger lead size, looser for long reading.
- Secondary text is the same face in a softer ink (`--muted`), not a lighter weight and not smaller
  than 13px.
- Do not centre paragraphs longer than three lines. Do not justify.
- Light text on a dark ground looks heavier: drop one weight step or add 0.01em tracking, and raise
  leading slightly.

## 5. Loading the fonts properly

The sheet gives a `<link>` tag. Use it exactly; do not swap in a face you prefer.

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="…from the direction sheet…" rel="stylesheet">
```

```css
:root {
  --font-display: "Family From Sheet", "Helvetica Neue", Arial, sans-serif;
  --font-text: "Family From Sheet", Georgia, serif;
}
```

- Request only the weights you use (usually two or three in total).
- Always give a fallback stack of the same broad kind (sans for sans, serif for serif).
- In a framework, use its font loader with the same family and weights (`next/font/google`, or a
  self-hosted `@font-face` with `font-display: swap`). Fontshare faces load from the
  `api.fontshare.com` stylesheet on the sheet.
- After building, `inspect.mjs` reports the families that actually rendered. If it warns that a face
  did not load, the screenshots show a fallback and nothing you judge from them is real. Fix the link
  first.

## 6. One family or two

A third of the studied sites use a single family for everything, and it is the safest way to look
coherent. When the dealt system has two faces:

- Give each a job and never swap them: one sets headlines, the other sets everything else.
- Do not introduce a third face for labels, numbers or quotes.
- Do not mix two faces inside one headline.
- Italic is for titles of works and real emphasis inside paragraphs, never as decoration.

## 7. Details that separate set type from typed text

- Curly quotes and apostrophes, real dashes, a real ellipsis (see `copy.md` §7).
- `font-variant-numeric: tabular-nums` for prices, tables, dates in columns; `oldstyle-nums` suits
  serif body text.
- `text-underline-offset: 0.18em; text-decoration-thickness: 0.07em` on links, so underlines clear the
  descenders.
- `hyphens: none` on headings. `overflow-wrap: anywhere` only on long URLs or emails.
- A heading belongs to the text after it: space above a heading is at least twice the space below.
- `::selection { background: var(--accent); color: var(--accent-ink); }` and a visible
  `:focus-visible` outline in the accent, so even the browser's own states belong to the design.
- Do not change weight on hover; the line reflows.

## 8. Mistakes the inspector cannot see

- A headline that passes the two-line rule but breaks badly: one orphan word on line two, or a break
  that splits a phrase ("Stools for small / kitchens"). Author the break.
- Display type so tight that letters touch, or so loose it falls apart.
- A confident hero followed by section headings almost as large. Keep one size dominant per screen.
- Body text set in the display face when that face was drawn for large sizes only.
- Everything bold. If every line is emphasised, none is.
scripts/assets.mjs›
#!/usr/bin/env node
// tasteskill / assets: find real, openly licensed pictures of the subject's world, and keep their credits.
//
// A page without photographs of its own does not have to draw everything. Archives, museums and open
// collections hold the subject's city and street, its maps, its materials, its trade's history and the
// museum objects of its kind, free to use with a credit. This searches them without keys and prints what
// an agent needs to use a picture honestly: a direct image URL, the page it comes from, its size, its
// creator, its licence and a one-line credit. Then it downloads the chosen ones into the project and
// records their credits, so the page works offline and credits every picture.
//
//   node assets.mjs "<query>"                     search every source, licences safe for commercial use
//        [--source all|openverse|commons|aic|met|museums]    one or several, comma-separated
//        [--license safe|pd|all|cc0,pd,by,by-sa,...]        safe = CC0, public domain, CC BY, CC BY-SA
//        [--orientation landscape|portrait|square]  [--min-width 1000]  [--width 1920]  [--limit 5]
//        [--no-check]                              skip checking that each image loads (faster)
//        [--json]                                  machine-readable results
//   node assets.mjs "<query>" --video              search for real footage instead (same options, plus
//        [--res 720]                               the largest rendition to pick, by its short side
//        [--max-mb 25]                             clips over this are flagged, and --get refuses them
//   node assets.mjs --get <ref>[,<ref>...] [--to assets] [--name <file name>] [--width 1920] [--res 720] [--max-mb 25]
//                                                  download into the project and record the credit
//   node assets.mjs --credits [--to assets]        print the credits for the footer and the report
//
// Image sources: Openverse (api.openverse.org: Flickr, Wikimedia Commons, museums and libraries in one
// index), Wikimedia Commons, the Art Institute of Chicago (public-domain works over IIIF) and The
// Metropolitan Museum of Art (Open Access). Video sources: Wikimedia Commons (with its web-sized webm
// transcodes), the Internet Archive (its curated Prelinger Archives of films, public domain or Creative
// Commons) and the NASA Image and Video Library. Zero dependencies, no API keys. Node 18+ (built-in fetch).
import { readFileSync, writeFileSync, existsSync, mkdirSync, createWriteStream, unlinkSync } from 'node:fs';
import { join, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const UA = 'taste-skill-assets/2.0 (+https://github.com/Leonxlnx/taste-skill)';
const SELF = fileURLToPath(import.meta.url).split('\\').join('/');
const CMD = `node ${/\s/.test(SELF) ? `"${SELF}"` : SELF}`;
const SOURCES = {
  openverse: { media: 'image', name: 'Openverse', note: 'one index over Flickr, Wikimedia Commons, museums and libraries' },
  commons: { media: 'both', name: 'Wikimedia Commons', note: 'archival photographs, old maps, scans of prints and books, places as they look now', videoNote: 'video files with web-sized webm transcodes: places, crafts, processes, nature' },
  aic: { media: 'image', name: 'Art Institute of Chicago', note: 'public-domain paintings, prints, photographs and textiles, served large' },
  met: { media: 'image', name: 'The Metropolitan Museum of Art', note: 'public-domain objects, prints, drawings and photographs (Open Access)' },
  archive: { media: 'video', name: 'Internet Archive', note: 'the Prelinger Archives: industrial, educational, city and home films from the 1920s to the 1970s, mostly long' },
  nasa: { media: 'video', name: 'NASA Image and Video Library', note: 'footage of the Earth, space, aircraft, launches and laboratories' },
};
const SOURCE_ALIAS = { museums: ['aic', 'met'], wikimedia: ['commons'], artic: ['aic'], chicago: ['aic'], metmuseum: ['met'], ia: ['archive'], internetarchive: ['archive'] };
const VIDEO_TYPES = { 'video/mp4': 'mp4', 'video/webm': 'webm' }; // what every current browser plays; Ogg Theora and QuickTime are left out
const LICENCES = ['cc0', 'pd', 'by', 'by-sa', 'by-nc', 'by-nd', 'by-nc-sa', 'by-nc-nd', 'other'];
const SAFE = ['cc0', 'pd', 'by', 'by-sa'];
const LICENCE_ALIAS = { pdm: 'pd', 'public-domain': 'pd', publicdomain: 'pd', 'cc-by': 'by', 'cc-by-sa': 'by-sa', 'cc-zero': 'cc0' };
const WEB_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif', 'image/svg+xml': 'svg' };
const PROVIDERS = { flickr: 'Flickr', wikimedia: 'Wikimedia Commons', met: 'The Metropolitan Museum of Art', nypl: 'New York Public Library', rawpixel: 'rawpixel', stocksnap: 'StockSnap', clevelandmuseum: 'Cleveland Museum of Art', brooklynmuseum: 'Brooklyn Museum', sciencemuseum: 'Science Museum Group', europeana: 'Europeana', geographorguk: 'Geograph Britain and Ireland', inaturalist: 'iNaturalist', wordpress: 'WordPress Photo Directory' };
const AIC_FIELDS = ['id', 'title', 'artist_title', 'artist_display', 'date_display', 'image_id', 'thumbnail', 'medium_display', 'artwork_type_title', 'is_public_domain'];
const AIC_HEADERS = { 'AIC-User-Agent': UA };

const USAGE = `Find real, openly licensed pictures of the subject's world, download them, keep their credits.

  ${CMD} "<query>" [options]
      --source all|openverse|commons|aic|met|museums   comma-separated; default all
      --license safe|pd|all|cc0,pd,by,by-sa,...        default safe: CC0, public domain, CC BY, CC BY-SA
      --orientation landscape|portrait|square
      --min-width <px>       smallest acceptable full width, default 1000
      --width <px>           width of the image URL to use or download, default 1920
      --limit <n>            results per source, default 5 (at most 20)
      --no-check             do not check that every image loads (faster, less reliable)
      --json                 machine-readable results
  ${CMD} "<query>" --video [options]   real footage: Wikimedia Commons, Internet Archive, NASA
      --source all|commons|archive|nasa
      --res <p>              largest rendition to pick, by its short side, default 720
      --max-mb <n>           clips over this are flagged and --get refuses them, default 25
      --min-width <px>       default 640 for video
  ${CMD} --get <ref>[,<ref>...] [--to assets] [--name <file name>] [--width 1920] [--res 720] [--max-mb 25]
  ${CMD} --credits [--to assets]

Queries work best in the subject's own words and a medium: "ghent postcard", "ghent old map", "azulejo tiles",
"botanical engraving fig", "rotterdam harbour 1920", "basalt texture". The museum sources match one or two
words best ("fig", "harbour"). For footage, name the action or the process: "coffee roasting", "timber
construction", "river", "city street night".`;

// ---------- arguments ----------
const VALUED = new Set(['source', 'sources', 'license', 'licence', 'orientation', 'min-width', 'width', 'limit', 'get', 'to', 'name', 'type', 'res', 'max-mb']);
const FLAGS = new Set(['no-check', 'json', 'credits', 'help', 'video']);
const args = {}; const words = [];
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
  const a = argv[i];
  if (a === '-h') { args.help = true; continue; }
  if (!a.startsWith('--')) { words.push(a); continue; }
  const eq = a.indexOf('=');
  const key = a.slice(2, eq === -1 ? undefined : eq);
  if (VALUED.has(key)) {
    const v = eq === -1 ? argv[++i] : a.slice(eq + 1);
    if (v === undefined || v === '' || (eq === -1 && v.startsWith('--'))) fail(`--${key} needs a value.`);
    args[key] = v;
  } else if (FLAGS.has(key)) args[key] = true;
  else fail(`Unknown option --${key}.`);
}
function fail(message) { console.error(`${message}\n\n${USAGE}`); process.exit(2); }
function number(key, def, min, max) {
  if (args[key] === undefined) return def;
  const n = Number(args[key]);
  if (!Number.isFinite(n) || n < min || n > max) fail(`--${key} must be a number from ${min} to ${max}.`);
  return Math.round(n);
}
if (args.type !== undefined && !['image', 'images', 'video', 'videos'].includes(String(args.type).toLowerCase())) fail('--type is image or video.');
const VIDEO = Boolean(args.video) || /^videos?$/i.test(String(args.type || ''));
function pickSources(value) {
  const media = VIDEO ? 'video' : 'image';
  const usable = Object.keys(SOURCES).filter((k) => SOURCES[k].media === media || SOURCES[k].media === 'both');
  const out = [];
  for (const s of String(value).toLowerCase().split(',').map((x) => x.trim()).filter(Boolean)) {
    const found = s === 'all' ? usable : SOURCE_ALIAS[s] || (SOURCES[s] ? [s] : null);
    if (!found) fail(`Unknown source "${s}". ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}${VIDEO ? '' : ', museums (aic and met)'}, all.`);
    const wrong = found.filter((x) => !usable.includes(x));
    if (wrong.length) fail(`${wrong.join(', ')} has no ${media === 'video' ? 'video' : 'still images'} here. ${VIDEO ? 'Video' : 'Image'} sources: ${usable.join(', ')}.`);
    for (const x of found) if (!out.includes(x)) out.push(x);
  }
  return out.length ? out : usable;
}
function pickLicences(value) {
  const v = String(value).toLowerCase().trim();
  if (v === 'safe') return SAFE;
  if (v === 'pd') return ['cc0', 'pd'];
  if (v === 'all' || v === 'any') return LICENCES;
  const out = v.split(',').map((x) => x.trim()).filter(Boolean).map((x) => LICENCE_ALIAS[x] || x);
  const bad = out.filter((x) => !LICENCES.includes(x));
  if (bad.length || !out.length) fail(`Unknown licence "${bad.join(', ') || v}". Use safe, pd, all, or a list of: ${LICENCES.join(', ')}.`);
  return out;
}
function pickOrientation(value) {
  if (value === undefined) return null;
  const v = String(value).toLowerCase();
  const o = { landscape: 'landscape', wide: 'landscape', horizontal: 'landscape', portrait: 'portrait', tall: 'portrait', vertical: 'portrait', square: 'square' }[v];
  if (!o) fail('--orientation is landscape, portrait or square.');
  return o;
}
const o = {
  sources: pickSources(args.source ?? args.sources ?? 'all'),
  licences: pickLicences(args.license ?? args.licence ?? 'safe'),
  orientation: pickOrientation(args.orientation),
  minWidth: number('min-width', VIDEO ? 640 : 1000, 0, 20000),
  width: number('width', 1920, 200, 4000),
  limit: number('limit', 5, 1, 20),
  video: VIDEO,
  res: number('res', 720, 144, 2160),
  maxMb: number('max-mb', 25, 1, 2000),
  check: !args['no-check'],
  to: resolve(String(args.to || 'assets')),
  toArg: args.to ? ` --to ${args.to}` : '',
};

// ---------- small helpers ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const short = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1).replace(/[\s,;:.-]+$/, '') + '…' : t; };
const noQuery = (u) => (u ? String(u).split('?')[0] : u);
const rel = (p) => relative(process.cwd(), p).split('\\').join('/') || '.';
const size = (bytes) => (bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`);
const shape = (w, h) => (!w || !h ? null : w / h >= 1.15 ? 'landscape' : w / h <= 0.87 ? 'portrait' : 'square');
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function text(v) {
  if (v === undefined || v === null) return '';
  return String(v).replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'")
    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&amp;/g, '&')
    .replace(/\s+/g, ' ').trim();
}
function keyOf(u) { let s = noQuery(u) || ''; try { s = decodeURIComponent(s); } catch {} return s.toLowerCase().replace(/^https?:\/\//, '').replace(/ /g, '_'); }
function slug(s) {
  return String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
    .split('-').slice(0, 7).join('-').slice(0, 60).replace(/-+$/, '');
}
// The words of a query that carry meaning, and whether a record's own description holds them
// (all of them, or all but one of three or more). Used to keep the museum sources on the subject.
function queryWords(q) { return String(q).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w && !['the', 'a', 'an', 'of', 'in', 'on', 'at', 'and', 'or', 'for', 'with', 'from', 'to', 'by'].includes(w)); }
function onSubject(wanted, description) {
  if (!wanted.length) return true;
  const loose = (s) => String(s).toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/our\b/g, 'or'); // harbour = harbor
  const hay = loose(description);
  const hits = wanted.filter((w) => { const x = loose(w); return hay.includes(x) || (x.length > 4 && hay.includes(x.replace(/(es|s)$/, ''))); }).length;
  return hits >= (wanted.length >= 3 ? wanted.length - 1 : wanted.length);
}
async function mapLimit(items, n, fn) {
  const out = new Array(items.length); let next = 0;
  await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) { const k = next++; out[k] = await fn(items[k], k); } }));
  return out;
}
async function getJson(url, ms = 20000, headers = {}) {
  for (let attempt = 1; ; attempt++) {
    let r;
    try { r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) }); }
    catch (e) {
      const timedOut = e.name === 'TimeoutError' || e.name === 'AbortError';
      if (!timedOut && attempt < 2) { await sleep(800); continue; }
      throw new Error(timedOut ? `no answer within ${Math.round(ms / 1000)} s` : `could not connect (${e.cause?.code || e.message})`);
    }
    if ([502, 503, 504].includes(r.status) && attempt < 2) { await sleep(1200); continue; }
    if (r.status === 429) throw new Error('rate limited (HTTP 429): wait a minute, or search another --source');
    if (r.status === 410) throw new Error('this API endpoint has been retired (HTTP 410); assets.mjs needs updating for it');
    if (!r.ok) throw new Error(`HTTP ${r.status}${r.status >= 500 ? ', the service is having trouble' : ''}`);
    try { return await r.json(); } catch { throw new Error('the answer was not JSON'); }
  }
}
// Width and height from the first bytes of a JPEG, PNG, GIF or WebP file.
function dims(b) {
  if (!b || b.length < 26) return {};
  if (b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG') return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
  if (b.toString('ascii', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
  if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' && b.length >= 30) {
    const f = b.toString('ascii', 12, 16);
    if (f === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
    if (f === 'VP8L') { const bits = b.readUInt32LE(21); return { w: 1 + (bits & 0x3fff), h: 1 + ((bits >>> 14) & 0x3fff) }; }
    if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
  }
  if (b[0] === 0xff && b[1] === 0xd8) {
    let i = 2;
    while (i + 9 < b.length) {
      if (b[i] !== 0xff) { i++; continue; }
      const m = b[i + 1];
      if (m === 0xff) { i++; continue; }
      if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue; }
      if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
      i += 2 + b.readUInt16BE(i + 2);
    }
  }
  return {};
}
// Width, height and duration from the head of an MP4 whose index comes first (what web renditions use).
function mp4Info(b) {
  const out = {}; let i = -1; let area = 0;
  while ((i = b.indexOf('tkhd', i + 1)) > 3) { // one per track; the video track is the one with a size
    const end = i - 4 + b.readUInt32BE(i - 4);
    if (end <= b.length && end - (i - 4) >= 84) { const w = b.readUInt32BE(end - 8) >>> 16; const h = b.readUInt32BE(end - 4) >>> 16; if (w * h > area) { area = w * h; out.w = w; out.h = h; } }
  }
  const m = b.indexOf('mvhd');
  if (m > 3 && m + 36 <= b.length) {
    const v1 = b[m + 4] === 1; const scale = b.readUInt32BE(m + (v1 ? 24 : 16)); const d = v1 ? Number(b.readBigUInt64BE(m + 28)) : b.readUInt32BE(m + 20);
    if (scale && d) out.duration = d / scale;
  }
  return out;
}
const clock = (s) => { if (!s && s !== 0) return null; const t = Math.round(s); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; };
// The Art Institute's image server turns away scripts that do not name themselves in this header.
const imageHeaders = (url) => ({ 'User-Agent': UA, Accept: 'image/*', ...(/^https?:\/\/[^/]*artic\.edu\//.test(url) ? AIC_HEADERS : {}) });
// Does the file load, is it something a browser shows, and how large is it? Reads only the first
// 128 KB of an image, or 512 KB of a video (enough for an MP4's size and duration).
async function probe(url, ms = 15000, video = false, again = true) {
  const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms);
  const want = video ? 524288 : 131072;
  try {
    const headers = video ? { 'User-Agent': UA, Accept: 'video/*' } : imageHeaders(url);
    const r = await fetch(url, { headers: { ...headers, Range: `bytes=0-${want - 1}` }, signal: ac.signal, redirect: 'follow' });
    if (!r.ok) {
      r.body?.cancel().catch(() => {});
      if (r.status >= 500 && again) { clearTimeout(timer); await sleep(1000); return probe(url, ms, video, false); } // file servers stumble now and then
      return { ok: false, why: `HTTP ${r.status}` };
    }
    const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
    if (!(video ? VIDEO_TYPES : WEB_TYPES)[type]) { r.body?.cancel().catch(() => {}); return { ok: false, why: type ? `not a web ${video ? 'video' : 'image'} (${type})` : 'no content type' }; }
    const reader = r.body.getReader(); const chunks = []; let n = 0;
    while (n < want) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); n += value.length; }
    reader.cancel().catch(() => {});
    const bytes = r.status === 206 ? Number((r.headers.get('content-range') || '').split('/')[1]) || null : Number(r.headers.get('content-length')) || null;
    const head = Buffer.concat(chunks);
    return { ok: true, type, bytes, ...(video ? (type === 'video/mp4' ? mp4Info(head) : {}) : dims(head)) };
  } catch (e) {
    return { ok: false, why: e.name === 'AbortError' ? 'timed out' : e.cause?.code || e.message };
  } finally { clearTimeout(timer); }
}

// ---------- licences and credits ----------
function licenceLabel(it) {
  if (it.licenseName) return it.licenseName; // set where the plain code would mislead: museums, informal statements, unusual licences
  if (it.license === 'cc0') return 'CC0';
  if (it.license === 'pd') return 'public domain';
  if (it.license === 'other') return 'licence on the source page';
  return `CC ${it.license.toUpperCase()}${it.licenseVersion ? ' ' + it.licenseVersion : ''}`;
}
function licenceUrl(it) {
  if (it.licenseUrl) return String(it.licenseUrl).replace(/^http:/, 'https:');
  if (it.license === 'cc0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
  if (it.license === 'pd') return 'https://creativecommons.org/publicdomain/mark/1.0/';
  if (it.license.startsWith('by')) return `https://creativecommons.org/licenses/${it.license}/${it.licenseVersion || '4.0'}/`;
  return null;
}
const creditRequired = (it) => it.license !== 'cc0' && it.license !== 'pd';
function licenceNotes(it) {
  const n = [];
  if (/-sa$/.test(it.license)) n.push('Share-alike: an altered version (recoloured, combined with other pictures) must be shared under the same licence.');
  if (/-nc/.test(it.license)) n.push('Non-commercial only: not for a business, a shop or a client site.');
  if (/-nd$/.test(it.license)) n.push('No derivatives: show it whole and unaltered, no crops or treatments.');
  if (it.license === 'other') n.push('A licence outside the usual set: read the source page before using it.');
  return n;
}
function credit(it) {
  const by = it.creator && !/^(unknown|anonymous)\b/i.test(it.creator) ? ` by ${short(it.creator, 60)}` : '';
  const date = (it.source === 'aic' || it.source === 'met') && it.date ? `, ${short(it.date, 30)}` : '';
  return `"${short(it.title, 90)}"${by}${date}, ${licenceLabel(it)}, via ${it.via}`;
}
function base(source, id) {
  return { ref: `${source}:${id}`, source, media: 'image', via: SOURCES[source].name, title: '', creator: '', creatorUrl: null, date: null, kind: null, about: null, license: 'other', licenseVersion: null, licenseName: null, licenseUrl: null, page: null, image: null, original: null, smaller: null, width: null, height: null, imageWidth: null, imageHeight: null, bytes: null, estimated: false, duration: null, poster: null, alt: null, notes: [], hint: null };
}
// Licence code and version from a licence URL (Creative Commons licences and marks).
function licenceFromUrl(u) {
  const s = String(u || '').toLowerCase();
  if (/publicdomain\/zero/.test(s)) return { code: 'cc0', version: '1.0' };
  if (/publicdomain\/mark|publicdomain\/?$|\/publicdomain\b/.test(s)) return { code: 'pd', version: null };
  const m = s.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)\/(\d\.\d)/);
  return m ? { code: m[1], version: m[2] } : { code: 'other', version: null };
}
// Of the renditions a source offers, the largest whose short side is at most `res` (720p by default),
// in a format every browser plays. If all are larger, the smallest one.
function pickRendition(list, res) {
  const playable = list.filter((r) => r.url && VIDEO_TYPES[r.type]);
  const side = (r) => (r.w && r.h ? Math.min(r.w, r.h) : Infinity);
  const fit = playable.filter((r) => side(r) <= res).sort((a, b) => side(b) - side(a) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15));
  return fit[0] || playable.sort((a, b) => side(a) - side(b) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15))[0] || null;
}
function useRendition(it, pick, largest) {
  Object.assign(it, { media: 'video', image: pick.url, mime: pick.type, imageWidth: pick.w || null, imageHeight: pick.h || null, bytes: pick.bytes || null, estimated: Boolean(pick.estimated), duration: it.duration || pick.duration || null });
  if (largest) { it.width = largest.w || it.width; it.height = largest.h || it.height; }
  return it;
}

// ---------- Openverse ----------
async function searchOpenverse(q, opts) {
  const lic = opts.licences.filter((l) => l !== 'other').map((l) => (l === 'pd' ? 'pdm' : l));
  if (!lic.length) return { total: 0, items: [], note: 'Openverse filters by Creative Commons licences and marks only.' };
  const call = (large) => {
    const p = new URLSearchParams({ q, license: lic.join(','), page_size: '20' });
    if (opts.orientation) p.set('aspect_ratio', { landscape: 'wide', portrait: 'tall', square: 'square' }[opts.orientation]);
    if (large) p.set('size', 'large');
    return getJson(`https://api.openverse.org/v1/images/?${p}`, 30000);
  };
  // Its default order is led by 1024px Flickr files. Ask for the large ones first, and fill up from the rest only if needed.
  const first = await call(true);
  let rows = first.results || []; let total = first.result_count ?? null;
  if (rows.filter((r) => !r.width || r.width >= opts.minWidth).length < opts.limit + 2) {
    const rest = await call(false).catch(() => null);
    if (rest) { total = rest.result_count ?? total; rows = [...rows, ...(rest.results || []).filter((r) => !rows.some((x) => x.id === r.id))]; }
  }
  const items = rows.map(fromOpenverse).filter((it) => it.image);
  await wikimediaSizes(items, opts.width);
  return { total, items };
}
function fromOpenverse(r) {
  const it = base('openverse', r.id);
  const via = r.source || r.provider;
  Object.assign(it, {
    via: PROVIDERS[via] || (String(via || '').startsWith('smithsonian') ? 'Smithsonian' : via ? String(via).replace(/_/g, ' ') : 'Openverse'),
    title: text(r.title).replace(/^File:/, '').replace(/\.(jpe?g|png|tiff?|gif|webp)$/i, '') || 'Untitled', creator: text(r.creator), creatorUrl: r.creator_url || null,
    license: r.license === 'pdm' ? 'pd' : String(r.license || 'other'), licenseVersion: r.license_version || null, licenseUrl: r.license_url || null,
    page: r.foreign_landing_url || r.detail_url, image: r.url, original: r.url,
    width: r.width || null, height: r.height || null, imageWidth: r.width || null, imageHeight: r.height || null,
  });
  if (!LICENCES.includes(it.license)) it.license = 'other';
  return it;
}
// Openverse points at Wikimedia originals, which can be 10 MB or a TIFF. Swap in a web-sized rendition.
async function wikimediaSizes(items, width) {
  const wm = items.filter((it) => /^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\//.test(it.original || ''));
  if (!wm.length) return;
  const title = (it) => { let n = noQuery(it.original).split('/').pop(); try { n = decodeURIComponent(n); } catch {} return 'File:' + n.replace(/_/g, ' '); };
  const j = await getJson(commonsApi({ titles: wm.map(title).join('|'), prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: String(width) })).catch(() => null);
  if (!j?.query) return;
  const normal = new Map((j.query.normalized || []).map((n) => [n.from, n.to]));
  const info = new Map((j.query.pages || []).map((pg) => [pg.title, pg.imageinfo?.[0]]));
  for (const it of wm) { const t = title(it); const ii = info.get(normal.get(t) || t); if (ii) applyThumb(it, ii); }
}

// ---------- Wikimedia Commons ----------
function commonsApi(params) { return `https://commons.wikimedia.org/w/api.php?${new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', origin: '*', ...params })}`; }
function commonsInfo(width) {
  return { prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiurlwidth: String(width), iiextmetadatalanguage: 'en',
    iiextmetadatafilter: 'ObjectName|Artist|LicenseShortName|License|LicenseUrl|Restrictions|DateTimeOriginal|ImageDescription' };
}
async function searchCommons(q, opts) {
  let s = `${q} filetype:bitmap`;
  if (opts.minWidth > 1) s += ` filew:>${opts.minWidth - 1}`;
  const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: s, gsrlimit: '30', ...commonsInfo(opts.width) }));
  if (j.error) throw new Error(j.error.info || 'the search failed');
  const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
  return { total: null, items: pages.map(fromCommons).filter(Boolean) };
}
function fromCommons(pg) {
  const ii = pg?.imageinfo?.[0]; if (!ii) return null;
  const m = (k) => text(ii.extmetadata?.[k]?.value);
  const lic = commonsLicence(m('License'), m('LicenseShortName'));
  const it = base('commons', pg.pageid);
  Object.assign(it, {
    title: m('ObjectName').replace(/\s*\b(?:title|label) QS:.*$/s, '') || pg.title.replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, ''), creator: m('Artist'),
    date: m('DateTimeOriginal').replace(/\s*\bdate QS:.*$/s, '').replace(/^(\d{4}-\d{2}-\d{2})[ T]\d{2}:\d{2}.*$/, '$1') || null, about: m('ImageDescription') || null,
    license: lic.code, licenseVersion: lic.version, licenseName: lic.name, licenseUrl: m('LicenseUrl') || null, page: ii.descriptionurl,
  });
  applyThumb(it, ii);
  if (lic.informal) it.notes.push('Not a formal licence: the archive that published it states it knows of no copyright restrictions.');
  if (it.about && it.about.toLowerCase().startsWith(it.title.toLowerCase()) && it.about.length < it.title.length + 12) it.about = null;
  const restrictions = m('Restrictions');
  if (restrictions) it.notes.push(`Restrictions named on the source page (${restrictions}): personality rights, a trademark or similar. Read it before use.`);
  if (/^pd-us/i.test(m('License'))) it.notes.push('Public domain in the United States; check the source page before using it elsewhere.');
  return it;
}
function commonsLicence(license, shortName) {
  const l = String(license || '').toLowerCase(); const s = String(shortName || '').toLowerCase();
  const version = (shortName.match(/\b(\d\.\d)\b/) || l.match(/(\d\.\d)/) || [])[1] || null;
  if (/^cc0|^cc-zero/.test(l) || /^cc0/.test(s)) return { code: 'cc0', version: '1.0' };
  if (/^pd|public domain/.test(l) || /public domain/.test(s)) return { code: 'pd', version: null };
  // Flickr Commons and similar archives: no formal licence, the institution states it knows of no copyright
  if (/^no (known copyright )?restrictions/.test(s)) return { code: 'pd', version: null, name: 'no known copyright restrictions', informal: true };
  const cc = l.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)(?:-|$)/) || s.match(/^cc (by(?:-nc)?(?:-sa|-nd)?)\b/);
  if (cc) return { code: cc[1], version };
  return { code: 'other', version: null, name: shortName || license || null };
}
// Commons serves thumbnails only at standard widths (1280, 1920, 3840...); the API picks the step.
function applyThumb(it, ii) {
  it.width = ii.width || it.width; it.height = ii.height || it.height; it.original = noQuery(ii.url) || it.original;
  const tw = Number(String(ii.thumburl || '').split('?')[0].match(/(\d+)px-[^/]*$/)?.[1]) || 0;
  if (ii.thumburl && tw && ii.width && (tw < ii.width || (ii.mime && !WEB_TYPES[ii.mime]))) {
    it.image = noQuery(ii.thumburl); it.imageWidth = Math.min(tw, ii.width); it.imageHeight = ii.height ? Math.round((ii.height * it.imageWidth) / ii.width) : null;
  } else { it.image = it.original; it.imageWidth = ii.width || null; it.imageHeight = ii.height || null; }
}

// ---------- Art Institute of Chicago ----------
async function searchAic(q, opts) {
  const wanted = queryWords(q);
  const run = (match) => getJson(`https://api.artic.edu/api/v1/artworks/search?params=${encodeURIComponent(JSON.stringify({
    query: { bool: {
      // the long description field is left out on purpose: it matches almost anything
      must: [{ multi_match: { query: wanted.join(' ') || q, type: 'cross_fields', fields: ['title^3', 'subject_titles^2', 'term_titles^2', 'classification_titles', 'medium_display', 'artist_title', 'place_of_origin', 'date_display'], ...match } }],
      filter: [{ term: { is_public_domain: true } }, { exists: { field: 'image_id' } }],
    } },
    fields: AIC_FIELDS, limit: 30,
  }))}`, 20000, AIC_HEADERS);
  // every word must match; with three words or more, one may be missing. Looser than that returns noise.
  let j = await run({ operator: 'and' });
  if (!(j.data || []).length && wanted.length >= 3) j = await run({ operator: 'or', minimum_should_match: wanted.length - 1 });
  const iiif = j.config?.iiif_url || 'https://www.artic.edu/iiif/2';
  return { total: j.pagination?.total ?? null, items: (j.data || []).map((d) => fromAic(d, iiif, opts.width)).filter(Boolean) };
}
function fromAic(d, iiif, width) {
  if (!d?.image_id || d.is_public_domain === false) return null;
  const it = base('aic', d.id);
  const W = d.thumbnail?.width || null; const H = d.thumbnail?.height || null;
  const cap = W ? Math.min(W, 3000) : 3000; const w = Math.min(width, cap);
  Object.assign(it, {
    title: text(d.title) || 'Untitled', creator: text(d.artist_title) || text(String(d.artist_display || '').split('\n')[0]),
    date: text(d.date_display) || null, kind: [d.artwork_type_title, d.medium_display].filter(Boolean).map(text).join(', ') || null,
    license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
    page: `https://www.artic.edu/artworks/${d.id}`, image: `${iiif}/${d.image_id}/full/${w},/0/default.jpg`, original: `${iiif}/${d.image_id}/full/${cap},/0/default.jpg`,
    width: W && H ? cap : null, height: W && H ? Math.round((H * cap) / W) : null, imageWidth: W && H ? w : null, imageHeight: W && H ? Math.round((H * w) / W) : null,
    alt: text(d.thumbnail?.alt_text) || null,
  });
  it.hint = 'Download it with --get: the museum\'s image server answers browsers and this script, and refuses plain curl or wget.';
  return it;
}

// ---------- The Met ----------
async function searchMet(q, opts) {
  const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1.1/search?${new URLSearchParams({ q, hasImages: 'true', limit: String(Math.min(40, opts.limit * 4 + 8)), offset: '0' })}`);
  const ids = (s.objectIDs || []).slice(0, opts.limit * 4 + 8);
  const objects = await mapLimit(ids, 8, (id) => getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`, 15000).catch(() => null));
  // The museum's search reads every field of a record, so much of what it returns is off the subject.
  // Keep the objects whose own name, material, maker, place or tags carry the words of the query.
  const wanted = queryWords(q);
  const about = (x) => [x.title, x.objectName, x.medium, x.classification, x.culture, x.period, x.artistDisplayName, x.department, x.city, x.country, x.region, x.objectDate, ...(x.tags || []).map((t) => t.term)].filter(Boolean).join(' ');
  return { total: s.total ?? null, items: objects.filter((x) => x && onSubject(wanted, about(x))).map(fromMet).filter(Boolean) };
}
function fromMet(x) {
  if (!x || !x.isPublicDomain || !x.primaryImage) return null;
  const it = base('met', x.objectID);
  const fix = (u) => (u ? encodeURI(u).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
  Object.assign(it, {
    title: text(x.title) || text(x.objectName) || 'Untitled', creator: text(x.artistDisplayName) || (x.culture ? `${text(x.culture)} maker` : ''),
    date: text(x.objectDate) || null, kind: [x.objectName || x.classification, x.medium].filter(Boolean).map(text).join(', ') || null,
    license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
    page: x.objectURL || `https://www.metmuseum.org/art/collection/search/${x.objectID}`,
    image: fix(x.primaryImage), original: fix(x.primaryImage), smaller: x.primaryImageSmall && x.primaryImageSmall !== x.primaryImage ? fix(x.primaryImageSmall) : null,
  });
  return it;
}

// ---------- video: Wikimedia Commons ----------
async function searchCommonsVideo(q, opts) {
  const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: `${q} filetype:video`, gsrlimit: '30', ...commonsInfo(1280), prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
  if (j.error) throw new Error(j.error.info || 'the search failed');
  const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
  return { total: null, items: pages.map((pg) => fromCommonsVideo(pg, opts)).filter(Boolean) };
}
function fromCommonsVideo(pg, opts) {
  const it = fromCommons(pg); const ii = pg?.imageinfo?.[0];
  if (!it || !ii) return null;
  it.poster = it.image !== it.original ? it.image : null; // for a video file, the thumbnail is a still frame
  it.duration = Number(ii.duration) || null;
  // the original plus the transcodes Commons makes of every video (240p to 1080p webm); sizes from their bit rates
  const renditions = (pg.videoinfo?.[0]?.derivatives || []).map((d) => {
    const url = noQuery(d.src); const original = !/\/transcoded\//.test(url);
    return { url, type: String(d.type || '').split(';')[0].trim(), w: Number(d.width) || null, h: Number(d.height) || null,
      bytes: original ? Number(ii.size) || null : d.bandwidth && it.duration ? Math.round((d.bandwidth * it.duration) / 8) : null, estimated: !original };
  });
  const pick = pickRendition(renditions, opts.res);
  return pick ? useRendition(it, pick, { w: ii.width, h: ii.height }) : null;
}

// ---------- video: Internet Archive ----------
const first = (v) => (Array.isArray(v) ? v[0] : v);
const seconds = (s) => {
  if (s === undefined || s === null || s === '') return null;
  if (/^\d+(\.\d+)?$/.test(String(s))) return Number(s);
  const p = String(s).split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, x) => a * 60 + x, 0) : null;
};
// Only the Prelinger Archives: curated industrial, educational, advertising and home films, mostly in the
// public domain. The rest of the archive's films are uploads by anyone, and their licence claims go
// unchecked (an upload called "... all rights reserved" can carry a public-domain mark), so they are left out.
const ARCHIVE_LICENCE = { cc0: '*publicdomain\\/zero*', pd: '*publicdomain*', by: '*licenses\\/by\\/*', 'by-sa': '*licenses\\/by-sa\\/*', 'by-nc': '*licenses\\/by-nc\\/*', 'by-nd': '*licenses\\/by-nd\\/*', 'by-nc-sa': '*licenses\\/by-nc-sa\\/*', 'by-nc-nd': '*licenses\\/by-nc-nd\\/*' };
async function searchArchive(q, opts) {
  const words = queryWords(q);
  const licences = opts.licences.includes('other') ? '' : ` AND licenseurl:(${[...new Set(opts.licences.map((l) => ARCHIVE_LICENCE[l]))].join(' OR ')})`;
  const p = new URLSearchParams({ q: `(${(words.length ? words : [q]).join(' AND ')}) AND mediatype:(movies) AND collection:(prelinger)${licences}`, rows: '40', output: 'json' });
  for (const f of ['identifier', 'licenseurl']) p.append('fl[]', f);
  const j = await getJson(`https://archive.org/advancedsearch.php?${p}`, 30000);
  if (j.error) throw new Error(`its search is failing right now (${short(text(j.error), 80)}); try again later`);
  const docs = j.response?.docs || [];
  const allowed = docs.filter((d) => opts.licences.includes(licenceFromUrl(first(d.licenseurl)).code));
  const metas = await mapLimit(allowed.slice(0, opts.limit * 2 + 4), 6, (d) => getJson(`https://archive.org/metadata/${encodeURIComponent(d.identifier)}`, 20000).catch(() => null));
  return { total: j.response?.numFound ?? null, skippedLicence: docs.length - allowed.length, items: metas.map((m) => fromArchive(m, opts)).filter(Boolean) };
}
function fromArchive(m, opts) {
  const md = m?.metadata; if (!md?.identifier || !Array.isArray(m.files)) return null;
  const id = md.identifier;
  const fileUrl = (name) => `https://archive.org/download/${encodeURIComponent(id)}/${String(name).split('/').map(encodeURIComponent).join('/')}`;
  const isVideo = (f) => /\.(mp4|m4v|webm|mpe?g|avi|mov|ogv|mkv|wmv|flv)$/i.test(f.name || '');
  // an item can hold several films: keep the first original and the web copies made from it
  const originals = m.files.filter((f) => f.source === 'original' && isVideo(f));
  const stem = originals[0] ? originals[0].name.replace(/\.[^.]+$/, '') : null;
  const family = m.files.filter((f) => isVideo(f) && (!stem || f.name.startsWith(stem)));
  const renditions = family.filter((f) => /\.(mp4|webm)$/i.test(f.name)).map((f) => ({ url: fileUrl(f.name), type: /\.webm$/i.test(f.name) ? 'video/webm' : 'video/mp4', w: Number(f.width) || null, h: Number(f.height) || null, bytes: Number(f.size) || null, duration: seconds(f.length) }));
  const pick = pickRendition(renditions, opts.res); if (!pick) return null;
  const largest = family.reduce((a, f) => ((Number(f.width) || 0) > (a.w || 0) ? { w: Number(f.width), h: Number(f.height) } : a), { w: pick.w, h: pick.h });
  const lic = licenceFromUrl(first(md.licenseurl));
  const it = base('archive', id);
  Object.assign(it, {
    title: text(first(md.title)) || id, creator: text(first(md.creator)), date: text(first(md.date) || first(md.year)) || null, about: text(first(md.description)) || null,
    license: lic.code, licenseVersion: lic.version, licenseUrl: first(md.licenseurl) || null, page: `https://archive.org/details/${id}`,
    original: fileUrl((originals[0] || family[0]).name), duration: pick.duration || seconds(first(md.runtime)),
    poster: null, // the archive's own stills are about 160 pixels wide: too small to stand in for a clip
  });
  // the same film is often stored in several formats; different names mean different films
  const films = new Set(originals.map((f) => f.name.split('/').pop().split('.')[0])).size;
  if (films > 1) it.notes.push(`This item holds ${films} films; the first is shown. The page lists the others.`);
  return useRendition(it, pick, largest);
}

// ---------- video: NASA Image and Video Library ----------
const nasaUrl = (u) => (u ? encodeURI(String(u).replace(/^http:/, 'https:')).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
async function searchNasa(q, opts) {
  const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ q, media_type: 'video', page_size: String(Math.min(100, opts.limit * 3 + 6)) })}`);
  // its search matches any word anywhere; keep the clips whose own title, description or keywords carry the query
  const wanted = queryWords(q);
  const rows = (j.collection?.items || []).filter((x) => { const d = x.data?.[0] || {}; return onSubject(wanted, [d.title, d.description, ...(d.keywords || [])].join(' ')); }).slice(0, opts.limit * 2 + 4);
  const items = await mapLimit(rows, 6, async (x) => fromNasa(x, await getJson(nasaUrl(x.href), 15000).catch(() => null), opts));
  return { total: j.collection?.metadata?.total_hits ?? null, items: items.filter(Boolean) };
}
function fromNasa(x, files, opts) {
  const d = x?.data?.[0]; if (!d?.nasa_id || !Array.isArray(files)) return null;
  const find = (suffix) => files.find((f) => String(f).endsWith(suffix));
  // NASA makes the same renditions of every video; these are their 16:9 sizes, and the check reads the real ones
  const renditions = [['~large.mp4', 1920, 1080], ['~medium.mp4', 1280, 720], ['~small.mp4', 640, 360], ['~mobile.mp4', 320, 180]]
    .filter(([s]) => find(s)).map(([s, w, h]) => ({ url: nasaUrl(find(s)), type: 'video/mp4', w, h }));
  const pick = pickRendition(renditions, opts.res); if (!pick) return null;
  const it = base('nasa', encodeURIComponent(d.nasa_id));
  Object.assign(it, {
    title: text(d.title) || d.nasa_id, creator: text(d.photographer || d.secondary_creator) || `NASA${d.center ? ' ' + d.center : ''}`,
    date: String(d.date_created || '').slice(0, 10) || null, about: text(d.description) || null,
    license: 'pd', licenseName: 'public domain (NASA)', licenseUrl: 'https://www.nasa.gov/nasa-brand-center/images-and-media/',
    page: `https://images.nasa.gov/details/${encodeURIComponent(d.nasa_id)}`, original: nasaUrl(find('~orig.mp4')) || pick.url,
    poster: nasaUrl(find('~large.jpg') || find('~medium.jpg') || find('~thumb.jpg')),
  });
  it.notes.push('NASA footage is generally free of copyright in the US. Never use NASA logos or suggest NASA endorses the client; check the page for third-party material.');
  return useRendition(it, pick, renditions[0]);
}
function videoNotes(it, opts) {
  const n = [];
  const smaller = it.imageWidth && it.imageHeight && Math.min(it.imageWidth, it.imageHeight) > 480;
  if (it.bytes && it.bytes > opts.maxMb * 1048576) n.push(`Over ${opts.maxMb} MB (${it.estimated ? 'about ' : ''}${size(it.bytes)}): too heavy for a page, and --get refuses it unless you pass --max-mb. ${smaller ? 'Try --res 480, or a shorter clip.' : 'Choose a shorter clip.'}`);
  if (it.duration && it.duration > 60) n.push(`Long (${clock(it.duration)}): a loop on a page wants 5 to 20 seconds. Prefer a shorter clip, or trim one with ffmpeg if it is installed.`);
  return n;
}

// ---------- search ----------
function fits(it, opts, skipped) {
  const W = it.width || it.imageWidth; const H = it.height || it.imageHeight;
  if (W && W < opts.minWidth) { skipped.small++; return false; }
  if (opts.orientation && W && H && shape(W, H) !== opts.orientation) { skipped.shape++; return false; }
  return true;
}
async function search(query, opts) {
  const runners = opts.video ? { commons: searchCommonsVideo, archive: searchArchive, nasa: searchNasa } : { openverse: searchOpenverse, commons: searchCommons, aic: searchAic, met: searchMet };
  // a slow service never holds the whole search: each source gets 45 s, the check 30 s
  const deadline = (ms, message) => new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref());
  const results = await Promise.all(opts.sources.map(async (key) => {
    try { return { key, ...(await Promise.race([runners[key](query, opts), deadline(45000, 'no complete answer within 45 s; try again or search another --source')])) }; }
    catch (e) { return { key, error: e.message, items: [] }; }
  }));
  // short clips first, then longer ones, then anything over the size cap; the source's own order within each
  const cap = opts.maxMb * 1048576;
  const rank = (it) => (it.media !== 'video' ? 0 : it.bytes && it.bytes > cap ? 4 : !it.duration ? 3 : it.duration <= 30 ? 0 : it.duration <= 90 ? 1 : it.duration <= 300 ? 2 : 3);
  const byRank = (a, b) => rank(a) - rank(b);
  const seen = new Set();
  for (const r of results) {
    r.skipped = { licence: r.skippedLicence || 0, small: 0, shape: 0, dead: 0, alike: 0 };
    const titles = new Map();
    r.items = r.items.filter((it) => {
      if (!opts.licences.includes(it.license)) { r.skipped.licence++; return false; }
      const k = keyOf(it.original || it.image); if (seen.has(k)) return false; seen.add(k);
      if (!fits(it, opts, r.skipped)) return false;
      // one scanned book can fill a whole page of results: keep two pictures per title
      const t = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 32); const n = (titles.get(t) || 0) + 1; titles.set(t, n);
      if (t && n > 2) { r.skipped.alike++; return false; }
      return true;
    }).sort(byRank).slice(0, opts.limit + 4); // a few spares for files that turn out not to load
  }
  if (opts.check) {
    await Promise.race([mapLimit(results.flatMap((r) => r.items), 8, async (it) => {
      const video = it.media === 'video';
      const p = await probe(it.image, video ? 15000 : 12000, video); it.check = p;
      if (p.ok && p.w && p.h) {
        it.imageWidth = p.w; it.imageHeight = p.h;
        if (!it.width || it.image === it.original) { it.width = p.w; it.height = p.h; }
      }
      if (p.ok && p.bytes) { it.bytes = p.bytes; it.estimated = false; }
      if (p.ok && p.duration) it.duration = p.duration;
    }), new Promise((r) => setTimeout(r, 30000).unref())]);
    for (const r of results) r.items = r.items.filter((it) => (it.check?.ok ? fits(it, opts, r.skipped) : (r.skipped.dead++, (r.skipped.why = it.check?.why || 'not checked within 30 s'), false))).sort(byRank);
  }
  for (const r of results) r.items = r.items.slice(0, opts.limit);
  const shown = results.reduce((n, r) => n + r.items.length, 0);

  if (args.json) {
    console.log(JSON.stringify({ query, options: { media: opts.video ? 'video' : 'image', sources: opts.sources, licences: opts.licences, orientation: opts.orientation, minWidth: opts.minWidth, width: opts.width, res: opts.video ? opts.res : undefined, maxMb: opts.video ? opts.maxMb : undefined, limit: opts.limit, checked: opts.check },
      sources: results.map((r) => ({ source: r.key, name: SOURCES[r.key].name, total: r.total ?? null, error: r.error || null, skipped: r.skipped,
        items: r.items.map(({ check, ...it }) => ({ ...it, licenseLabel: licenceLabel(it), licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...(it.media === 'video' ? videoNotes(it, opts) : [])] })) })) }, null, 1));
    return shown ? 0 : 1;
  }

  const filters = opts.video
    ? [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `renditions up to ${opts.res}p`, `flagged over ${opts.maxMb} MB`, opts.check ? 'every file checked' : 'files not checked']
    : [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `image URLs ${opts.width}px wide where the source can size them`, opts.check ? 'every image checked' : 'images not checked'];
  console.log(`ASSETS${opts.video ? ' · VIDEO' : ''} · "${query}" · ${filters.filter(Boolean).join(' · ')}`);
  for (const r of results) {
    const src = SOURCES[r.key];
    console.log('');
    if (r.error) { console.log(`## ${src.name}: could not search it (${r.error})`); continue; }
    const dropped = [r.skipped.small && `${r.skipped.small} narrower than ${opts.minWidth}px`, r.skipped.shape && `${r.skipped.shape} not ${opts.orientation}`, r.skipped.licence && `${r.skipped.licence} under other licences`, r.skipped.alike && `${r.skipped.alike} more with the same title`, r.skipped.dead && `${r.skipped.dead} did not load (${r.skipped.why})`].filter(Boolean);
    const count = r.total != null ? `${r.total} matches, ${r.items.length} shown` : `${r.items.length} shown`;
    console.log(`## ${src.name}: ${r.items.length ? count : 'nothing usable'}${dropped.length ? ` (dropped: ${dropped.join(', ')})` : ''}`);
    if (r.note) console.log(r.note);
    if (!r.items.length) continue;
    console.log(`${(opts.video && src.videoNote) || src.note}`);
    for (const it of r.items) {
      console.log('');
      console.log(`${it.ref}  ${short(it.title, 100)}${it.date ? ` (${short(it.date, 30)})` : ''}`);
      if (it.kind) console.log(`  what     ${short(it.kind, 110)}`);
      if (it.about) console.log(`  about    ${short(it.about, 150)}`);
      const pixels = it.imageWidth && it.imageHeight ? `${it.imageWidth} × ${it.imageHeight}` : 'size unknown';
      const largest = it.width && it.height && it.width !== it.imageWidth ? `; largest ${it.width} × ${it.height}` : '';
      const sh = shape(it.width || it.imageWidth, it.height || it.imageHeight);
      if (it.media === 'video') {
        const facts = [pixels, it.duration ? clock(it.duration) : 'length unknown', it.bytes ? `${it.estimated ? 'about ' : ''}${size(it.bytes)}` : null, VIDEO_TYPES[it.mime]];
        console.log(`  file     ${it.image}`);
        console.log(`  size     ${facts.filter(Boolean).join(', ')}${largest}${sh ? `; ${sh}` : ''}`);
        if (it.poster) console.log(`  poster   ${it.poster}`);
      } else {
        console.log(`  image    ${it.image}`);
        console.log(`  size     ${pixels}${it.bytes ? `, ${size(it.bytes)}` : ''}${largest}${sh ? `; ${sh}` : ''}`);
        if (it.smaller) console.log(`  smaller  ${it.smaller}`);
      }
      console.log(`  page     ${it.page}`);
      const lu = licenceUrl(it);
      console.log(`  licence  ${licenceLabel(it)}${lu ? ` (${lu})` : ''}; ${creditRequired(it) ? 'credit required' : 'no credit required, credit anyway'}`);
      console.log(`  credit   ${credit(it)}`);
      for (const n of [...(it.media === 'video' ? videoNotes(it, opts) : []), ...it.notes, ...licenceNotes(it), ...(it.hint ? [it.hint] : [])]) console.log(`  note     ${n}`);
    }
  }
  console.log('');
  if (results.every((r) => r.error)) {
    console.log('No source could be reached. Check the network and try again; if this machine has no internet access, draw the');
    console.log('pictures instead (references/imagery.md) and say in your report that the open collections could not be searched.');
    return 1;
  }
  if (!shown && opts.video) {
    console.log('Nothing usable came back. Name the action or the process ("coffee roasting", "timber framing", "river"); use fewer');
    console.log('words; try a smaller --min-width or another --source. A page does not need video: a still picture or a drawing is fine.');
    return 1;
  }
  if (!shown) {
    console.log('Nothing usable came back. Try fewer words; the place\'s own name for things ("azulejo", not "blue tile"); the place and a');
    console.log('medium ("ghent postcard", "ghent old map", "ghent engraving"); a decade ("rotterdam harbour 1920"); a smaller --min-width;');
    console.log('or another --source. The museum sources match one or two words best ("fig", "harbour"). If nothing honest exists, draw it.');
    return 1;
  }
  if (opts.video) console.log('Next. Watch before you choose: --get downloads the clip and a poster frame into the project and records the credit.');
  else console.log('Next. Look before you choose: --get downloads into the project and records the credit; open the file to see it.');
  console.log(`  ${CMD} --get <ref>[,<ref>...]${o.toArg}`);
  console.log(`  ${CMD} --credits${o.toArg}     the footer credit line and the list for your report`);
  console.log(`These ${opts.video ? 'clips' : 'pictures'} show the subject's world. Never present one as the client's own product, room, dish, person or team.`);
  return 0;
}

// ---------- get ----------
function creditsPath(opts) { return join(opts.to, 'credits.json'); }
function readCredits(opts) { try { const j = JSON.parse(readFileSync(creditsPath(opts), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }
// A file name in the target folder that is free, or already belongs to this same picture.
function targetFile(opts, stem, ext, ref, credits) {
  let file = join(opts.to, `${stem}.${ext}`);
  for (let k = 2; existsSync(file) && credits.find((c) => c.file === rel(file))?.ref !== ref; k++) file = join(opts.to, `${stem}-${k}.${ext}`);
  return file;
}
function recordCredit(opts, credits, entry) { writeFileSync(creditsPath(opts), JSON.stringify([...credits.filter((c) => c.file !== entry.file), entry], null, 1)); }
async function getOne(ref, opts) {
  const m = String(ref).match(/^(openverse|commons|aic|met|archive|nasa):(.+)$/);
  if (!m) throw new Error('a ref looks like openverse:<id>, commons:<id>, aic:<id>, met:<id>, archive:<id> or nasa:<id>, as a search prints it.');
  const [, src, id] = m;
  let it;
  if (src === 'openverse') { it = fromOpenverse(await getJson(`https://api.openverse.org/v1/images/${encodeURIComponent(id)}/`, 30000)); await wikimediaSizes([it], opts.width); }
  else if (src === 'commons') {
    // the same ref form serves pictures and videos; the record says which
    const j = await getJson(commonsApi({ pageids: id, ...commonsInfo(opts.width), iiprop: 'url|size|mime|mediatype|extmetadata', prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
    const pg = j.query?.pages?.[0]; const ii = pg?.imageinfo?.[0];
    it = ii && (ii.mediatype === 'VIDEO' || /^video\//.test(ii.mime || '')) ? fromCommonsVideo(pg, opts) : fromCommons(pg);
  }
  else if (src === 'aic') { const j = await getJson(`https://api.artic.edu/api/v1/artworks/${encodeURIComponent(id)}?fields=${AIC_FIELDS.join(',')}`, 20000, AIC_HEADERS); it = fromAic(j.data, j.config?.iiif_url || 'https://www.artic.edu/iiif/2', opts.width); }
  else if (src === 'archive') it = fromArchive(await getJson(`https://archive.org/metadata/${encodeURIComponent(id)}`, 30000), opts);
  else if (src === 'nasa') {
    const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ nasa_id: decodeURIComponent(id), media_type: 'video' })}`);
    const x = j.collection?.items?.[0];
    it = x ? fromNasa(x, await getJson(nasaUrl(x.href), 15000), opts) : null;
  }
  else {
    it = fromMet(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${encodeURIComponent(id)}`));
    if (it?.smaller) { const s = await probe(it.smaller); if (s.ok && s.w >= opts.width * 0.9) it.image = it.smaller; }
  }
  if (!it || !it.image) throw new Error(`no usable ${['archive', 'nasa'].includes(src) ? 'video (no rendition a browser plays, or the record is gone)' : 'image (not public domain, or the record has no picture)'}.`);
  if (!opts.licences.includes(it.license)) throw new Error(`its licence is ${licenceLabel(it)}, outside the ones allowed (${opts.licences.join(', ')}). Pass --license all to take it anyway, and say why in your report.`);
  if (it.media === 'video') return getVideo(it, src, id, opts);
  let r;
  try { r = await fetch(it.image, { headers: imageHeaders(it.image), signal: AbortSignal.timeout(90000) }); }
  catch (e) { throw new Error(`the image did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
  if (!r.ok) throw new Error(`the image did not load (HTTP ${r.status}).`);
  const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
  const ext = WEB_TYPES[type];
  if (!ext) throw new Error(`the file is ${type || 'of no stated type'}, not a web image.`);
  const buf = Buffer.from(await r.arrayBuffer());
  if (!buf.length) throw new Error('the image was empty.');
  const d = dims(buf);
  mkdirSync(opts.to, { recursive: true });
  const credits = readCredits(opts);
  const stem = slug(args.name || it.title) || `${src}-${slug(id).slice(0, 12)}`;
  const file = targetFile(opts, stem, ext, it.ref, credits);
  writeFileSync(file, buf);
  const entry = {
    file: rel(file), media: 'image', ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
    license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
    page: it.page, via: it.via, image: it.image, width: d.w || it.imageWidth, height: d.h || it.imageHeight, bytes: buf.length,
    credit: credit(it), notes: [...it.notes, ...licenceNotes(it)], saved: new Date().toISOString(),
  };
  recordCredit(opts, credits, entry);
  const alt = it.alt || it.title;
  console.log(`Saved ${entry.file}${entry.width ? ` · ${entry.width} × ${entry.height}` : ''} · ${size(buf.length)}`);
  console.log(`  page     ${it.page}`);
  console.log(`  credit   ${entry.credit}`);
  console.log(`  markup   <img src="${esc(entry.file)}"${entry.width ? ` width="${entry.width}" height="${entry.height}"` : ''} alt="${esc(short(alt, 120))}" loading="lazy" decoding="async">`);
  console.log('           (rewrite the alt to say what the picture shows; drop loading="lazy" on the hero image)');
  for (const n of entry.notes) console.log(`  note     ${n}`);
}
// A clip: check its size against the cap first, stream it to disk (stopping at the cap), then save a still
// frame from the source as its poster.
async function getVideo(it, src, id, opts) {
  const cap = opts.maxMb * 1048576;
  const head = await probe(it.image, 20000, true);
  if (!head.ok) throw new Error(`the video did not load (${head.why}).`);
  const side = it.imageWidth && it.imageHeight ? Math.min(it.imageWidth, it.imageHeight) : 0;
  if (head.bytes && head.bytes > cap) throw new Error(`the clip is ${size(head.bytes)}, over the ${opts.maxMb} MB cap. ${side > 480 ? 'Try --res 480 for a smaller rendition, or a shorter clip' : 'Choose a shorter clip'}; pass --max-mb ${Math.ceil(head.bytes / 1048576) + 1} only if the page really needs this one.`);
  mkdirSync(opts.to, { recursive: true });
  const credits = readCredits(opts);
  const stem = slug(args.name || it.title) || `${src}-${slug(decodeURIComponent(id)).slice(0, 12)}`;
  const file = targetFile(opts, stem, VIDEO_TYPES[head.type], it.ref, credits);
  let r;
  try { r = await fetch(it.image, { headers: { 'User-Agent': UA, Accept: 'video/*' }, signal: AbortSignal.timeout(600000) }); }
  catch (e) { throw new Error(`the video did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
  if (!r.ok) throw new Error(`the video did not load (HTTP ${r.status}).`);
  const out = createWriteStream(file); const first512 = []; let kept = 0; let n = 0;
  try {
    const reader = r.body.getReader();
    for (;;) {
      const { done, value } = await reader.read(); if (done) break;
      n += value.length;
      if (n > cap) { reader.cancel().catch(() => {}); throw new Error(`the clip passed the ${opts.maxMb} MB cap while downloading; choose a shorter clip or pass a larger --max-mb.`); }
      if (kept < 524288) { first512.push(value); kept += value.length; }
      if (!out.write(value)) await new Promise((res) => out.once('drain', res));
    }
    await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
  } catch (e) { out.destroy(); try { unlinkSync(file); } catch {} throw e; }
  if (!n) { try { unlinkSync(file); } catch {} throw new Error('the video was empty.'); }
  const info = head.type === 'video/mp4' ? mp4Info(Buffer.concat(first512)) : {};
  const w = info.w || it.imageWidth; const h = info.h || it.imageHeight; const duration = info.duration || it.duration;
  let poster = null;
  if (it.poster) {
    try {
      const pr = await fetch(it.poster, { headers: imageHeaders(it.poster), signal: AbortSignal.timeout(60000) });
      const pt = (pr.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
      if (pr.ok && WEB_TYPES[pt]) { poster = file.replace(/\.[^.]+$/, `-poster.${WEB_TYPES[pt]}`); writeFileSync(poster, Buffer.from(await pr.arrayBuffer())); }
    } catch { poster = null; }
  }
  const entry = {
    file: rel(file), media: 'video', poster: poster ? rel(poster) : null, ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
    license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
    page: it.page, via: it.via, video: it.image, width: w || null, height: h || null, duration: duration ? Math.round(duration * 10) / 10 : null, bytes: n,
    credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...videoNotes({ ...it, bytes: n, duration }, opts)], saved: new Date().toISOString(),
  };
  recordCredit(opts, credits, entry);
  console.log(`Saved ${entry.file}${w ? ` · ${w} × ${h}` : ''}${duration ? ` · ${clock(duration)}` : ''} · ${size(n)}`);
  console.log(`  poster   ${entry.poster || 'none from the source: export a still frame yourself, or use a found picture'}`);
  console.log(`  page     ${it.page}`);
  console.log(`  credit   ${entry.credit}`);
  console.log(`  markup   <video autoplay muted loop playsinline preload="metadata"${entry.poster ? ` poster="${esc(entry.poster)}"` : ''}${w ? ` width="${w}" height="${h}"` : ''} aria-hidden="true"><source src="${esc(entry.file)}" type="${head.type}"></video>`);
  console.log('           (a silent loop is decoration: pause it or show the poster under prefers-reduced-motion; references/imagery.md "Video")');
  for (const x of entry.notes) console.log(`  note     ${x}`);
}
async function getAll(refs, opts) {
  let failed = 0;
  for (const ref of refs) {
    try { await getOne(ref, opts); } catch (e) { failed++; console.error(`Could not get ${ref}: ${e.message}`); }
    console.log('');
  }
  if (failed < refs.length) {
    console.log(`Credits are recorded in ${rel(creditsPath(opts))}. Look at each picture and watch each clip before using it.`);
    console.log(`Print the footer credits and the report list with: ${CMD} --credits${opts.toArg}`);
  }
  return failed ? 1 : 0;
}

// ---------- credits ----------
function printCredits(opts) {
  const list = readCredits(opts);
  const uniq = list.filter((c, i) => list.findIndex((x) => x.ref === c.ref) === i && existsSync(resolve(c.file)));
  if (!uniq.length) { console.log(`No pictures recorded in ${rel(creditsPath(opts))} yet. Download them with --get <ref> first.`); return 1; }
  const html = (c) => {
    const by = c.creator && !/^(unknown|anonymous)\b/i.test(c.creator) ? ` by ${esc(short(c.creator, 60))}` : '';
    const date = /^(aic|met):/.test(c.ref) && c.date ? `, ${esc(short(c.date, 30))}` : '';
    const lic = c.licenseUrl ? `<a href="${esc(c.licenseUrl)}">${esc(c.license)}</a>` : esc(c.license);
    return `<a href="${esc(c.page)}">“${esc(short(c.title, 90))}”</a>${by}${date}, ${lic}, via ${esc(c.via)}`;
  };
  const media = new Set(uniq.map((c) => c.media || 'image'));
  const label = media.size > 1 ? 'Pictures and footage' : media.has('video') ? 'Footage' : 'Pictures';
  console.log('Footer credits (HTML). Full contrast, keep the links, body size (one step smaller only on a page with plenty of');
  console.log('running text). Remove anything the page no longer uses, here and in credits.json.\n');
  console.log(`<p class="credits">${label}: ${uniq.map(html).join('; ')}.</p>`);
  console.log('\nFor your report:\n');
  for (const c of uniq) console.log(`- ${c.file}${c.media === 'video' && c.duration ? ` (video, ${clock(c.duration)})` : ''}: ${c.credit}. ${c.page}${c.creditRequired ? '' : ' (no credit required)'}`);
  return 0;
}

// ---------- run ----------
if (args.help) { console.log(USAGE); process.exit(0); }
if (args.credits) process.exit(printCredits(o));
if (args.get) process.exit(await getAll(String(args.get).split(',').map((s) => s.trim()).filter(Boolean), o));
const query = words.join(' ').trim();
if (!query) fail('Give a search query, for example: node assets.mjs "ghent postcard"');
process.exit(await search(query, o));
scripts/draw.mjs›
#!/usr/bin/env node
// tasteskill / draw — deal an art direction from the research library.
//
// The script chooses, not the model: a model asked to pick a direction returns the category
// default every time. The draw is random within what fits the brief, and it remembers recent
// draws so consecutive projects do not look alike.
//
//   node draw.mjs --sector <sector> --brief "one line" [--sections hero,showcase,pricing,footer]
//                 [--tone quiet|bold|warm|technical|playful|luxury|editorial]
//                 [--have photography,product-photo,product-ui,illustration,video]   assets you really have
//                 [--keep palette,type]        the brand already fixes these; draw the rest
//                 [--avoid id,id] [--seed N] [--out DIR]
//                 [--options 3]                show three dealt directions to choose from, write nothing
//                 [--direction <id>]           deal the full sheet for the chosen direction
//   node draw.mjs --show                       reprint the saved direction sheet
//   node draw.mjs --redraw hero|type|palette|photos|scenes|section:<role>|direction   re-deal one slot
//   node draw.mjs --list sectors|directions|roles|tones|scenes
//   node draw.mjs --site <id>                  print one studied reference site in full
//   node draw.mjs --app --kind <kind> ...      deal a style for an app screen instead (see lib/app-draw.mjs)
//
// Zero dependencies. Node 18+.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { homedir } from 'node:os';

const HERE = dirname(fileURLToPath(import.meta.url));
const LIB = join(HERE, '..', 'library');
const ASSETS_PATH = join(HERE, 'assets.mjs').split('\\').join('/');
const ASSETS_CMD = `node ${/\s/.test(ASSETS_PATH) ? `"${ASSETS_PATH}"` : ASSETS_PATH}`;
const load = (name) => JSON.parse(readFileSync(join(LIB, name + '.json'), 'utf8'));

const argv = process.argv.slice(2);
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
const outDir = resolve(String(opt('out', '.taste-skill')));
const sheetPath = join(outDir, 'direction.json');

const SECTORS = ['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance', 'agency', 'studio-portfolio', 'personal-portfolio', 'ecommerce', 'physical-product', 'fashion', 'hospitality', 'restaurant', 'real-estate', 'architecture', 'event', 'health', 'education', 'nonprofit', 'culture', 'music', 'other'];
const TONES = ['quiet', 'bold', 'warm', 'technical', 'playful', 'luxury', 'editorial'];
const DEFAULT_SECTIONS = {
  saas: ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
  'dev-tool': ['hero', 'showcase', 'features', 'how-it-works', 'pricing', 'faq', 'cta', 'footer'],
  'ai-product': ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'cta', 'footer'],
  'consumer-app': ['hero', 'showcase', 'features', 'testimonial', 'cta', 'footer'],
  finance: ['hero', 'showcase', 'features', 'stats', 'testimonial', 'faq', 'cta', 'footer'],
  agency: ['hero', 'intro', 'work', 'services', 'about', 'cta', 'footer'],
  'studio-portfolio': ['hero', 'work', 'intro', 'services', 'about', 'cta', 'footer'],
  'personal-portfolio': ['hero', 'work', 'about', 'cta', 'footer'],
  ecommerce: ['hero', 'gallery', 'features', 'intro', 'testimonial', 'cta', 'footer'],
  'physical-product': ['hero', 'showcase', 'features', 'gallery', 'intro', 'pricing', 'cta', 'footer'],
  fashion: ['hero', 'gallery', 'intro', 'work', 'cta', 'footer'],
  hospitality: ['hero', 'intro', 'gallery', 'features', 'list', 'contact', 'footer'],
  restaurant: ['hero', 'intro', 'list', 'gallery', 'contact', 'footer'],
  'real-estate': ['hero', 'intro', 'gallery', 'features', 'stats', 'contact', 'footer'],
  architecture: ['hero', 'work', 'intro', 'about', 'contact', 'footer'],
  event: ['hero', 'intro', 'list', 'features', 'pricing', 'faq', 'cta', 'footer'],
  health: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'faq', 'cta', 'footer'],
  education: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
  nonprofit: ['hero', 'intro', 'stats', 'features', 'about', 'cta', 'footer'],
  culture: ['hero', 'intro', 'gallery', 'list', 'about', 'cta', 'footer'],
  music: ['hero', 'list', 'gallery', 'about', 'cta', 'footer'],
  other: ['hero', 'intro', 'features', 'showcase', 'cta', 'footer'],
};
// requested role names → the role vocabulary used by library/sections.json
const ROLE_ALIAS = { features: 'features', feature: 'features', benefits: 'features', services: 'features', showcase: 'showcase', product: 'showcase', demo: 'showcase', 'how-it-works': 'how-it-works', how: 'how-it-works', process: 'how-it-works', steps: 'how-it-works', testimonial: 'testimonial', testimonials: 'testimonial', quotes: 'testimonial', proof: 'proof', logos: 'proof', press: 'proof', stats: 'stats', numbers: 'stats', pricing: 'pricing', plans: 'pricing', faq: 'faq', cta: 'cta', footer: 'footer', intro: 'intro', manifesto: 'intro', about: 'about', team: 'about', work: 'work', projects: 'work', index: 'work', 'case-studies': 'work', gallery: 'gallery', images: 'gallery', list: 'list', menu: 'list', schedule: 'list', lineup: 'list', 'line-up': 'list', speakers: 'list', agenda: 'list', programme: 'list', program: 'list', timetable: 'list', rooms: 'list', tickets: 'pricing', price: 'pricing', contact: 'contact', booking: 'contact', newsletter: 'contact', signup: 'contact' };

// App screens (dashboards, admin, settings) have their own library and sheet: scripts/lib/app-draw.mjs.
const savedKind = (() => { try { return JSON.parse(readFileSync(sheetPath, 'utf8')).kind; } catch { return null; } })();
if (opt('app') || ((opt('show') || opt('redraw')) && savedKind === 'app')) await import('./lib/app-draw.mjs');

if (opt('list')) {
  const what = opt('list');
  if (what === 'sectors') console.log(SECTORS.join('\n'));
  else if (what === 'tones') console.log(TONES.join('\n'));
  else if (what === 'roles') console.log([...new Set(Object.values(ROLE_ALIAS))].join('\n'));
  else if (what === 'scenes') { const all = existsSync(join(LIB, 'scenes.json')) ? load('scenes') : []; for (const sc of all) console.log(`${sc.id.padEnd(34)} ${sc.name} — ${sc.family}; ${sc.summary.split(/(?<=\.)\s/)[0]}`); if (!all.length) console.log('No scene library yet.'); }
  else for (const d of load('directions')) console.log(`${d.id.padEnd(30)} ${d.name} — ${d.sectors.join(', ')}`);
  process.exit(0);
}
if (opt('photos')) {
  const lib = load('photos'); const q = String(opt('photos'));
  const line = (ph) => `- https://picsum.photos/id/${ph.id}/1600/1000 — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · photo: ${ph.by}`;
  if (q === 'list' || q === 'true') { for (const [id, set] of Object.entries(lib.sets)) console.log(`${id.padEnd(22)} ${String(set.photos.length).padStart(3)} photos — ${set.note}`); }
  else if (lib.sets[q]) { console.log(`# ${lib.sets[q].title} — ${lib.sets[q].note}`); for (const ph of lib.sets[q].photos) console.log(line(ph)); }
  else { // free-text search across all descriptions
    const words = q.toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 3);
    const hits = Object.values(lib.sets).flatMap((s) => s.photos).filter((ph) => words.some((w) => (ph.alt + ' ' + (ph.colours || []).join(' ')).toLowerCase().includes(w)));
    if (!hits.length) console.log('No photograph matches. `--photos list` shows the sets.'); else for (const ph of hits.slice(0, 24)) console.log(line(ph));
  }
  console.log('\nPath numbers are width/height; add ?grayscale to desaturate. Photographs are atmosphere, never a stand-in for a specific product or person.');
  process.exit(0);
}
if (opt('site')) {
  const s = load('sites').find((x) => x.id === opt('site') || x.name.toLowerCase() === String(opt('site')).toLowerCase());
  if (!s) { console.error('No studied site with that id. Ids appear in the direction sheet under "Real references".'); process.exit(2); }
  console.log(siteCard(s, true)); process.exit(0);
}

// ---------- randomness (seedable) ----------
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
const startSeed = seed;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
// prefer items not used recently, without ever emptying the pool
const fresh = (pool, recent) => { const f = pool.filter((x) => !recent.includes(x.id)); return f.length ? f : pool; };

// ---------- history (project + user level) ----------
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
const readHist = () => { for (const p of [userHist]) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch {} } return []; };
const writeHist = (entry) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(entry); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };

// ---------- show / redraw ----------
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
if (opt('show')) {
  if (!saved) { console.error('No saved direction. Run a draw first.'); process.exit(2); }
  console.log(render(saved)); process.exit(0);
}

const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions'), scenes: existsSync(join(LIB, 'scenes.json')) ? load('scenes') : [] };
const hist = readHist();
const recent = (key, n) => hist.slice(0, n).map((h) => h[key]).filter(Boolean);

let sheet;
if (opt('alternatives')) {
  // The other blueprints of one role that suit the saved direction: for when the dealt one cannot hold
  // the real content (it assumes three plans and there is one; it needs five quotes and there are none).
  if (!saved) { console.error('Run a draw first.'); process.exit(2); }
  const role = ROLE_ALIAS[String(opt('alternatives'))] || String(opt('alternatives'));
  const dir = L.directions.find((d) => d.id === saved.direction.id);
  const current = saved.sections.find((s) => s.role === role)?.id;
  const pool = sectionPool(dir, role, saved.input);
  if (!pool.length) { console.error('No blueprints for role ' + role + '. Roles: --list roles'); process.exit(2); }
  const line = (s) => `- \`${s.id}\`${s.id === current ? ' (dealt)' : ''} — **${s.name}** (${s.shape}). ${s.blueprint.split(/(?<=\.)\s/).slice(0, 2).join(' ')}\n`;
  console.log(`# ${role}: blueprints that suit "${dir.name}"\n`);
  for (const s of pool) console.log(line(s));
  const software = imageryMode(saved.input) === 'interface';
  const others = L.sections.filter((s) => s.role === role && !pool.includes(s) && (software || !softwareOnly(s)) && (s.needs || []).every((n) => saved.input.have.includes(n) || (n === 'product-ui' && software)));
  if (others.length) {
    console.log(`# Other ${role} blueprints (written for other directions; if one fits the content better, take its geometry and set it in this direction's type, colour and manner)\n`);
    for (const s of others) console.log(line(s));
  }
  console.log(`Choose by what the content really is (how many plans, quotes, items exist), then: node draw.mjs --redraw section:${role}=<id>   and say why in your report.`);
  process.exit(0);
}
if (opt('redraw')) {
  if (!saved) { console.error('Nothing to redraw. Run a draw first.'); process.exit(2); }
  sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1; // the sheet keeps its original seed; slot redraws are counted
  const what = String(opt('redraw'));
  const dir = L.directions.find((d) => d.id === sheet.direction.id);
  if (what === 'direction') sheet = deal({ ...sheet.input, direction: null, avoid: [...(sheet.input.avoid || []), sheet.direction.id] }, sheet.redraws);
  else if (what === 'hero') sheet.hero = dealHero(dir, sheet.input, [sheet.hero?.id]);
  else if (what === 'type') sheet.type = dealType(dir, [sheet.type?.id]);
  else if (what === 'palette') sheet.palette = dealPalette(dir, sheet.input, [sheet.palette?.id]);
  else if (what === 'photos') sheet.photos = dealPhotos(dir, sheet.input);
  else if (what === 'scenes') sheet.scenes = dealScenes(dir, sheet.input, (sheet.scenes || []).map((x) => x.id));
  else if (what.startsWith('section:')) {
    // section:<role> re-deals at random; section:<role>=<id> takes a named alternative (see --alternatives)
    const [roleRaw, wanted] = what.slice(8).split('=');
    const role = ROLE_ALIAS[roleRaw] || roleRaw;
    const i = sheet.sections.findIndex((s) => s.role === role);
    if (i === -1) { console.error('No section with role ' + role + ' in this sheet.'); process.exit(2); }
    const used = sheet.sections.map((s) => s.id);
    if (wanted) {
      // a blueprint written for another role may be borrowed when its geometry fits the content better
      const chosen = L.sections.find((s) => s.id === wanted);
      if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives <role>.`); process.exit(2); }
      sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` };
    } else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i];
  } else { console.error('Unknown slot. Use hero, type, palette, photos, scenes, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
} else {
  const sector = String(opt('sector', ''));
  if (!SECTORS.includes(sector)) {
    console.error(`--sector is required. One of:\n  ${SECTORS.join(', ')}\nPick the closest; use "other" only when nothing fits.`);
    process.exit(2);
  }
  const requested = list(opt('sections'));
  const input = {
    brief: typeof opt('brief') === 'string' ? opt('brief') : '', sector,
    tone: TONES.includes(opt('tone')) ? opt('tone') : null,
    have: list(opt('have')), keep: list(opt('keep')), avoid: list(opt('avoid')),
    sections: (requested.length ? requested : DEFAULT_SECTIONS[sector]).map((r) => ROLE_ALIAS[r] || r).filter((r) => r !== 'hero'),
    direction: typeof opt('direction') === 'string' ? opt('direction') : null,
  };
  if (input.direction && !L.directions.some((d) => d.id === input.direction)) { console.error(`No direction with id ${input.direction}. List them with --list directions.`); process.exit(2); }
  if (opt('options')) {
    const n = Math.max(2, Math.min(5, Number(opt('options')) || 3));
    const options = dealOptions(input, n);
    console.log(`# ${n} directions dealt for this brief\n`);
    console.log('Choose the one whose way of composing can best carry an idea from your inventory (SKILL.md steps 1 and 4), not the');
    console.log('most familiar one. Then deal it in full with the same command plus `--direction <id>` (and without --options).\n');
    for (const d of options) {
      console.log(`## \`${d.id}\` — ${d.name}`);
      console.log(`Idea engine: ${d.idea.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
      console.log(`Composition: ${d.composition.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
      if (d.needsPictures) console.log('Needs: many pictures. Without photographs of its own, each is a real picture of the subject\'s world found with scripts/assets.mjs, or a drawing you make.');
      console.log('');
    }
    process.exit(0);
  }
  sheet = deal(input, 0);
}

mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector, scenes: (sheet.scenes || []).map((x) => x.id) });
console.log(render(sheet));

// ---------- dealing ----------
// What kind of pictures this build can honestly have. It decides which blueprints are dealt and how the
// sheet tells the agent to fill picture slots.
function imageryMode(input) {
  if (input.have.includes('product-photo') || input.have.includes('photography')) return 'own';
  if (['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance'].includes(input.sector)) return 'interface';
  if (['physical-product', 'ecommerce'].includes(input.sector)) return 'no-product-photo';
  return 'atmosphere';
}
function pictureLed(x) { return (x.tags || []).includes('photo-led') || (x.tags || []).includes('object-led'); }
function worksWithoutPictures(x) { return !pictureLed(x) || (x.tags || []).some((t) => ['ui-led', 'type-led', 'colour-field'].includes(t)); }

// The directions that suit this brief and the assets it really has.
function directionPool(input) {
  const has = (need) => input.have.includes(need) || need === 'photography'; // the photo library covers photography
  let pool = L.directions.filter((d) => (d.sectors.includes(input.sector) || d.sectors.includes('*')) && !input.avoid.includes(d.id));
  const feasible = pool.filter((d) => (d.needs || []).every(has));
  if (feasible.length) pool = feasible;
  // directions built on photographs of the subject are not dealt when the subject cannot be photographed honestly
  const mode = imageryMode(input);
  if (mode === 'interface' || mode === 'no-product-photo') { const plain = pool.filter((d) => !d.needsPictures); if (plain.length >= 3) pool = plain; }
  if (input.tone) { const toned = pool.filter((d) => (d.tones || []).includes(input.tone)); if (toned.length >= 2) pool = toned; }
  if (!pool.length) pool = L.directions.filter((d) => !input.avoid.includes(d.id));
  return pool;
}
// Three directions dealt at random from the pool, for the builder to choose the one that carries its idea.
function dealOptions(input, n) {
  const pool = directionPool(input); const out = [];
  let rest = fresh(pool, recent('direction', Math.min(5, pool.length - n)));
  while (out.length < n && pool.length > out.length) {
    if (!rest.length) rest = pool.filter((d) => !out.includes(d));
    const d = pick(rest); out.push(d); rest = rest.filter((x) => x !== d);
  }
  return out;
}
function deal(input, redraws) {
  const pool = directionPool(input);
  const forced = input.direction ? L.directions.find((d) => d.id === input.direction) : null;
  const dir = forced || pick(fresh(pool, recent('direction', Math.min(5, pool.length - 1))));
  const s = { version: 1, at: new Date().toISOString(), seed: startSeed, redraws, input, imagery: imageryMode(input), allow: dir.allow || [], direction: dir };
  s.hero = dealHero(dir, input, []);
  s.type = input.keep.includes('type') ? null : dealType(dir, []);
  s.palette = input.keep.includes('palette') ? null : dealPalette(dir, input, []);
  s.photos = dealPhotos(dir, input);
  s.motion = L.motions.find((m) => m.id === (s.hero?.motion || dir.motion)) || L.motions.find((m) => m.id === dir.motion) || null;
  s.sections = [];
  for (const role of input.sections) {
    const sec = dealSection(dir, role, s.sections.map((x) => x.id), s.sections.at(-1)?.shape, null, input);
    if (sec) s.sections.push(sec); else s.sections.push({ id: null, role, shape: null, name: role, blueprint: null });
  }
  s.references = dealReferences(dir, input);
  s.scenes = dealScenes(dir, input, []);
  return s;
}
// The page's big scroll moments (references/scenes.md): two or three tested scenes from different
// families, each placed on a section of the argument it can carry. Expressive sectors and tones get three.
function dealScenes(dir, input, exclude) {
  if (!L.scenes.length) return [];
  const has = (need) => input.have.includes(need) || need === 'photography';
  const fits = (sc) => !exclude.includes(sc.id) && (sc.sectors || ['*']).some((x) => x === '*' || x === input.sector) && (sc.needs || []).every(has);
  let pool = L.scenes.filter(fits);
  if (pool.length < 2) pool = L.scenes.filter((sc) => !exclude.includes(sc.id) && (sc.needs || []).every(has));
  if (!pool.length) return [];
  const loud = ['agency', 'studio-portfolio', 'personal-portfolio', 'fashion', 'culture', 'music', 'event', 'architecture', 'physical-product', 'real-estate', 'hospitality'].includes(input.sector) || ['bold', 'playful', 'luxury', 'editorial'].includes(input.tone);
  const quiet = ['quiet', 'technical'].includes(input.tone);
  const n = Math.min(pool.length, quiet ? 2 : loud ? 3 : 2);
  const recentIds = hist.slice(0, 4).flatMap((h) => h.scenes || []);
  let rest = fresh(pool, recentIds);
  const out = []; const families = new Set(); const usedRoles = new Set();
  while (out.length < n) {
    let cands = rest.filter((sc) => !families.has(sc.family) && !(sc.position === 'opening' && out.some((o) => o.position === 'opening')) && !(sc.position === 'closing' && out.some((o) => o.position === 'closing')));
    if (!cands.length) cands = pool.filter((sc) => !out.includes(sc) && !families.has(sc.family));
    if (!cands.length) break;
    const sc = pick(cands); out.push(sc); families.add(sc.family); rest = rest.filter((x) => x !== sc);
  }
  // the section each scene carries: the first role of the argument it suits that no other scene has taken
  const roles = ['hero', ...input.sections];
  return out.map((sc) => {
    const role = sc.position === 'opening' ? 'hero' : roles.find((r) => (sc.roles || []).includes(r) && !usedRoles.has(r) && r !== 'hero') || null;
    if (role) usedRoles.add(role);
    return { ...sc, carries: role };
  }).sort((a, b) => (a.carries ? roles.indexOf(a.carries) : 99) - (b.carries ? roles.indexOf(b.carries) : 99));
}
function dealHero(dir, input, exclude) {
  // the direction's own list, and any hero written for this direction since (the October 2026 study added some)
  let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id) || (h.directions || []).includes(dir.id));
  if (!pool.length) pool = L.heroes;
  if (imageryMode(input) !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((h) => !softwareOnly(h)); if (plain.length) pool = plain; }
  const ok = pool.filter((h) => !exclude.includes(h.id) && (h.needs || []).every((n) => n === 'photography' || input.have.includes(n)));
  return pick(fresh(ok.length ? ok : pool.filter((h) => !exclude.includes(h.id)).length ? pool.filter((h) => !exclude.includes(h.id)) : pool, recent('hero', 3)));
}
function dealType(dir, exclude) {
  let pool = L.type.filter((t) => (dir.type || []).includes(t.id) || (dir.typeClasses || []).includes(t.class));
  if (!pool.length) pool = L.type;
  const p = pool.filter((t) => !exclude.includes(t.id));
  return pick(fresh(p.length ? p : pool, recent('type', Math.min(6, pool.length - 1))));
}
function dealPalette(dir, input, exclude) {
  let pool = L.palettes.filter((p) => (dir.palettes || []).includes(p.id) || (p.tags || []).some((t) => (dir.paletteTags || []).includes(t)));
  if (!pool.length) pool = L.palettes;
  const p = pool.filter((x) => !exclude.includes(x.id));
  return pick(fresh(p.length ? p : pool, recent('palette', Math.min(6, pool.length - 1))));
}
function dealPhotos(dir, input) {
  if (dir.photos === false) return null; // image-free directions
  const words = briefWords(input.brief);
  const mode = imageryMode(input);
  const SOFT_SETS = ['architecture', 'fog-and-quiet', 'sky-and-space', 'texture', 'water'];
  // subject first (sector + words of the brief), then the direction's preferred tone
  const scored = Object.entries(L.photos.sets).map(([id, set]) => {
    let score = (set.sectors || []).includes(input.sector) ? 3 : 0;
    if ((set.primary || []).includes(input.sector)) score += 5;
    // rooms and tables would be taken for this very house: for places to stay, deal the surroundings instead
    if (mode === 'atmosphere' && ['hospitality', 'real-estate'].includes(input.sector) && id === 'interior-and-table') score -= 6;
    score += words.filter((w) => (set.keywords || []).some((k) => k === w || (k.length >= 5 && w.startsWith(k)) || (w.length >= 5 && k.startsWith(w)))).length * 4;
    score += set.photos.filter((ph) => words.some((w) => ph.alt.toLowerCase().includes(w))).length;
    if (dir.photoTone) score += set.photos.filter((ph) => ph.tone === dir.photoTone).length / set.photos.length;
    return { id, set, score };
  }).filter((x) => x.score > 0 && x.set.photos.length >= 5 && (mode !== 'interface' || SOFT_SETS.includes(x.id))).sort((a, b) => b.score - a.score);
  if (!scored.length) return null;
  // one set, so the photographs share a subject and a light; choose among the best-fitting few
  const top = scored.filter((x) => x.score >= scored[0].score * 0.6).slice(0, 4);
  const chosen = pick(top);
  let pool = chosen.set.photos;
  if (dir.photoTone) { const toned = pool.filter((ph) => ph.tone === dir.photoTone || ph.tone === 'mid'); if (toned.length >= 6) pool = toned; }
  // Without product photographs, a picture of the same kind of thing would pass for the product: deal
  // those last and mark them. Otherwise a picture that matches the brief is the better atmosphere.
  const things = words.filter((w) => w.length >= 5);
  const matches = (ph) => things.some((w) => ph.alt.toLowerCase().includes(w));
  const bonus = mode === 'no-product-photo' ? -2 : 2;
  const ranked = shuffle(pool).sort((a, b) => (b.quality + (matches(b) ? bonus : 0)) - (a.quality + (matches(a) ? bonus : 0)))
    .map((ph) => (mode === 'no-product-photo' && matches(ph) ? { ...ph, lookalike: true } : ph));
  return { set: chosen.id, title: chosen.set.title, note: chosen.set.note, treatment: dir.photoTreatment || null, others: scored.filter((x) => x.id !== chosen.id).slice(0, 4).map((x) => x.id), photos: ranked.slice(0, mode === 'interface' ? 4 : 8) };
}
function briefStop() {
  return new Set(['with', 'that', 'this', 'from', 'page', 'site', 'website', 'landing', 'their', 'they', 'have', 'will', 'your', 'about', 'into', 'make', 'build', 'create', 'design', 'small', 'based', 'called', 'named', 'which', 'where', 'what', 'need', 'want', 'like', 'also', 'some', 'very', 'more', 'most', 'just', 'than', 'then', 'them', 'and', 'the', 'for', 'one', 'hand', 'handmade', 'made', 'batch', 'ship', 'ships', 'week', 'within', 'across', 'euro', 'euros', 'cost', 'price', 'open', 'only', 'each', 'every', 'there', 'images', 'photos', 'photograph', 'static', 'html']);
}
function briefWords(brief) {
  const stop = briefStop();
  return [...new Set(String(brief || '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4 && !stop.has(w)).map((w) => w.replace(/(ies|es|s)$/, '')))].filter((w) => w.length >= 4);
}
// First queries for scripts/assets.mjs, built from the brief: the place in old postcards and maps, then the
// subject itself. In testing "<place> postcard" and "<place> old map" found archival material far more
// reliably than a place and a year. A start only: the better queries come from the builder's inventory.
function assetQueries(input) {
  const brief = String(input.brief || '');
  const notPlace = /^(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?$/i;
  const placeRe = /(?:^|[\s,(])(?:in|at|near|from|on|above|below|beside|outside|off|over)\s+(\p{Lu}[\p{L}'’-]*(?:\s+(?:\p{Lu}[\p{L}'’-]*|de|da|do|dos|das|di|del|van|von|der|den))*)/gu;
  const place = [...brief.matchAll(placeRe)].map((m) => m[1].trim()).find((p) => !notPlace.test(p.split(/\s+/)[0]));
  const stop = briefStop();
  const generic = new Set(['seat', 'seats', 'room', 'rooms', 'minute', 'minutes', 'hour', 'hours', 'night', 'nights', 'year', 'years', 'people', 'family', 'local', 'short', 'best', 'making', 'selling', 'serving', 'offering', 'business', 'company', 'brand', 'shop', 'store', 'studio', 'service', 'services', 'dinner', 'lunch', 'breakfast', 'included', 'online', 'modern']);
  // the subject: the last plain words of the first noun phrase ("a 38-seat Italian restaurant in ..." gives "restaurant")
  const words = brief.split(/\s+/).filter(Boolean); const phrase = [];
  for (let k = words.findIndex((w) => /^an?$/i.test(w)) + 1; k < words.length; k++) {
    if (/^(?:in|at|on|for|from|with|near|above|below|by|that|which|who|of|and|to)$/i.test(words[k])) break;
    phrase.push(words[k].replace(/[,.;:()!?]+$/, ''));
    if (/[,.;:()!?]$/.test(words[k])) break;
  }
  const plain = phrase.filter((w) => /^\p{Ll}{3,}$/u.test(w) && !stop.has(w) && !generic.has(w));
  const subject = plain.slice(-2).join(' ') || (input.sector !== 'other' ? input.sector.replace(/-/g, ' ') : null);
  const out = [];
  if (place) out.push(`${place.toLowerCase()} postcard`, `${place.toLowerCase()} old map`);
  if (subject) out.push(subject.toLowerCase());
  return [...new Set(out)];
}
// Blueprints written for showing software (an interface fragment is their picture).
function softwareOnly(x) { return (x.directions || [])[0] === 'product-performs' || (x.tags || []).includes('ui-led') || /data-ui|interface fragment/i.test(x.blueprint || ''); }
// Blueprints were measured on real sites and carry that site's sizes, greys, faces, colours and corners.
// This lists, for one blueprint, exactly where it disagrees with the house rules and the dealt system,
// so the builder does not have to work it out section by section.
function adjustments(bp, s) {
  const text = [bp.blueprint, bp.mobile, bp.headline].filter(Boolean).join(' ');
  const notes = [];
  const sizes = (re) => [...new Set([...text.matchAll(re)].map((m) => m[1] + 'px'))];
  const notSpacing = String.raw`(?!\s*(?:gaps?|gutters?|margins?|padding|radius|corners?|apart|from|inset|offset|high|tall|wide|thick|square|borders?|stroke|rules?|line\b|dots?|circles?|bars?|of|between|beneath|below|above|under|down|up|across|deep|left|right|in\b|to\b|and\b|each))`;
  const tiny = sizes(new RegExp(String.raw`(?<!(?:radius|radii|gap|margin|padding|inset|offset)[:\s]{0,3}(?:of\s)?)\b(9|10|11|12)(?:\.\d)?px\b` + notSpacing, 'g'));
  if (tiny.length) notes.push(`It sets text at ${tiny.join(', ')}: use your small step (13px or more) instead.`);
  const running = sizes(/\b(14|15)px\s*(?:paragraphs?|body|text|descriptions?|copy|sentences?|answers?)/gi);
  if (running.length) notes.push(`It sets running text at ${running.join(', ')}: paragraphs use your body step (16px or more).`);
  if (/\d{1,2}% (?:opacity|black|ink|grey|gray|white)|opacity:? ?0?\.\d|\b(?:rest|others?|remaining)\b[^.;]{0,25}\b(?:at|to) \d{1,2}%(?! (?:of|width|wide))|full (?:ink|white|strength)[^.;]{0,60}\bat \d{1,2}%(?! (?:of|width|wide))/i.test(text)) notes.push('It fades text: anything a visitor reads, inactive items of a list included, is `--ink` or `--muted` at full strength (contrast 4.5:1 or more). The active item is told apart by `--ink` against `--muted`, weight or the accent, not by fading the rest.');
  if (/\d+px (?:corner )?radius|radius (?:of )?\d+px|\brounded\b|\bpill/i.test(text)) notes.push('It names its own corners: use the one radius of this page (square unless the direction says otherwise) and one button shape.');
  if (/\b(?:serif|sans-serif|condensed|grotesk|italic|monospace|mono|all caps|uppercase|capitals|script)\b/i.test(text)) notes.push(s.type ? `It names a kind of typeface: use the dealt faces (display ${s.type.display.family}, text ${s.type.text.family}) and their settings; capitals only where the type system sets them.` : 'It names a kind of typeface: use the brand faces.');
  const colours = [...new Set([...text.matchAll(/\b(green|red|blue|yellow|orange|pink|purple|teal|navy|cream|beige|brown|grey|gray|white|black)\b(?![- ](?:space|studio|background disappears))/gi)].map((m) => m[1].toLowerCase()))];
  if (colours.length) notes.push(`It names colours (${colours.join(', ')}): map them onto this palette's roles (ground \`--bg\`, panels \`--surface\` and the fields, text \`--ink\`/\`--muted\`, the accent \`--accent\` on the primary action only).`);
  if (/(?<!\b(?:no|without|never|not|nor) (?:a |any )?)\b(?:rule|rules|hairline|border|borders|divider|underline|thin line|1px line)\b/i.test(text)) notes.push('It draws lines or borders: keep them only inside a table, a form or a `data-ui` fragment; elsewhere separate with space or a change of ground.');
  if (/shadow|glow/i.test(text)) notes.push('It mentions a shadow: only on something open above the page (a menu, a dialog), never on a resting element.');
  const bigSentence = [...text.matchAll(/\b(?:sentence|statement|paragraph|quotation|quote)\b[^.;]{0,40}?\b(?:at|to)\s+(?:clamp\(\s*\d+(?:\.\d+)?px,\s*)?(\d+(?:\.\d+)?)(px|vw)/gi)].some((m) => (m[2] === 'px' && +m[1] > 36) || (m[2] === 'vw' && +m[1] > 2.6));
  if (bigSentence) notes.push('It sets a sentence large over several lines: jurors read that as a long headline. Either cut it to a two-line headline at display size, or keep it a statement at 36px or less on three lines (24px, four lines on a phone).');
  if (/(?:sticky|pinned|pins?\b|stays? (?:fixed|put|in place))/i.test(text) && /\b(?:three|four|five|six) (?:to (?:four|five|six) )?(?:viewports|screens)\b|\b[3-6]00svh\b/i.test(text)) notes.push('It pins something for three screens or more: hold a pin for two or three screens at most, give every screen under it its own content, and let nothing pass over the pinned words (the inspector checks all three).');
  if (/mix-blend-mode:\s*(?:difference|exclusion)/i.test(text)) notes.push('It blends text with `difference`: over anything but black, white or grey that gives random hues. Use a solid ink chosen for each ground instead.');
  if (/tops? of the (?:letters|word|name|wordmark)|(?:letters|wordmark|name|word)\b[^.;]{0,30}\b(?:sliced|cut|cropped|clipped)[^.;]{0,20}\btop/i.test(text)) notes.push('It crops letters along their top: the inspector fails that (jurors read it as broken). Crop at the bottom edge or a side instead.');
  if (bp.archetype && /single action[^.;]{0,40}?\b(?:text link|underlined (?:text )?link)/i.test(text)) notes.push('Its action is a text link: the primary action on the first screen is a real button (filled or solid-bordered, 44px or taller), placed where the blueprint puts the link. A second action may stay a link.');
  if (s.type?.class === 'condensed-heavy' && /\d+(?:\.\d+)?vw/.test(text)) notes.push('Its vw sizes assume a face of normal width: with this condensed display face, size the words to fill the measure the blueprint gives them instead of copying the vw value.');
  if (/\b0[1-9]\b(?![:.,]\d)|numbered|index number/i.test(text)) notes.push('It numbers things 01, 02: drop the numbers unless they are real data (a price, a date, a count).');
  return notes;
}
// Every blueprint of a role that suits this direction and this build's real assets.
function sectionPool(dir, role, input) {
  let pool = L.sections.filter((s) => s.role === role);
  if (!pool.length) return [];
  const mode = imageryMode(input);
  const feasible = pool.filter((s) => (s.needs || []).every((n) => n === 'photography' || input.have.includes(n) || (n === 'product-ui' && mode === 'interface')));
  if (feasible.length) pool = feasible;
  if (mode !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((s) => !softwareOnly(s)); if (plain.length) pool = plain; }
  if (mode === 'interface' || mode === 'no-product-photo') {
    // prefer compositions that do not hang on a photograph of the thing
    const honest = pool.filter((s) => (mode === 'interface' ? worksWithoutPictures(s) : !(s.tags || []).includes('object-led') || worksWithoutPictures(s)));
    if (honest.length >= 2) pool = honest;
  }
  const byDirection = pool.filter((s) => (s.directions || []).includes(dir.id));
  if (byDirection.length) return byDirection;
  const byTag = pool.filter((s) => (s.tags || []).some((t) => (dir.sectionTags || []).includes(t)));
  return byTag.length ? byTag : pool;
}
function dealSection(dir, role, usedIds, prevShape, nextShape, input) {
  let pool = sectionPool(dir, role, input);
  if (!pool.length) return null;
  if (imageryMode(input) === 'interface') { const ui = pool.filter((s) => (s.tags || []).includes('ui-led')); if (ui.length >= 2 && rnd() < 0.6) pool = ui; }
  const unused = pool.filter((s) => !usedIds.includes(s.id)); if (unused.length) pool = unused;
  const varied = pool.filter((s) => s.shape !== prevShape && s.shape !== nextShape); if (varied.length) pool = varied;
  return pick(pool);
}
function dealReferences(dir, input) {
  const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean);
  // the October 2026 panel scored on a lower scale (its top band starts at 7.5, the first panel's at about 7.6)
  const good = (s, a, b) => s.score >= (s.panel === '2026-10' ? b : a);
  const sameKind = L.sites.filter((s) => s.kind === input.sector && good(s, 7.2, 7.3) && !named.includes(s));
  const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)];
  return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2);
}

// ---------- rendering ----------
function siteCard(s, full) {
  const lines = [`- **${s.name}** (${s.source}, studied score ${s.score}) \`${s.id}\``, `  Idea: ${s.idea}`, `  Hero: ${s.hero}`];
  if (full) {
    lines.push(`  Type: ${s.type}`, `  Colour: ${s.colour}`, `  Imagery: ${s.imagery}`, `  Layout: ${s.layout}`);
    if (s.media) lines.push(`  Pictures and footage: ${s.media}`);
    if (s.motion) lines.push(`  Motion: ${s.motion}`);
    for (const sec of s.sections || []) lines.push(`  Section (${sec.role}): ${sec.description}`);
    for (const t of s.steal || []) lines.push(`  Worth taking: ${t}`);
    lines.push(`  URL: ${s.url}`);
  } else if (s.steal?.[0]) lines.push(`  Worth taking: ${s.steal[0]}`);
  return lines.join('\n');
}
function render(s) {
  const d = s.direction; const o = [];
  const p = (x = '') => o.push(x);
  p(`# DIRECTION SHEET — ${d.name}`);
  p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · sector ${s.input.sector}${s.input.tone ? ' · tone ' + s.input.tone : ''}`);
  if (s.input.brief) p(`Brief: ${s.input.brief}`);
  p();
  p('| | |'); p('| --- | --- |');
  p(`| Direction | ${d.name} |`);
  p(`| Hero | ${s.hero.name} |`);
  p(`| Type | ${s.type ? s.type.name : 'kept from the brand'} |`);
  p(`| Colour | ${s.palette ? s.palette.name : 'kept from the brand'} |`);
  p(`| Sections | ${s.sections.map((x) => x.role + (x.name && x.name !== x.role ? ' (' + x.name + ')' : '')).join(' → ')} |`);
  p(`| Motion | ${s.motion ? s.motion.name : '—'} |`);
  p(`| Photographs | ${s.photos ? 'the "' + (s.photos.title || s.photos.set) + '" set, as atmosphere' : 'none'} |`);
  p();
  p('This is the direction for this build. Adopt it. Do not blend it with the layout you would have made anyway,');
  p('and do not redraw because it is unfamiliar: unfamiliar is the point. Redraw a slot only when it conflicts');
  p('with a stated brand constraint or needs an asset that does not exist, and say why in your report.');
  p();
  p('The parts of this sheet were written separately and will disagree in small ways. Settle every disagreement in this order:');
  p('**house rules** (including type no smaller than 13px and body text of 16px or more) → **the direction** (the idea, the composition habits, where the');
  p('one accent goes) → **the type system and the palette** (which face does which job, the five size steps, which colour is the ground and where the');
  p('accent goes) → **the hero blueprint** → **the section blueprints**. Under each blueprint below, "Adjusted for this page" lists where it disagrees.');
  p('In practice: take geometry from blueprints (columns, proportions, what sits where) and take everything else from above them. Map every size a');
  p('blueprint names onto one of your five type steps instead of adding a sixth. Use one button shape, one corner radius and one pair of page margins on');
  p('the whole page, whatever individual blueprints say. If a blueprint names a kind of typeface (a light serif, a heavy grotesk), use the dealt face.');
  p();
  p('## 1. The idea'); p(d.idea); p();
  p('Before anything else, write one sentence in your notes: "On this page, <the subject\'s own material> becomes <the device>."');
  p('Fill the first blank from your inventory of what only this client has (its name and what it means, its street, its numbers, its');
  p('constraint), then put the nearest competitor in the sentence: if it still works, the idea is the category\'s, not the client\'s.');
  p('Then name the one live thing the visitor can do on the page (SKILL.md step 4).'); p();
  p('## 2. Composition'); p(d.composition); p();
  if (s.type) {
    const t = s.type;
    p(`## 3. Type — ${t.name}`); p(t.note);
    p('```html'); for (const l of [...new Set([t.display.css, t.text.css].filter(Boolean))]) p(l); p('```');
    p(`- Display: **${t.display.family}** — ${t.display.settings}`);
    p(`- Text: **${t.text.family}** — ${t.text.settings}`);
    if (t.scale) { p('```css'); p(':root {'); for (const [k, v] of Object.entries(t.scale)) p(`  --${k}: ${v};`); p('}'); p('```'); }
    if (d.typography) p(d.typography);
    p('The direction decides sizes and how many of them; the type system decides how this face is set. Where the two give different tracking or leading, use the values of the type system: they are tuned to this face.');
    p('The display size itself comes from the hero blueprint below.');
    p();
  } else { p('## 3. Type'); p('Kept from the brand. ' + (d.typography || '')); p(); }
  if (s.palette) {
    const c = s.palette;
    p(`## 4. Colour — ${c.name}`); p('```css'); p(':root {');
    for (const k of ['bg', 'surface', 'ink', 'muted', 'accent', 'accentInk']) if (c[k]) p(`  --${k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())}: ${c[k]};`);
    (c.fields || []).forEach((f, i) => p(`  --field-${i + 1}: ${f.bg};${f.ink ? `  --field-${i + 1}-ink: ${f.ink};` : ''}`));
    p('}'); p('```'); p(c.note); if (d.colour) p(d.colour); p();
  } else { p('## 4. Colour'); p('Kept from the brand. ' + (d.colour || '')); p(); }
  p('## 5. Imagery');
  // where to look for real pictures of the subject's world, with first queries built from the brief
  const qs = assetQueries(s.input);
  const find = qs.length
    ? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)`
    : `\`${ASSETS_CMD} "<a place, material or object from your inventory>"\``;
  const MODE = {
    interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected) by the app-screen rules (references/app-screens.md: deal its style once with `node <skill>/scripts/draw.mjs --app --kind <kind> --keep palette,type --out .taste-skill/app`, and build it with assets/app.css and assets/charts.js), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
    'no-product-photo': '**There are no photographs of the product and none can be faked.** Wherever this sheet calls for a picture of the object, draw the object: its pieces as flat shapes in inline SVG, large, in one graphic language and in the colours that make it worth buying (a glaze, a finish, a material), never grey placeholders (references/imagery.md §8). **Around the object, use real pictures of its world** from the open collections: its material up close, its town in old postcards, its craft in engravings, a museum piece of the same kind, never one a visitor could take for the product itself (references/imagery.md §3). Start with ' + find + '. Download what you use with `--get` and credit every one on the page (`--credits` prints the footer line). Type at scale and full colour fields carry the rest; the dealt photographs only where they plainly do not pretend to be the product. In your report, say that product photography is needed and which drawings it will replace, and list every found picture with its source and licence.',
    atmosphere: '**The project has no photographs of its own yet.** First choice: **real pictures of the subject\'s world from the open collections**: its city and street in old postcards and photographs, an old map of the neighbourhood, its materials up close, its trade in engravings, museum objects of its kind (references/imagery.md §3). Start with ' + find + '. Download what you use into the project with `--get` and credit every one on the page (`--credits` prints the footer line). They show the world around the client, never the client\'s own room, dish, product, person or team. Second, for what no honest picture exists of: draw the subject\'s own things in code, large, in one flat graphic language and the palette\'s real colours (its tiles, its windows, its objects; references/imagery.md §8). Third: the dealt photographs, only where an honestly generic picture fits (the sea, the weather, a material), never one a visitor could take for the thing itself and never an unrelated scene. One treatment for all of them. If this direction is built on photographs, plan the set before building (four to six pictures of one family) and reuse them at different crops and scales instead of finding or drawing a new picture for every slot; keep drawings as `<symbol>` + `<use>` or generate them with a small script so the page stays light. List every found picture (with its source and licence), every dealt photograph and every drawing that stands in for photography in your report.',
    own: '**Use the images that belong to the project in every picture slot.** For slots they cannot fill, real pictures of the subject\'s world from the open collections come next (start with ' + find + '; credit them on the page), then the dealt photographs.',
  };
  p(MODE[s.imagery || 'atmosphere']); p(); p(d.imagery);
  p(); p('**Floor (the inspector checks it):** at least four real pictures on the page, usually six to twelve; SVG drawings do not count. One moving picture where an honest clip exists: `node <skill>/scripts/assets.mjs --video "<query>"`, used as a muted loop with a poster frame. Whatever this direction says about pictures, it is carried out with real ones.');
  if (s.photos) {
    p();
    p(s.imagery === 'interface' ? `Optional atmosphere, two at most, from the "${s.photos.title || s.photos.set}" set (${s.photos.note}). The page does not need them.` : `Photographs dealt for this build, from the "${s.photos.title || s.photos.set}" set (${s.photos.note})`);
    p('Order of preference for imagery: (1) the project\'s own photographs and product shots; (2) images you generate, if an image tool is');
    p('available; (3) real pictures of the subject\'s world found in the open collections (assets.mjs, downloaded and credited on the page);');
    p('(4) drawings of the subject in code; (5) these. They are real photographs (Unsplash licence, served by picsum.photos) of places, light and');
    p('texture. They are atmosphere, never a stand-in for a specific product, dish, person or interface. List the ones you use in your report so');
    p('they can be replaced.');
    if (s.photos.treatment) p(`Treatment: ${s.photos.treatment}`);
    for (const ph of s.photos.photos) p(`- \`https://picsum.photos/id/${ph.id}/1600/1000\` — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.lookalike ? ' · **could be taken for the product: never use it as the product**' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · native ${ph.ratio >= 1.15 ? 'landscape' : ph.ratio <= 0.87 ? 'portrait' : 'square'} · photo: ${ph.by}`);
    p('The last two path numbers are width and height: request the exact crop you render (`/1200/1500` for a 4:5 portrait, `/2400/1350` for a hero) and');
    p('add `?grayscale` to desaturate. ' + (s.imagery === 'interface' ? 'Use at most two.' : 'Use three to six of them, not all eight.') + ' Always set `width`, `height`, `alt` and `loading="lazy"` (not on the hero image).');
    if (s.photos.others?.length) p(`Other sets that fit this brief: ${s.photos.others.join(', ')} — \`node draw.mjs --photos <set>\` prints one in full; \`--photos list\` shows all sets. Taking photographs from another set is your call and is not a redraw; keep them to one light and one treatment.`);
  } else if (d.photos === false) {
    p(); p('This direction does not lead with photographs; it still carries the picture floor below, with real pictures placed where the direction allows them.');
  }
  p();
  const adjust = (bp) => { const n = adjustments(bp, s); if (n.length) { p(); p('Adjusted for this page:'); for (const x of n) p(`- ${x}`); } };
  p(`## 6. Hero — ${s.hero.name}`); p(s.hero.blueprint); p(); p(`On a phone: ${s.hero.mobile}`); if (s.hero.headline) p(`Headline: ${s.hero.headline}`); adjust(s.hero); p();
  p('## 7. Sections, in order');
  p('Each is a composition to rebuild with this project\'s content. Keep the geometry; change the subject. Re-order or drop a');
  p('section when the argument of the page needs it (see references/copy.md), never to fall back to a card grid.');
  s.sections.forEach((sec, i) => {
    p();
    if (!sec.blueprint) { p(`### ${i + 1}. ${sec.role}`); p('No blueprint in the library for this role. Compose it from the direction above, using a layout shape not used by its neighbours.'); return; }
    p(`### ${i + 1}. ${sec.role} — ${sec.name} (${sec.shape})`); p(sec.blueprint); if (sec.mobile) p(`On a phone: ${sec.mobile}`); adjust(sec);
  });
  p();
  if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); }
  if (s.scenes?.length) {
    p('## 9. Scroll scenes');
    p(`The page's big moments: ${s.scenes.length} scroll scenes, tested and ready to fill. They are part of the composition, so build them in step 6 with the section they carry, not as a motion afterthought. Each runs on the shared stage (assets/scenes/stage.js and stage.css, with GSAP and Lenis from the pinned CDN lines in stage.js) and ships its own finished composition for reduced motion and for phones that rest. How to put one on a page, fill it and check it: references/scenes.md. Check them with \`node <skill>/scripts/scenes.mjs <page>\` and look at every sheet.`);
    for (const sc of s.scenes) {
      p();
      p(`### ${sc.name} (\`${sc.id}\`)${sc.carries ? ` — carries the ${sc.carries === 'hero' ? 'first screen' : sc.carries + ' section'}` : ' — a transition between two sections'}`);
      p(sc.summary);
      if (sc.place) p(`Where: ${sc.place}`);
      if (sc.content) p(`Content: ${sc.content}`);
      if (sc.options) p(`Options (data-options): ${sc.options}`);
      p(`Files: \`<skill>/assets/scenes/${sc.id}.js\` and \`${sc.id}.css\`; a working example: \`<skill>/assets/scenes/demo/${sc.id}.html\`.${sc.phone ? ' On a phone: ' + sc.phone : ''}`);
    }
    p();
    p('Leave a quiet section between two scenes so each one lands. A scene whose content you cannot fill honestly is redrawn (`--redraw scenes`), never faked.');
    p();
  }
  if (d.richness) { p('## 10. Where the richness comes from'); p(d.richness); p(); }
  if (d.pitfalls) { p('## 11. How this direction goes wrong'); p(d.pitfalls); p(); }
  if (s.references?.length) {
    p('## 12. Real references');
    p('Studied sites that work this way. Take the principle, never the specifics: no names, copy, images or signature assets of theirs.');
    for (const r of s.references) p(siteCard(r, false));
    p('Full notes on any of them: `node draw.mjs --site <id>`.'); p();
  }
  p('## Always');
  p('Headlines two lines or fewer at every width. No eyebrow labels, no monospace, no 01/02/03, no thin rules, no gradients, no shadows at rest,');
  p('no cream-and-terracotta, no violet. The frame: one h1 on the first screen that says what this is, equal outer margins, no');
  p('section huddled on one side with the other half empty, no blank stretch over half a screen, a heading on every section.');
  p('`inspect.mjs` measures all of it.');
  if (s.allow?.length) p(`This direction is allowed: ${s.allow.join(', ')} (the inspector reads this from the saved sheet).`);
  p();
  p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
  return o.join('\n');
}
scripts/inspect.mjs›
#!/usr/bin/env node
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
//
//   node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]
//
// --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel
// input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction
// that dealt scroll scenes switches it on by itself.
//
// --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a
// direction dealt with draw.mjs --app switches it on by itself.
//
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';

const args = process.argv.slice(2);
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
const has = (name) => { const i = args.indexOf('--' + name); if (i === -1) return false; args.splice(i, 1); return true; };
const outDir = resolve(flag('out', '.taste-skill/inspect'));
const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean);
let allow = flag('allow', '').split(',').filter(Boolean);
const asJson = has('json');
let appMode = has('app');
let film = has('film');
let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default
const target = args[0];
if (!target || target === '-h' || target.startsWith('--')) {
  console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]');
  process.exit(2);
}
// Allowances granted by the drawn direction (written by draw.mjs) apply automatically.
for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) {
  if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; }
}
// A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to
// open a file path (some sandboxes show Node and the browser different views of the disk) and relative
// assets, fetch() and module scripts behave as they will when the page is hosted.
let url = target; let server = null;
if (!/^https?:\/\//.test(target)) {
  const file = resolve(target);
  if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
  const cwd = resolve('.');
  const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
  const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
  server = createServer((req, res) => {
    try {
      let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
      if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
      if (statSync(f).isDirectory()) f = join(f, 'index.html');
      res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'content-length': statSync(f).size, 'cache-control': 'no-store' });
      createReadStream(f).pipe(res);
    } catch { res.writeHead(404); res.end('not found'); }
  });
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
  url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');

mkdirSync(outDir, { recursive: true });
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };

const MOTION_MIN = 8, MOTION_GOOD = 16; // distinct moving elements over load + one scroll through the page
// Runs in the page before any of its own scripts: counts the distinct elements that actually move while
// the page loads and is scrolled (CSS animations, CSS transitions, Web Animations, and script-driven
// transform/opacity changes such as GSAP), so a page that never moves can be told from one that does.
const MOTION_RECORDER = `(() => {
  const seen = new WeakSet(); const c = { elements: 0, css: 0, transitions: 0, scripted: 0 };
  const note = (t, k) => { if (!(t instanceof Element) || seen.has(t)) return; seen.add(t); c.elements++; c[k]++; };
  addEventListener('animationstart', (e) => note(e.target, 'css'), true);
  addEventListener('transitionrun', (e) => note(e.target, 'transitions'), true);
  const watch = () => new MutationObserver((list) => { for (const m of list) { const s = m.target.getAttribute && m.target.getAttribute('style') || ''; if (/transform|translate|opacity|scale|rotate|clip-path/.test(s)) note(m.target, 'scripted'); } })
    .observe(document.documentElement, { attributes: true, attributeFilter: ['style'], subtree: true });
  if (document.documentElement) watch(); else addEventListener('DOMContentLoaded', watch);
  window.__motionSample = () => { for (const a of document.getAnimations ? document.getAnimations() : []) { const t = a.effect && a.effect.target; if (t) note(t, a.constructor && a.constructor.name === 'CSSTransition' ? 'transitions' : 'css'); } };
  window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; };
})();`;

// Is the page's title on screen and readable: in the viewport, not faded, not masked away?
const TITLE_SEEN = `(() => {
  const h = document.querySelector('h1'); if (!h) return { found: false };
  const r = h.getBoundingClientRect();
  if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' };
  if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business
  let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; }
  if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' };
  // text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre
  const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect();
  const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2));
  const hit = document.elementFromPoint(x, y);
  if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; }
  return { found: true, visible: true };
})()`;

async function sheet(frames, cols, cellW, cellH, path) {
  const rows = Math.ceil(frames.length / cols);
  await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 });
  await helper.eval((a) => {
    document.body.style.cssText = `margin:0;background:#888;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px)`;
    document.body.innerHTML = a.frames.map((s) => `<img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #888">`).join('');
    return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
  }, { frames, cols, cellW, cellH });
  await helper.screenshot({ path, quality: 76 });
}

try {
  for (const w of widths) {
    const h = HEIGHT[w] || Math.round(w * 0.625);
    const label = name(w);
    const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
    await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
    const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
    await sleep(600); // about 1.2s after the load event: is the title there to read yet?
    const early = await page.eval(TITLE_SEEN).catch(() => null);
    await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
    await sleep(800); // let the load choreography finish before judging the first screen
    const fold = join(outDir, `${label}-fold.jpg`);
    await page.screenshot({ path: fold, quality: 82 });
    const metrics = await page.eval(probe);

    // walk the page one screen at a time so scroll reveals fire, photographing each screen
    const frames = [readFileSync(fold).toString('base64')];
    const frameChecks = [await page.eval(frame).catch(() => null)];
    const screens = Math.min(11, Math.max(0, Math.ceil(metrics.page.height / h) - 1));
    for (let i = 1; i <= screens; i++) {
      await page.eval(`window.scrollTo({ top: ${i * h}, behavior: 'instant' })`).catch(() => {});
      await page.wheel(2);
      await sleep(650);
      frames.push(await page.screenshot({ quality: 78 }));
      frameChecks.push(await page.eval(frame).catch(() => null));
      await page.eval('window.__motionSample && window.__motionSample()').catch(() => {});
    }
    const motion = await page.eval('window.__motion ? window.__motion() : null').catch(() => null);
    // re-measure after everything has revealed (catches content that only exists post-reveal)
    await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
    await sleep(350);
    const after = await page.eval(probe).catch(() => metrics);
    const m = after.headings.length >= metrics.headings.length ? after : metrics;
    const lay = await page.eval(layout).catch(() => null);
    const appM = appMode ? await page.eval(app).catch(() => null) : null;
    if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null);

    // scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room
    const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : [];
    const sceneFindings = [];
    if (sceneState.length) {
      const idle = sceneState.filter((s) => !s.live && !s.rest);
      if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of <body> (references/scenes.md, "Putting a scene on a page").' });
      const atRest = sceneState.filter((s) => s.rest);
      if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' });
      if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' });
      const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0);
      if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' });
    }

    // --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a
    // scroll scene shows its in-between states, not only where each screen lands.
    const filmSheets = [];
    if (film && w >= 1100) {
      await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
      await sleep(700);
      const shots = []; let lastY = -1, still = 0;
      for (let i = 0; i < 96; i++) {
        shots.push(await page.screenshot({ quality: 70 }));
        await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 });
        await sleep(420);
        const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1);
        if (y === lastY) { if (++still >= 2) break; } else still = 0;
        lastY = y;
      }
      for (let i = 0; i < shots.length; i += 24) {
        const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
        await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
        filmSheets.push(p);
      }
    }

    const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4;
    const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390;
    const cellH = Math.round((cellW * h) / w);
    const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
    const sheets = [];
    for (let i = 0; i < frames.length; i += perSheet) {
      const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
      await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
      sheets.push(p);
    }
    let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
    if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
      const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
      const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
      const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f))));
      if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' });
    }
    // a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s
    if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' });
    if (lay) findings.push(...judgeLayout(lay, { width: w, allow }));
    if (appMode) {
      findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title');
      if (appM) findings.push(...judgeApp(appM, m, { width: w, allow }));
    }
    // what only shows up while scrolling: text hidden under something, and screens with almost nothing on them
    const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
    if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' });
    const clippedText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.clipped || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
    if (clippedText.length && !allow.includes('clipped-text')) findings.push({ level: 'FAIL', id: 'clipped-text', width: w, message: 'Large text is cut off along its top by the box that holds it: ' + clippedText.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen})`).join('; '), fix: 'Jurors read a wordmark sliced at the top as broken. Give the box room for the ascenders (line-height and padding), or crop at the bottom or a side edge on purpose.' });
    const overPictures = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.pinnedOver || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
    if (overPictures.length && !allow.includes('pinned-over-picture')) findings.push({ level: 'WARN', id: 'pinned-over-picture', width: w, message: 'Pinned large text slides across pictures while the page scrolls: ' + overPictures.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% over a picture)`).join('; '), fix: 'Jurors read a pinned title crossing drawings or photographs as a collision. Keep the pictures in the column the title does not occupy, or unpin it.' });
    const empty = frameChecks.map((f, i) => ({ f, i })).filter(({ f }) => f && f.filled < 0.2);
    // the same large statement on screen for three screens or more in a row
    const runs = new Map();
    frameChecks.forEach((f, i) => { for (const t of f?.big || []) { const r = runs.get(t) || { start: i, last: i - 1, best: 0 }; if (r.last === i - 1) r.last = i; else { r.start = i; r.last = i; } r.best = Math.max(r.best, r.last - r.start + 1); runs.set(t, r); } });
    const longPins = [...runs.entries()].filter(([, r]) => r.best >= 3);
    if (w >= 1100 && longPins.length && !allow.includes('long-pin')) findings.push({ level: 'WARN', id: 'long-pin', width: w, message: `The same large text stays on screen for ${Math.max(...longPins.map(([, r]) => r.best))} screens in a row: ` + longPins.slice(0, 2).map(([t]) => `"${t}"`).join('; '), fix: 'A pinned element earns two screens, and something must change at each. Shorten the run or give every screen its own content.' });
    // a page that barely moves: counted from what really animated while it loaded and was scrolled
    if (w >= 1100 && motion && !allow.includes('still-page')) {
      const moving = motion.elements + motion.playingVideos * 4;
      if (moving < MOTION_MIN) findings.push({ level: 'FAIL', id: 'still-page', width: w, message: `Only ${motion.elements} element(s) moved while the page loaded and scrolled${motion.playingVideos ? ` (plus ${motion.playingVideos} playing video)` : ''}.`, fix: 'Build the motion layer (SKILL.md step 9, references/motion.md): the signature move, an entrance for the parts of every section, feedback on every control, and one moving picture. Transform and opacity only, with a reduced-motion fallback.' });
      else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
    }
    if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
    report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) });
    report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings));
    report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets });
    await page.close();
  }
  // Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an
  // animation that will not run. Two in three studied award pages had no reduced-motion rules at all.
  if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) {
    const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
    try {
      await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 });
      const hidden = await rp.eval(`(() => {
        const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
        for (let n = w.nextNode(); n; n = w.nextNode()) {
          const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue;
          rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue;
          let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; }
          if (gone || o < 0.1) out.push(t.slice(0, 40));
        }
        return out;
      })()`).catch(() => []);
      if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' });
    } finally { await rp.close(); }
  }
} finally {
  await browser.close();
  if (server) server.close();
}

// de-duplicate findings that repeat across viewports (keep the widest occurrence, note the others)
const merged = [];
for (const f of report.findings) {
  const same = merged.find((x) => x.id === f.id && x.level === f.level);
  if (same && f.id !== 'headline-lines' && f.id !== 'overflow') { same.alsoAt = [...(same.alsoAt || []), f.width]; continue; }
  merged.push(f);
}
report.findings = merged;
writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 1));

if (asJson) { console.log(JSON.stringify(report, null, 1)); process.exit(merged.some((f) => f.level === 'FAIL') ? 1 : 0); }

const d = report.viewports[0].metrics;
const line = (s = '') => console.log(s);
line(`TASTESKILL · INSPECT   ${report.url}`);
line('');
line('MEASURED (first viewport listed)');
if (d.hero) line(`  hero headline   "${d.hero.text.slice(0, 70)}"  ${d.hero.words} words · ${d.hero.lines} line(s) · ${d.hero.size}px (${d.hero.vw}vw) · ${d.hero.font} ${d.hero.weight} · tracking ${d.hero.tracking}em · leading ${d.hero.leading}`);
for (const v of report.viewports.slice(1)) if (v.metrics.hero) line(`  @${v.width}px          ${v.metrics.hero.lines} line(s) · ${v.metrics.hero.size}px`);
line(`  typefaces       ${d.type.families.filter((f) => f.share >= 0.02).map((f) => `${f.family} ${Math.round(f.share * 100)}%`).join(', ')}`);
line(`  sizes in use    ${d.type.sizes.join(' / ')} px${d.type.body ? `   body ${d.type.body.size}px / ${d.type.body.leading}` : ''}`);
line(`  colour          page ${d.color.bodyBg}${d.color.sectionBgs.length ? ' · sections ' + d.color.sectionBgs.map((s) => s.color).slice(0, 6).join(' ') : ''}${d.color.button ? ' · button ' + d.color.button.bg : ''}`);
line(`  page            ${d.page.screens} screens · ${d.page.words} words · ${d.page.img} images · hero media ${d.heroMedia[0] ? Math.round(d.heroMedia[0].ratio * 100) + '% ' + d.heroMedia[0].type : 'none'}`);
line(`  headings        ${d.headingSummary.count} · longest ${d.headingSummary.maxLines} line(s) · median ${d.headingSummary.medianWords ?? '-'} words`);
line('');
const fails = merged.filter((f) => f.level === 'FAIL'), warns = merged.filter((f) => f.level === 'WARN');
if (!fails.length && !warns.length) line('FINDINGS  none. Measurements are clean — now judge the pictures.');
for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN — fix, or be able to defend', warns]]) {
  if (!list.length) continue;
  line(title);
  for (const f of list) {
    line(`  [${f.id}] @${f.width}px${f.alsoAt ? ' (+' + f.alsoAt.join(', ') + ')' : ''}  ${f.message}`);
    line(`      → ${f.fix}`);
  }
  line('');
}
line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:');
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
for (const im of report.images) { line(`  ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(`  ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(`  ${'scroll film'.padEnd(15)} ${rel(p)}  (6 frames a row, a third of a screen apart)`); }
line('');
line(`report: ${rel(join(outDir, 'report.json'))}`);
line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`);
process.exit(fails.length ? 1 : 0);
scripts/lib/app-draw.mjs›
// tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox).
//
//   node draw.mjs --app --kind <kind> --brief "one line" [--screens overview,list,detail,settings]
//                 [--theme light|dark] [--keep palette,type] [--options 3] [--style <id>] [--seed N]
//   node draw.mjs --show                      reprint the saved sheet
//   node draw.mjs --redraw style|shell|screen:<name>
//   node draw.mjs --app --list kinds|styles|screens
//
// Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not
// end up in the same default look. The library (library/app.json) was distilled from a study of real
// product screens; it holds styles (tokens and the one move that makes each recognisable), shells and
// screen patterns.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { homedir } from 'node:os';

const HERE = dirname(fileURLToPath(import.meta.url));
const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8'));
const argv = process.argv.slice(2);
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
const outDir = resolve(String(opt('out', '.taste-skill')));
const sheetPath = join(outDir, 'direction.json');

const KINDS = Object.keys(LIB.kinds);
// signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens
const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))];
const SIGNATURES = LIB.patterns.filter((p) => p.signature);
const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); };

if (opt('list')) {
  const what = opt('list');
  if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`);
  else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`);
  else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`);
  else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`);
  process.exit(0);
}

let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
const startSeed = seed;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } };
const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style);

const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); }

function stylePool(input) {
  const pref = LIB.kinds[input.kind]?.styles || [];
  let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id));
  if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; }
  const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind));
  if (fit.length >= 2) pool = fit;
  return pool;
}
function dealStyle(input, exclude = []) {
  let pool = stylePool(input).filter((s) => !exclude.includes(s.id));
  if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id));
  const fresh = pool.filter((s) => !recentStyles.includes(s.id));
  return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles);
}
// The style's own shell usually carries part of its move, so it is dealt about two times in three.
function dealShell(style, input, exclude = []) {
  const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id));
  const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id));
  if (own && (!alts.length || rnd() < 0.66)) return own;
  if (alts.length) return pick(alts);
  const rest = LIB.shells.filter((s) => !exclude.includes(s.id));
  return pick(rest.length ? rest : LIB.shells);
}
function dealScreen(name, style, input, exclude = []) {
  let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id));
  // a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a
  // leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead
  pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind));
  return pool.length ? pick(pool) : null;
}
function deal(input) {
  const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input);
  if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); }
  const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] };
  for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) });
  return s;
}

let sheet;
if (opt('redraw') && saved?.kind === 'app') {
  sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1;
  const what = String(opt('redraw'));
  if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); }
  else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]);
  else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; }
  else { console.error('Redraw style, shell or screen:<name>.'); process.exit(2); }
} else {
  const kind = String(opt('kind', ''));
  if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n  ${KINDS.join(', ')}`); process.exit(2); }
  const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']);
  const bad = screens.filter((x) => !SCREENS.includes(x));
  if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); }
  const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null };
  if (opt('options')) {
    const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = [];
    let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool;
    while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); }
    console.log(`# ${out.length} app styles dealt for this brief\n`);
    console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style <id>` (and without --options).\n');
    for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`);
    process.exit(0);
  }
  sheet = deal(input);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id });
console.log(render(sheet));
process.exit(0);

function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
// A Google Fonts link for the style's faces (all of them are free there).
function fontLink(f) {
  const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';');
  const fams = [fam(f.family, f.weights || [400, 500, 600])];
  const second = f.second && f.second.split(',')[0].trim();
  if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500]));
  return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n<link href="https://fonts.googleapis.com/css2?${fams.join('&')}&display=swap" rel="stylesheet">`;
}
function render(s) {
  const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x);
  p(`# APP SHEET — ${st.name}`);
  p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`);
  if (s.input.brief) p(`Brief: ${s.input.brief}`);
  p();
  p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the');
  p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is');
  p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them');
  p('as given rather than rounding them towards your habits.');
  p();
  p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p();
  if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`);
  if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.');
  if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`);
  p();
  // the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3)
  if (st.identity?.length) {
    p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to');
    p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.');
    for (const x of st.identity) p('- ' + x);
    p();
  }
  const sigs = signaturesFor(s.input.brief);
  if (sigs.length) {
    p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):');
    for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`);
    p();
  }
  const f = t.font || {};
  if (!s.input.keep.includes('palette')) {
    p('## 2. Tokens'); p('```css'); p(':root {');
    for (const [k, v] of Object.entries(t)) {
      if (k === 'font' || k === 'density') continue;
      if (typeof v === 'string') p(`  --${kebab(k)}: ${v};`);
      else if (typeof v === 'number') p(`  --${kebab(k)}: ${v}px;`);
      else if (k === 'chart') v.forEach((c, i) => p(`  --chart-${i + 1}: ${c};`));
      else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(`  --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`);
      else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(`  --radius-${n}: ${r}px;`);
    }
    if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(`  --fs-${n}: ${v}px;`);
    p('}'); p('```');
    p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`);
    p();
  } else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); }
  if (!s.input.keep.includes('type') && f.family) {
    p(`## 3. Type — ${f.family}`);
    p('```html'); p(fontLink(f)); p('```');
    p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`);
    p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p();
  }
  p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint);
  if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.');
  if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p();
  p('## 5. Screens');
  for (const sc of s.screens) {
    p();
    if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; }
    p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`);
  }
  p();
  p('## 6. Always');
  p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,');
  p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,');
  p('rainbow charts, emoji, lorem or Acme. `<skill>/assets/app.css` and `<skill>/assets/charts.js` are a neutral start set to these');
  p('rules. Check with `node <skill>/scripts/lint.mjs <files>` and `node <skill>/scripts/inspect.mjs <page>`: this sheet switches both');
  p('to app rules.');
  p();
  p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
  return o.join('\n');
}
scripts/lib/cdp.mjs›
// Zero-dependency Chrome DevTools Protocol client.
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
// Requires Node 22+. No npm install.
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync, readdirSync, statSync, openSync, closeSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export function findChrome() {
  const env = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
  if (env && existsSync(env)) return env;
  const home = process.env.HOME || process.env.USERPROFILE || '';
  const local = process.env.LOCALAPPDATA || '';
  const pf = process.env.PROGRAMFILES || 'C:\\Program Files';
  const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
  const candidates = [
    // Windows
    join(pf, 'Google/Chrome/Application/chrome.exe'),
    join(pf86, 'Google/Chrome/Application/chrome.exe'),
    join(local, 'Google/Chrome/Application/chrome.exe'),
    join(pf86, 'Microsoft/Edge/Application/msedge.exe'),
    join(pf, 'Microsoft/Edge/Application/msedge.exe'),
    join(pf, 'BraveSoftware/Brave-Browser/Application/brave.exe'),
    // macOS
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
    '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
    '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
    join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
    // Linux
    '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
    '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/microsoft-edge',
  ];
  for (const c of candidates) if (c && existsSync(c)) return c;
  return null;
}

// Profiles from runs that were killed before close(): removed once no Chrome holds them (a running
// Chrome keeps the profile's lockfile open) and they are a few minutes old.
function sweepProfiles(base) {
  try {
    const cutoff = Date.now() - 3 * 60 * 1000;
    for (const name of readdirSync(base)) {
      if (!name.startsWith('ad-chrome-')) continue;
      const p = join(base, name);
      try {
        if (statSync(p).mtimeMs > cutoff) continue;
        const lock = join(p, 'lockfile');
        if (existsSync(lock)) closeSync(openSync(lock, 'r+')); // throws while a Chrome holds it
        rmSync(p, { recursive: true, force: true });
      } catch {}
    }
  } catch {}
}

export class Browser {
  static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) {
    if (typeof WebSocket === 'undefined') {
      throw new Error('Node 22+ is required (built-in WebSocket missing). Current: ' + process.version);
    }
    const exe = findChrome();
    if (!exe) {
      throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
    }
    // A throwaway profile per run. Its cache is capped (each run starts cold anyway), profiles left by
    // runs that were killed are swept once they are a few hours old, and the profile goes on exit.
    const base = process.env.TASTE_SKILL_TMP || tmpdir();
    sweepProfiles(base);
    const dir = mkdtempSync(join(base, 'ad-chrome-'));
    // On exit Chrome still holds the profile's files: stop it first, then remove the folder.
    let pid = 0;
    const drop = () => {
      try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {}
      try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
    };
    process.once('exit', drop);
    for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); });
    const args = [
      '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
      '--disk-cache-size=16777216', '--media-cache-size=16777216',
      '--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars',
      '--disable-background-timer-throttling', '--disable-renderer-backgrounding',
      '--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter',
      '--force-color-profile=srgb', '--autoplay-policy=no-user-gesture-required',
      '--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank',
    ];
    const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
    pid = proc.pid;
    const wsUrl = await new Promise((resolve, reject) => {
      let buf = '';
      const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
      proc.stderr.on('data', (d) => {
        buf += d.toString();
        const m = buf.match(/DevTools listening on (ws:\/\/[^\s]+)/);
        if (m) { clearTimeout(t); resolve(m[1]); }
      });
      proc.on('exit', (code) => { clearTimeout(t); reject(new Error('Chrome exited early, code ' + code + '\n' + buf.slice(-600))); });
    });
    const b = new Browser();
    b.proc = proc; b.dir = dir; b.nextId = 1; b.pending = new Map(); b.listeners = new Map();
    b.ws = new WebSocket(wsUrl);
    await new Promise((res, rej) => { b.ws.onopen = res; b.ws.onerror = () => rej(new Error('CDP socket failed')); });
    b.ws.onmessage = (ev) => {
      const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString());
      if (msg.id && b.pending.has(msg.id)) {
        const { resolve, reject, timer } = b.pending.get(msg.id);
        clearTimeout(timer); b.pending.delete(msg.id);
        if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
      } else if (msg.method) {
        const fns = b.listeners.get((msg.sessionId || '') + '|' + msg.method);
        if (fns) for (const fn of [...fns]) fn(msg.params);
      }
    };
    return b;
  }

  send(method, params = {}, sessionId, timeoutMs = 30000) {
    const id = this.nextId++;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, timeoutMs);
      this.pending.set(id, { resolve, reject, timer });
      this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
    });
  }

  on(sessionId, method, fn) {
    const k = (sessionId || '') + '|' + method;
    if (!this.listeners.has(k)) this.listeners.set(k, new Set());
    this.listeners.get(k).add(fn);
    return () => this.listeners.get(k)?.delete(fn);
  }

  async newPage({ width = 1440, height = 900, dsf = 1, mobile = false, reducedMotion = false } = {}) {
    const { targetId } = await this.send('Target.createTarget', { url: 'about:blank', newWindow: true });
    const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
    const p = new Page(this, targetId, sessionId);
    await p.send('Page.enable');
    await p.send('Runtime.enable');
    await p.setViewport({ width, height, dsf, mobile });
    if (reducedMotion) {
      await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
    }
    return p;
  }

  async close() {
    try { await this.send('Browser.close', {}, undefined, 3000); } catch {}
    try { this.ws.close(); } catch {}
    try { this.proc.kill(); } catch {}
    await sleep(300);
    try { rmSync(this.dir, { recursive: true, force: true, maxRetries: 3 }); } catch {}
  }
}

export class Page {
  constructor(browser, targetId, sessionId) {
    this.browser = browser; this.targetId = targetId; this.sessionId = sessionId;
    this.consoleErrors = [];
    browser.on(sessionId, 'Runtime.exceptionThrown', (p) => {
      this.consoleErrors.push((p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || '').slice(0, 300));
    });
    // Never let a JS dialog block the page.
    browser.on(sessionId, 'Page.javascriptDialogOpening', () => {
      this.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {});
    });
  }

  send(method, params = {}, timeoutMs = 30000) {
    return this.browser.send(method, params, this.sessionId, timeoutMs);
  }

  async setViewport({ width, height, dsf = 1, mobile = false }) {
    this.viewport = { width, height, dsf, mobile };
    await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dsf, mobile });
    await this.send('Emulation.setTouchEmulationEnabled', { enabled: !!mobile });
  }

  // Navigates and resolves after the load event (or timeout), then waits `settleMs` for intros to finish.
  async goto(url, { timeoutMs = 25000, settleMs = 1500 } = {}) {
    let off;
    const loaded = new Promise((resolve) => { off = this.browser.on(this.sessionId, 'Page.loadEventFired', () => resolve('load')); });
    const nav = await this.send('Page.navigate', { url }, timeoutMs).catch((e) => ({ errorText: e.message }));
    if (nav.errorText && !/ERR_ABORTED/.test(nav.errorText)) { off(); throw new Error('navigate: ' + nav.errorText); }
    const how = await Promise.race([loaded, sleep(timeoutMs).then(() => 'timeout')]);
    off();
    await sleep(settleMs);
    return how;
  }

  // Evaluates a function (serialised) or an expression string in the page and returns its JSON value.
  async eval(fnOrExpr, arg) {
    const expression = typeof fnOrExpr === 'function'
      ? `(${fnOrExpr.toString()})(${arg === undefined ? '' : JSON.stringify(arg)})`
      : fnOrExpr;
    const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, 30000);
    if (r.exceptionDetails) {
      throw new Error('page eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
    }
    return r.result?.value;
  }

  async screenshot({ path, format = 'jpeg', quality = 72, fullPage = false, clip } = {}) {
    const params = { format, captureBeyondViewport: !!fullPage };
    if (format !== 'png') params.quality = quality;
    if (fullPage) {
      const m = await this.send('Page.getLayoutMetrics');
      const s = m.cssContentSize || m.contentSize;
      params.clip = { x: 0, y: 0, width: Math.ceil(s.width), height: Math.min(Math.ceil(s.height), 16000), scale: 1 };
    } else if (clip) {
      params.clip = { scale: 1, ...clip };
    }
    const { data } = await this.send('Page.captureScreenshot', params, 45000);
    if (path) writeFileSync(path, Buffer.from(data, 'base64'));
    return data;
  }

  // Real wheel input, so smooth-scroll libraries and scroll-driven reveals behave as they do for a visitor.
  async wheel(totalDeltaY, { step = 240, pauseMs = 45 } = {}) {
    const { width, height } = this.viewport;
    const dir = Math.sign(totalDeltaY) || 1;
    let left = Math.abs(totalDeltaY);
    while (left > 0) {
      const d = Math.min(step, left);
      await this.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: Math.round(width / 2), y: Math.round(height / 2), deltaX: 0, deltaY: d * dir }).catch(() => {});
      left -= d;
      await sleep(pauseMs);
    }
  }

  async close() {
    try { await this.browser.send('Target.closeTarget', { targetId: this.targetId }, undefined, 5000); } catch {}
  }
}

export { sleep };
scripts/lib/probe.mjs›
// probe() runs INSIDE the page (it is serialised and evaluated through cdp.mjs), so it must stay
// self-contained: no imports, no closures over module scope.
// It measures what a design reviewer looks at — headline line counts, the type system, colour,
// surfaces, decoration — and the specific tells this skill bans.
export function probe() {
  const W = innerWidth, H = innerHeight, SY = scrollY;
  const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;

  // --- colour normalisation (handles oklch(), color(), named colours, etc.)
  const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
  const ctx = cvs.getContext('2d', { willReadFrequently: true });
  const colorCache = new Map();
  const rgba = (c) => {
    if (colorCache.has(c)) return colorCache.get(c);
    ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1);
    const d = ctx.getImageData(0, 0, 1, 1).data;
    const v = { r: d[0], g: d[1], b: d[2], a: round(d[3] / 255) };
    colorCache.set(c, v); return v;
  };
  const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
  const lum = (v) => {
    const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
    return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b);
  };
  const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return round((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05), 1); };
  const hsl = (v) => {
    const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b);
    const l = (mx + mn) / 2; let h = 0, s = 0;
    if (mx !== mn) {
      const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
      h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60;
    }
    return { h: Math.round(h), s: round(s), l: round(l) };
  };

  // --- monospace detection by measurement, not by font name
  const mctx = document.createElement('canvas').getContext('2d');
  const monoCache = new Map();
  const isMono = (family) => {
    if (monoCache.has(family)) return monoCache.get(family);
    mctx.font = `40px ${family}`;
    const a = mctx.measureText('iiiiiiii').width, b = mctx.measureText('WWWWWWWW').width;
    const v = Math.abs(a - b) < 1;
    monoCache.set(family, v); return v;
  };
  const firstFam = (ff) => ff.split(',')[0].replace(/["']/g, '').trim();
  // Text kept for screen readers only (the usual .sr-only / .visually-hidden patterns) is not on the page.
  const srHidden = (el) => {
    for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) {
      const cs = getComputedStyle(p); const r = p.getBoundingClientRect();
      if (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath) || (r.width <= 2 && r.height <= 2 && cs.overflow !== 'visible')) return true;
    }
    return false;
  };
  const GENERIC = /^(serif|sans-serif|monospace|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|cursive|fantasy|inherit|-apple-system|BlinkMacSystemFont|Segoe UI|Arial|Helvetica|Helvetica Neue|Times New Roman|Times|Georgia|Verdana|Tahoma|Courier New|Courier|Roboto|Apple Color Emoji)$/i;

  // --- collect elements
  const all = [...document.body.querySelectorAll('*')].slice(0, 9000);
  const texts = [];
  const big = [];
  for (const el of all) {
    const tag = el.tagName;
    if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue;
    const r = el.getBoundingClientRect();
    if (r.width < 1 || r.height < 1) continue;
    const cs = getComputedStyle(el);
    if (cs.display === 'none' || cs.visibility === 'hidden') continue;
    const area = r.width * r.height;
    if (area >= 60 * 30) big.push({ el, r, cs, area, top: r.top + SY });
    let direct = '';
    for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
    direct = direct.replace(/\s+/g, ' ').trim();
    if (!direct) continue;
    if (tag === 'OPTION' || el.closest('svg') || srHidden(el)) continue;
    const size = parseFloat(cs.fontSize);
    if (!size || size < 6) continue;
    const ls = cs.letterSpacing === 'normal' ? 0 : parseFloat(cs.letterSpacing) / size;
    const lh = cs.lineHeight === 'normal' ? 1.2 : parseFloat(cs.lineHeight) / size;
    texts.push({
      el, tag, text: direct, chars: direct.length, fam: firstFam(cs.fontFamily), ff: cs.fontFamily,
      size: round(size, 1), weight: +cs.fontWeight || 400, ls: round(ls, 3), lh: round(lh, 2),
      upper: cs.textTransform === 'uppercase' || (direct.length > 2 && direct === direct.toUpperCase() && /[A-Z]{2}/.test(direct)),
      italic: cs.fontStyle === 'italic', align: cs.textAlign, color: cs.color,
      top: r.top + SY, bottom: r.bottom + SY, left: r.left, right: r.right, w: r.width, h: r.height,
      clipText: (cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text'),
      inCode: !!el.closest('pre, code, kbd, samp, textarea'), inUI: !!el.closest('[data-ui]'), textShadow: cs.textShadow !== 'none',
    });
  }

  // --- heading blocks: climb from a large text node to the block that holds the whole headline
  // An element that is a complete text unit by itself: separate links, list items or paragraphs set large
  // are separate lines of content, never the wrapped lines of one headline.
  const UNIT = 'h1, h2, h3, h4, h5, h6, [role="heading"], p, li, a, button, blockquote, dt, dd, figcaption, td, th, label, summary';
  const blockOf = (t) => {
    let cur = t.el;
    // usable as the block when it holds only this size of type (a title with its own small description is not one block)
    const clean = (el) => (el.innerText || '').trim().length <= 320 && !texts.some((o) => o.el !== t.el && el.contains(o.el) && o.size < t.size * 0.72);
    let unit = t.el.closest(UNIT);
    if (unit && unit.matches('a, button')) { const outer = unit.parentElement?.closest('h1, h2, h3, h4, h5, h6, p, blockquote, li'); if (outer && clean(outer)) unit = outer; }
    if (unit && clean(unit)) return unit;
    while (cur.parentElement && cur.parentElement !== document.body) {
      if (cur.matches(UNIT)) break;
      const p = cur.parentElement;
      if (p.matches('ul, ol, dl, nav, menu, table, tbody')) break;
      const inner = (p.innerText || '').trim();
      if (inner.length > 220) break;
      let ok = true;
      for (const o of texts) if (o.el !== t.el && p.contains(o.el) && o.size < t.size * 0.72) { ok = false; break; }
      if (!ok) break;
      cur = p;
    }
    return cur;
  };
  // Count lines from the text itself: inline images or media set inside a sentence are not lines.
  const linesOf = (el, size) => {
    const tops = [];
    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
    const range = document.createRange();
    for (let node = walker.nextNode(); node; node = walker.nextNode()) {
      if (!node.nodeValue.trim()) continue;
      range.selectNodeContents(node);
      for (const rc of range.getClientRects()) {
        if (rc.width < 1 || rc.height < size * 0.35) continue;
        const mid = rc.top + rc.height / 2;
        if (!tops.some((t) => Math.abs(t - mid) < size * 0.55)) tops.push(mid);
      }
    }
    return Math.max(1, tops.length);
  };
  const describeBlock = (el, t) => {
    const r = el.getBoundingClientRect();
    const text = (el.innerText || t.text).replace(/\s+/g, ' ').trim();
    const cx = (r.left + r.right) / 2 / W;
    const fams = new Set(texts.filter((o) => el.contains(o.el)).map((o) => o.fam + (o.italic ? ' italic' : '')));
    return {
      text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, chars: text.length,
      lines: linesOf(el, t.size), size: t.size, vw: round((t.size / W) * 100, 1), font: t.fam, weight: t.weight,
      tracking: t.ls, leading: t.lh, upper: t.upper, italic: t.italic, mono: isMono(t.ff),
      align: /center/.test(t.align) ? 'center' : /right|end/.test(t.align) ? 'right' : 'left',
      position: cx < 0.42 ? 'left' : cx > 0.58 ? 'right' : 'center',
      widthRatio: round(r.width / W), top: Math.round(r.top + SY), mixedFonts: fams.size > 1 ? [...fams] : undefined,
      gradientText: texts.some((o) => el.contains(o.el) && o.clipText) || undefined, tag: el.tagName.toLowerCase(),
      // a title (h1–h6, or unmarked large text) or a statement (a paragraph, quotation or list item set large)
      kind: el.closest('h1,h2,h3,h4,h5,h6,[role="heading"]') || el.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]') ? 'heading'
        : t.el.closest('p, blockquote, li, dd, q, figcaption') || text.length > 150 ? 'statement' : 'heading',
    };
  };
  const headingMin = W < 600 ? 22 : Math.max(26, W * 0.019);
  const seenBlocks = new Set();
  const headings = [];
  for (const t of texts.filter((x) => x.size >= headingMin && !x.inCode).sort((a, b) => b.size - a.size)) {
    const blk = blockOf(t);
    if (seenBlocks.has(blk) || [...seenBlocks].some((b) => b.contains(blk) || blk.contains(b))) continue;
    seenBlocks.add(blk);
    const d = describeBlock(blk, t);
    if (d.kind === 'statement' && d.chars > 150 && d.size <= 30) continue; // a lead paragraph, not display type
    headings.push({ ...d, _el: blk });
  }
  headings.sort((a, b) => a.top - b.top);

  // hero headline = largest heading block that starts inside the first viewport
  const inHero = headings.filter((h) => h.top < H * 0.95);
  let hero = inHero.slice().sort((a, b) => b.size - a.size || b.chars - a.chars)[0] || null;
  if (hero) hero.kind = 'heading'; // the main line of the first screen is a headline whatever its tag
  const h1el = document.querySelector('h1');
  if (!hero) {
    // quiet pages: fall back to the h1, then to the largest text in the first viewport
    const h1r = h1el && h1el.getBoundingClientRect();
    const pick = (h1r && h1r.top < H * 0.95 && h1r.width > 0 && texts.find((t) => h1el.contains(t.el))) ||
      texts.filter((t) => t.top - SY < H * 0.95 && t.size >= 18 && t.chars >= 8).sort((a, b) => b.size - a.size)[0];
    if (pick) { const blk = h1el && h1el.contains(pick.el) ? h1el : blockOf(pick); hero = { ...describeBlock(blk, pick), _el: blk }; }
  }
  let h1 = null;
  if (h1el && h1el.getBoundingClientRect().width > 0) {
    const cs = getComputedStyle(h1el); const size = parseFloat(cs.fontSize);
    const text = (h1el.innerText || '').replace(/\s+/g, ' ').trim();
    h1 = { text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, lines: linesOf(h1el, size), size: round(size, 1), font: firstFam(cs.fontFamily) };
  }

  // --- eyebrows: a small short label sitting directly above a heading block
  // Sticky and fixed layers overlap other content by design; their geometry says nothing about what labels what.
  const pinned = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; } return false; };
  const eyebrows = [];
  for (const h of headings) {
    const hr = h._el.getBoundingClientRect(); const htop = hr.top + SY;
    if (h._el.closest('[data-ui]')) continue;
    const cand = texts.filter((t) => !t.inUI && !h._el.contains(t.el) && t.size <= Math.min(18, h.size * 0.5) && t.chars <= 44 && t.chars >= 2 &&
      t.bottom <= htop + 4 && htop - t.bottom <= Math.min(140, Math.max(72, h.size * 1.1)) && t.right > hr.left - 8 && t.left < hr.right + 8 &&
      !t.el.closest('button') &&
      !t.el.closest('nav, [role="navigation"], footer') && !(t.top < 120 && t.el.closest('header')) && !pinned(t.el) && pinned(h._el) === false)
      .sort((a, b) => b.bottom - a.bottom)[0];
    if (!cand) continue;
    // must be alone on its row (a real paragraph above a heading is not an eyebrow)
    if (texts.some((t) => t !== cand && Math.abs(t.top - cand.top) < 6 && t.chars > 44)) continue;
    // a note that sits closer to the heading above it than to this one belongs to that heading
    const gapDown = htop - cand.bottom;
    if (headings.some((o) => o !== h && !o._el.contains(cand.el) && (() => { const or = o._el.getBoundingClientRect(); const gapUp = cand.top - (or.bottom + SY); return gapUp >= -2 && gapUp < gapDown && or.left < cand.right && or.right > cand.left; })())) continue;
    let pill = false; let p = cand.el;
    for (let i = 0; i < 3 && p; i++, p = p.parentElement) {
      const cs = getComputedStyle(p); const pr = p.getBoundingClientRect();
      if (pr.width < 420 && pr.height < 56 && parseFloat(cs.borderTopLeftRadius) >= 10 && (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0)) pill = true;
    }
    eyebrows.push({ text: cand.text.slice(0, 50), above: h.text.slice(0, 40), upper: cand.upper, tracked: cand.ls >= 0.04, mono: isMono(cand.ff), pill, size: cand.size, top: Math.round(cand.top) });
  }

  // --- decorative numbering: 01 / 02 / (03) / 04. / 001 / "Step 02"
  const numbered = texts.filter((t) => !t.inCode && !t.inUI && (/^[(\[\/#]?\s?0\d{1,2}\s?[)\]\/.:—–-]?$/.test(t.text) || /^0\d\s?[\/.—–-]\s/.test(t.text) || /^(step|phase|no\.?|n°|nº|chapter|part|fig\.?)\s?0\d{1,2}\b/i.test(t.text)));

  // --- type system
  const famChars = new Map(); const sizeChars = new Map(); let totalChars = 0; let monoChars = 0; let monoProse = 0; const monoFams = new Set(); const monoSamples = [];
  let microLabels = 0; let microLabelChars = 0; let upperChars = 0; let centerChars = 0; const microSamples = [];
  for (const t of texts) {
    totalChars += t.chars;
    famChars.set(t.fam, (famChars.get(t.fam) || 0) + t.chars);
    if (!t.inUI) { const s = Math.round(t.size); sizeChars.set(s, (sizeChars.get(s) || 0) + t.chars); } // interface fragments keep their own sizes
    if (isMono(t.ff)) {
      monoChars += t.chars; monoFams.add(t.fam);
      if (!t.inCode) { monoProse += t.chars; if (monoSamples.length < 6) monoSamples.push(t.text.slice(0, 40)); }
    }
    if (t.size <= 14 && t.upper && t.ls >= 0.05 && !t.inUI && !t.el.closest('nav, button, a[href]')) { microLabels++; microLabelChars += t.chars; if (microSamples.length < 6) microSamples.push(t.text.slice(0, 40)); }
    if (t.upper) upperChars += t.chars;
    if (/center/.test(t.align)) centerChars += t.chars;
  }
  const families = [...famChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([family, chars]) => ({ family, share: round(chars / totalChars), mono: isMono(`"${family}", serif`) && isMono(`"${family}", sans-serif`) }));
  const bodyTexts = texts.filter((t) => t.size >= 10 && t.size <= 24 && t.chars >= 60 && !t.inCode && !t.inUI);
  const bodyBy = new Map();
  for (const t of bodyTexts) { const k = t.fam + '|' + Math.round(t.size); bodyBy.set(k, (bodyBy.get(k) || 0) + t.chars); }
  const bodyTop = [...bodyBy.entries()].sort((a, b) => b[1] - a[1])[0];
  const bodySample = bodyTop ? bodyTexts.find((t) => t.fam + '|' + Math.round(t.size) === bodyTop[0]) : null;
  const longest = bodyTexts.slice().sort((a, b) => b.chars - a.chars)[0];
  const sizes = [...sizeChars.entries()].sort((a, b) => a[0] - b[0]).map(([s]) => s);
  const maxText = texts.slice().sort((a, b) => b.size - a.size)[0];
  const smallText = texts.filter((t) => t.size < 13 && t.chars >= 12 && !t.inUI && !t.inCode).length; // interface fragments keep their true sizes
  const loaded = new Set([...document.fonts].filter((f) => f.status === 'loaded').map((f) => f.family.replace(/["']/g, '').toLowerCase()));
  const declared = new Set([...document.fonts].map((f) => f.family.replace(/["']/g, '').toLowerCase()));
  const notLoaded = families.map((f) => f.family).filter((f) => !GENERIC.test(f) && !loaded.has(f.toLowerCase()) && (declared.has(f.toLowerCase()) || declared.size > 0));

  // --- colours
  const bodyBg = (() => {
    for (const el of [document.body, document.documentElement]) { const v = rgba(getComputedStyle(el).backgroundColor); if (v.a > 0.5) return v; }
    return { r: 255, g: 255, b: 255, a: 1 };
  })();
  const sectionBgs = [];
  for (const b of big) {
    if (b.area < W * H * 0.3) continue;
    const v = rgba(b.cs.backgroundColor); if (v.a < 0.9) continue;
    const hx = hex(v); if (!sectionBgs.some((s) => s.color === hx)) sectionBgs.push({ color: hx, top: Math.round(b.top), hsl: hsl(v) });
  }
  const textColorChars = new Map();
  for (const t of texts) { const v = rgba(t.color); if (v.a < 0.3) continue; const hx = hex(v); textColorChars.set(hx, (textColorChars.get(hx) || 0) + t.chars); }
  const textColors = [...textColorChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([color, c]) => ({ color, share: round(c / totalChars) }));

  // effective background behind an element (null when an image or gradient sits behind it)
  const bgBehind = (el) => {
    for (let p = el; p && p !== document.documentElement; p = p.parentElement) {
      const cs = getComputedStyle(p);
      if (cs.backgroundImage !== 'none' || p.tagName === 'VIDEO' || p.tagName === 'CANVAS') return null;
      const v = rgba(cs.backgroundColor); if (v.a >= 0.9) return v;
      if (cs.position === 'absolute' || cs.position === 'fixed') return null; // may overlap media
    }
    return bodyBg;
  };
  // media that may sit behind text without being its ancestor (an absolutely placed hero image, a video layer)
  const mediaRects = big.filter((b) => b.area >= 120 * 80 && (b.el.tagName === 'IMG' || b.el.tagName === 'VIDEO' || b.el.tagName === 'CANVAS' || b.el.tagName === 'PICTURE' || b.el.tagName === 'IFRAME' || b.el.tagName === 'svg' || b.cs.backgroundImage !== 'none')).map((b) => ({ el: b.el, l: b.r.left, r: b.r.right, t: b.r.top + SY, b: b.r.bottom + SY }));
  const overMedia = (t) => {
    const area = Math.max(1, t.w * t.h);
    for (const m of mediaRects) {
      const ox = Math.min(t.right, m.r) - Math.max(t.left, m.l), oy = Math.min(t.bottom, m.b) - Math.max(t.top, m.t);
      if (ox > 0 && oy > 0 && (ox * oy) / area >= 0.25) return true;
    }
    return false;
  };
  const lowContrast = [];
  for (const t of texts) {
    if (t.chars < 4 || t.top - SY > H * 6) continue;
    const fg = rgba(t.color); if (fg.a < 0.99) continue;
    if (overMedia(t)) continue; // cannot know the pixels behind it; judge that from the screenshot
    // text blended with difference or exclusion inverts whatever lies under it; its colour alone says nothing
    let blended = false;
    for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) { blended = true; break; }
    if (blended) continue;
    const bg = bgBehind(t.el); if (!bg) continue;
    const c = contrast(fg, bg); const need = t.size >= 24 || (t.size >= 18.5 && t.weight >= 700) ? 3 : 4.5;
    if (c < need) lowContrast.push({ text: t.text.slice(0, 40), ratio: c, need, size: t.size, fg: hex(fg), bg: hex(bg) });
  }
  lowContrast.sort((a, b) => a.ratio - b.ratio);

  // --- buttons
  const buttons = [];
  for (const el of document.querySelectorAll('a, button, [role="button"], input[type="submit"]')) {
    const r = el.getBoundingClientRect(); if (r.height < 28 || r.height > 84 || r.width < 64 || r.width > Math.min(W * 0.96, 900)) continue;
    const cs = getComputedStyle(el); let bg = rgba(cs.backgroundColor); const bw = parseFloat(cs.borderTopWidth) || 0;
    const grad = cs.backgroundImage.includes('gradient(');
    if (grad && bg.a < 0.5) { const first = cs.backgroundImage.match(/(rgba?\([^)]+\)|oklch\([^)]+\)|color\([^)]+\)|#[0-9a-f]{3,8})/i); if (first) bg = rgba(first[1]); }
    if (bg.a < 0.5 && bw < 1) continue;
    const rad = parseFloat(cs.borderTopLeftRadius) || 0;
    buttons.push({
      text: (el.innerText || el.value || '').replace(/\s+/g, ' ').trim().slice(0, 40), filled: bg.a >= 0.5, bg: bg.a >= 0.5 ? hex(bg) : null, bgHsl: bg.a >= 0.5 ? hsl(bg) : null, fg: hex(rgba(cs.color)),
      radius: rad >= r.height / 2 - 1 ? 'pill' : Math.round(rad), h: Math.round(r.height), top: Math.round(r.top + SY),
      shadow: cs.boxShadow !== 'none' && !/^rgba?\(0, 0, 0, 0\)/.test(cs.boxShadow), gradient: grad, upper: cs.textTransform === 'uppercase', mono: isMono(cs.fontFamily),
    });
  }
  const mode = (arr) => { const m = new Map(); for (const a of arr) m.set(JSON.stringify(a), (m.get(JSON.stringify(a)) || 0) + 1); const top = [...m.entries()].sort((a, b) => b[1] - a[1])[0]; return top ? JSON.parse(top[0]) : undefined; };
  const filledBtns = buttons.filter((b) => b.filled);

  // --- surfaces and decoration
  const isOverlay = (el) => { for (let p = el, i = 0; p && i < 4; p = p.parentElement, i++) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; if (p.matches('dialog, [role="dialog"], [role="menu"], [role="tooltip"], [popover]')) return true; } return false; };
  // real tables, form fields and interface fragments (marked data-ui) keep their own lines
  const formish = (el) => el.matches('input, textarea, select, table, thead, tbody, tr, td, th, hr') || !!el.closest('table, [data-ui]');
  let shadowStatic = 0, shadowOverlay = 0; const shadowSamples = []; let gradients = 0; let scrims = 0; const gradientSamples = []; let gradientText = 0;
  let hairlines = 0; const hairlineSamples = []; let outlinedBoxes = 0; let blurred = 0; let roundedBoxes = 0; let squareMedia = 0; let roundedMedia = 0; const mediaRadii = []; const boxRadii = [];
  let dashed = 0;
  const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
  for (const b of big) {
    const { cs, r, el } = b;
    const sh = cs.boxShadow;
    if (sh !== 'none' && !/^rgba?\(0, 0, 0, 0\) 0px 0px 0px 0px$/.test(sh)) {
      const outer = sh.split(/,(?![^(]*\))/).some((part) => !/inset/.test(part) && !/ 0px 0px 0px 0px/.test(part) && !/rgba\(\d+, \d+, \d+, 0\)/.test(part));
      if (outer) { if (isOverlay(el) || el.closest('[data-ui]')) shadowOverlay++; else { shadowStatic++; if (shadowSamples.length < 4) shadowSamples.push(label(el) + ' → ' + sh.slice(0, 80)); } }
    }
    if (cs.backgroundImage.includes('gradient(')) {
      if ((cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text')) gradientText++;
      else if (b.area >= W * H * 0.02) {
        // neutral stops with changing alpha = a legibility scrim over media, not decoration
        const stops = [...cs.backgroundImage.matchAll(/rgba?\(([^)]+)\)/g)].map((mm) => mm[1].split(/[,\s/]+/).filter(Boolean).map(Number));
        const exotic = /oklch|oklab|color\(|lab\(|lch\(|hsl/.test(cs.backgroundImage);
        const scrim = !exotic && stops.length > 0 && stops.every((c) => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) <= 14) && stops.some((c) => c.length > 3 && c[3] < 1);
        if (scrim) scrims++; else { gradients++; if (gradientSamples.length < 4) gradientSamples.push(label(el) + ' → ' + cs.backgroundImage.slice(0, 110)); }
      }
    }
    if ((cs.backdropFilter && cs.backdropFilter !== 'none') || (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none')) blurred++;
    const bt = parseFloat(cs.borderTopWidth) || 0, bb = parseFloat(cs.borderBottomWidth) || 0, bl = parseFloat(cs.borderLeftWidth) || 0, br = parseFloat(cs.borderRightWidth) || 0;
    const thin = (w, c, style) => w > 0 && w <= 1.5 && style !== 'none' && rgba(c).a > 0.02;
    const topLine = thin(bt, cs.borderTopColor, cs.borderTopStyle), botLine = thin(bb, cs.borderBottomColor, cs.borderBottomStyle);
    const leftLine = thin(bl, cs.borderLeftColor, cs.borderLeftStyle), rightLine = thin(br, cs.borderRightColor, cs.borderRightStyle);
    if (!formish(el)) {
      if (topLine && botLine && leftLine && rightLine) { if (r.width >= 150 && r.height >= 80) outlinedBoxes++; }
      else if ((r.width >= 240 && (topLine || botLine)) || (r.height >= 240 && (leftLine || rightLine))) { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(r.width) + 'px'); }
      // thin dashed or dotted lines; a heavy dotted edge (3px or more) is a drawn perforation, not a hairline
      if ([[cs.borderTopStyle, bt], [cs.borderBottomStyle, bb], [cs.borderLeftStyle, bl], [cs.borderRightStyle, br]].some(([st, w]) => /dashed|dotted/.test(st) && w > 0 && w < 3)) dashed++;
    }
    const rad = parseFloat(cs.borderTopLeftRadius) || 0;
    const isMedia = el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'CANVAS' || /url\(/.test(cs.backgroundImage);
    if (isMedia && r.width >= 200 && r.height >= 140) {
      let effective = rad; let p = el.parentElement;
      for (let i = 0; i < 3 && p && effective === 0; i++, p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflow !== 'visible') effective = parseFloat(pcs.borderTopLeftRadius) || 0; }
      if (effective > 1) { roundedMedia++; mediaRadii.push(Math.round(effective)); } else squareMedia++;
    } else if (rad >= 6 && r.width >= 150 && r.height >= 80 && (rgba(cs.backgroundColor).a > 0.04 || bt > 0)) { roundedBoxes++; boxRadii.push(Math.round(Math.min(rad, 99))); }
  }
  // elements that are themselves a thin rule (1px tall or 1px wide)
  for (const el of all) {
    const r = el.getBoundingClientRect();
    const horiz = r.height > 0 && r.height <= 1.5 && r.width >= 160, vert = r.width > 0 && r.width <= 1.5 && r.height >= 160;
    if (!horiz && !vert) continue; if (formish(el) && (el.tagName !== 'HR' || el.closest('[data-ui]'))) continue;
    const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.03) continue;
    if (rgba(cs.backgroundColor).a > 0.02 || parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderLeftWidth) > 0 || cs.backgroundImage !== 'none') { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(Math.max(r.width, r.height)) + 'px rule'); }
  }

  // --- repeated card rows (three or more same-size boxed siblings)
  const cardRows = [];
  const seenParents = new Set();
  for (const b of big) {
    const p = b.el.parentElement; if (!p || seenParents.has(p)) continue; seenParents.add(p);
    // cards carry text; a row of photographs or media tiles is not a card row
    const kids = [...p.children].filter((k) => { const r = k.getBoundingClientRect(); return r.width >= 150 && r.width < W * 0.9 && r.height >= 90 && !k.matches('img, picture, video, canvas, svg, figure:not(:has(figcaption))') && (k.innerText || '').trim().length >= 12; }); // full-width bands are sections, not cards
    if (kids.length < 3) continue;
    const boxed = kids.filter((k) => { const cs = getComputedStyle(k); return (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none') && true; });
    if (boxed.length < 3) continue;
    const w0 = boxed[0].getBoundingClientRect().width;
    const same = boxed.filter((k) => Math.abs(k.getBoundingClientRect().width - w0) < 3);
    if (same.length < 3) continue;
    const iconTop = same.filter((k) => { const first = k.querySelector('svg, img, i, [class*="icon" i]'); if (!first) return false; const r = first.getBoundingClientRect(); return r.width > 0 && r.width <= 64 && r.height <= 64; }).length;
    cardRows.push({ count: same.length, iconTopped: iconTop >= same.length - 1 && iconTop >= 3, rounded: (parseFloat(getComputedStyle(same[0]).borderTopLeftRadius) || 0) >= 6, top: Math.round(same[0].getBoundingClientRect().top + SY) });
  }

  // --- hero media
  const heroMedia = [];
  for (const b of big) {
    const { el, r, cs } = b; if (r.top > H * 0.9 || r.bottom < 0) continue;
    const type = el.tagName === 'IMG' ? 'img' : el.tagName === 'VIDEO' ? 'video' : el.tagName === 'CANVAS' ? 'canvas' : el.tagName === 'svg' && r.width > 240 ? 'svg' : /url\(/.test(cs.backgroundImage) ? 'bg-image' : el.tagName === 'IFRAME' ? 'iframe' : null;
    if (!type) continue;
    const vw = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0)), vh = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0));
    const ratio = (vw * vh) / (W * H); if (ratio >= 0.08) heroMedia.push({ type, ratio: round(ratio) });
  }
  heroMedia.sort((a, b) => b.ratio - a.ratio);
  const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getBoundingClientRect().width > 0 && (i.currentSrc || i.src)).map((i) => (i.currentSrc || i.src).slice(0, 120)).slice(0, 8);

  // --- navigation
  const navEl = document.querySelector('header nav, nav, header, [role="navigation"]');
  let nav = null;
  if (navEl) {
    let host = navEl; let fixed = false; let p = navEl;
    for (let i = 0; i < 4 && p && p !== document.body; i++, p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') { fixed = true; host = p; break; } }
    const hr = host.getBoundingClientRect(); const hcs = getComputedStyle(host);
    const links = [...navEl.querySelectorAll('a')].filter((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.top < 140; });
    nav = { links: links.length, fixed, height: Math.round(hr.height), floatingPill: hr.width < W * 0.92 && parseFloat(hcs.borderTopLeftRadius) >= 16 && hr.top > 4, blur: (hcs.backdropFilter || 'none') !== 'none' };
  }

  // data-ui is for cropped interface fragments, not for wrapping the page
  const uiAbuse = [...document.querySelectorAll('[data-ui]')].some((el) => el.querySelector('h1') || el.getBoundingClientRect().height > H * 1.6 || el === document.body);
  const uiFragments = document.querySelectorAll('[data-ui]').length;
  // pictures that were not drawn by the page's own code: photographs, footage, raster artwork.
  // Inline SVG and .svg files do not count; cropped interface fragments (data-ui) are counted apart.
  const svgSrc = (u) => /\.svg(\?|#|$)|^data:image\/svg/i.test(u || '');
  let realPictures = 0, videoCount = 0; const pictureSamples = [];
  for (const el of all) {
    if (el.closest('[data-ui]') || el.closest('svg')) continue;
    const r = el.getBoundingClientRect(); if (r.width < 120 || r.height < 90 || r.width * r.height < 160 * 120) continue;
    if (el.tagName === 'IMG') {
      const src = el.currentSrc || el.src;
      if (el.naturalWidth > 0 && !svgSrc(src)) { realPictures++; if (pictureSamples.length < 8) pictureSamples.push(src.split('/').pop().slice(0, 48)); }
      continue;
    }
    if (el.tagName === 'VIDEO') { realPictures++; videoCount++; continue; }
    const bg = getComputedStyle(el).backgroundImage;
    if (bg && bg.includes('url(')) { const u = (bg.match(/url\(["']?([^"')]+)/) || [])[1]; if (!svgSrc(u)) realPictures++; }
  }
  const uiPictures = [...document.querySelectorAll('[data-ui]')].filter((el) => { const r = el.getBoundingClientRect(); return r.width >= 300 && r.height >= 200; }).length;
  // which elements push the page wider than the viewport (the deepest ones that are not clipped by an ancestor)
  let overflowCulprits = [];
  if (document.documentElement.scrollWidth > W + 2) {
    const clippedByAncestor = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (getComputedStyle(p).overflowX !== 'visible' && p.getBoundingClientRect().right <= W + 2) return true; } return false; };
    const wide = all.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.right > W + 2 && !clippedByAncestor(el); });
    overflowCulprits = wide.filter((el) => !wide.some((o) => o !== el && el.contains(o))).slice(0, 3).map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.classList.length ? '.' + [...el.classList].slice(0, 2).join('.') : ''} (+${Math.round(el.getBoundingClientRect().right - W)}px)`);
  }
  // rows that scroll sideways with content past their edge (read as clipped in screenshots and by most visitors)
  const sideScrollers = all.filter((el) => { if (el.closest('[data-ui], table, pre, code')) return false; const cs = getComputedStyle(el); return /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 24 && el.clientHeight > 40; }).length;
  const blendText = texts.filter((t) => { for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) return true; return false; }).length;
  const bodyText = document.body.innerText || '';
  const emoji = (bodyText.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu) || []);
  const doc = document.documentElement; const pageH = Math.max(doc.scrollHeight, document.body.scrollHeight);
  const headingStats = headings.map(({ _el, ...h }) => h);
  const heroOut = hero ? (({ _el, ...h }) => h)(hero) : null;
  const below = headingStats.filter((h) => !heroOut || h.top !== heroOut.top);

  return {
    viewport: { w: W, h: H },
    page: { height: pageH, screens: round(pageH / H, 1), words: bodyText.split(/\s+/).filter(Boolean).length,
      img: document.images.length, video: document.querySelectorAll('video').length, canvas: document.querySelectorAll('canvas').length, svg: document.querySelectorAll('svg').length, sections: document.querySelectorAll('section').length,
      horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10),
      videosNoPoster: [...document.querySelectorAll('video')].filter((v) => !v.getAttribute('poster') && v.getBoundingClientRect().width >= 160).length },
    hero: heroOut, h1,
    headings: headingStats.slice(0, 40),
    headingSummary: { count: headingStats.length, maxLines: Math.max(0, ...headingStats.map((h) => h.lines)), over2Lines: headingStats.filter((h) => h.lines > 2 && h.kind !== 'statement').length, statements: headingStats.filter((h) => h.kind === 'statement').length, centered: headingStats.filter((h) => h.align === 'center').length, medianWords: below.length ? below.map((h) => h.words).sort((a, b) => a - b)[Math.floor(below.length / 2)] : null, mixedFontHeadings: headingStats.filter((h) => h.mixedFonts).length },
    type: {
      families, familyCount: famChars.size, sizes, sizeCount: sizes.length, notLoaded,
      body: bodySample ? { font: bodySample.fam, size: bodySample.size, leading: bodySample.lh, weight: bodySample.weight, tracking: bodySample.ls, contrast: (() => { const bg = bgBehind(bodySample.el); return bg ? contrast(rgba(bodySample.color), bg) : null; })() } : null,
      measureChars: longest ? Math.round(longest.w / (longest.size * 0.5)) : null,
      max: maxText ? { size: maxText.size, vw: round((maxText.size / W) * 100, 1), font: maxText.fam, text: maxText.text.slice(0, 40) } : null,
      monoShare: round(monoChars / Math.max(1, totalChars)), monoProseShare: round(monoProse / Math.max(1, totalChars)), monoFamilies: [...monoFams], monoSamples,
      microLabels, microLabelShare: round(microLabelChars / Math.max(1, totalChars), 3), microSamples,
      upperShare: round(upperChars / Math.max(1, totalChars)), centerShare: round(centerChars / Math.max(1, totalChars)), smallText,
    },
    eyebrows, numbered: numbered.slice(0, 12).map((t) => ({ text: t.text.slice(0, 30), size: t.size, top: Math.round(t.top) })),
    color: {
      bodyBg: hex(bodyBg), bodyBgHsl: hsl(bodyBg), dark: lum(bodyBg) < 0.18, sectionBgs: sectionBgs.slice(0, 10), textColors,
      button: filledBtns.length ? { bg: mode(filledBtns.map((b) => b.bg)), hsl: mode(filledBtns.map((b) => b.bgHsl)), fg: mode(filledBtns.map((b) => b.fg)) } : null,
      lowContrast: lowContrast.slice(0, 6),
    },
    buttons: { count: buttons.length, inHero: buttons.filter((b) => b.top < H).length, radius: mode(buttons.map((b) => b.radius)), height: mode(buttons.map((b) => b.h)), withShadow: buttons.filter((b) => b.shadow).length, withGradient: buttons.filter((b) => b.gradient).length, mono: buttons.filter((b) => b.mono).length, upper: buttons.filter((b) => b.upper).length, heroLabels: buttons.filter((b) => b.top < H).map((b) => b.text).filter(Boolean).slice(0, 5) },
    surfaces: { shadowStatic, shadowOverlay, shadowSamples, gradients, scrims, gradientSamples, gradientText, hairlines, hairlineSamples, outlinedBoxes, dashed, blurred, roundedBoxes, boxRadius: mode(boxRadii) ?? 0, squareMedia, roundedMedia, mediaRadius: mode(mediaRadii) ?? 0, textShadow: texts.filter((t) => t.textShadow).length, cardRows: cardRows.slice(0, 8) },
    heroMedia: heroMedia.slice(0, 4), nav,
    fontsLoaded: [...loaded].slice(0, 12),
  };
}

// frame() runs at each scroll position the inspector photographs. It reports large text that something
// else is lying on top of (a picture sliding over a headline), and how much of the screen holds anything.
export function frame() {
  const W = innerWidth, H = innerHeight;
  const pinnedTop = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if ((cs.position === 'fixed' || cs.position === 'sticky') && p.getBoundingClientRect().top < 140 && p.getBoundingClientRect().height < 180) return true; } return false; };
  // 1. covered text. Hit-testing skips elements with pointer-events: none (a pinned title usually has it),
  // which would make whatever lies beneath look as if it were on top; switch it on while sampling.
  const hitStyle = document.createElement('style');
  hitStyle.textContent = '*, *::before, *::after { pointer-events: auto !important; }';
  document.head.appendChild(hitStyle);
  const covered = [], clipped = [], pinnedOver = [];
  const label = (el, direct) => (el.innerText || direct).replace(/\s+/g, ' ').trim().slice(0, 48);
  const pinnedLow = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.position === 'fixed' || pcs.position === 'sticky') return !pinnedTop(el); } return false; };
  // Does the element found on top actually paint anything there? A transparent layout box does not hide text.
  const paints = (hit, el) => {
    for (let p = hit; p && p !== document.body && !p.contains(el); p = p.parentElement) {
      if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME|svg)$/.test(p.tagName) || p.closest('svg')) return true;
      const pcs = getComputedStyle(p);
      if (pcs.backgroundImage !== 'none') return true;
      const m = pcs.backgroundColor.match(/rgba?\(([^)]+)\)/); const parts = m ? m[1].split(/[,\s/]+/).filter(Boolean) : [];
      if (parts.length && (parts.length < 4 || parseFloat(parts[3]) > 0.05)) return true;
      if (p === hit) for (const n of p.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) return true; // its own text lies on ours
    }
    return false;
  };
  const pictures = [...document.querySelectorAll('img, picture, svg, canvas, video')].filter((m) => !m.parentElement?.closest('svg')).map((m) => ({ m, r: m.getBoundingClientRect() })).filter(({ r }) => r.width >= 80 && r.height >= 80 && r.bottom > 0 && r.top < H);
  for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, a, button, span, div')) {
    let direct = ''; for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
    if (direct.trim().length < 3) continue;
    const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); if (size < 24 || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
    let srOnly = false;
    for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) { const pcs = getComputedStyle(p); const pr = p.getBoundingClientRect(); if (pcs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(pcs.clipPath) || (pr.width <= 2 && pr.height <= 2 && pcs.overflow !== 'visible')) { srOnly = true; break; } }
    if (srOnly) continue;
    // the boxes that clip this text (overflow other than visible, or a clip-path)
    const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); }
    // a scroll scene moves pictures across its own held stage by design; its text is judged by the scene rules
    const pinned = size >= 32 && pinnedLow(el) && !el.closest('.scene.is-live');
    // lines of one heading set as separate spans overlap each other's glyph boxes; that is not a collision
    const unit = el.closest('h1, h2, h3, h4, h5, h6, p, blockquote');
    const range = document.createRange(); let total = 0, hidden = 0, cutTop = false, area = 0, over = 0;
    for (const node of el.childNodes) {
      if (node.nodeType !== 3 || !node.nodeValue.trim()) continue;
      range.selectNodeContents(node);
      for (const r of range.getClientRects()) {
        if (r.width < 6 || r.bottom < 0 || r.top > H) continue;
        // sampled at two heights inside the line box (a display face's glyph box often spills past it and
        // under the next line), so a band lying over the top or the bottom of the letters is found too
        const lh = parseFloat(cs.lineHeight); const band = Math.min(r.height, Number.isFinite(lh) ? lh : r.height);
        for (const dy of [-0.2, 0.2]) for (const fx of [0.12, 0.37, 0.63, 0.88]) {
          const x = r.left + r.width * fx, y = r.top + r.height * 0.55 + band * dy; if (x < 0 || x >= W || y < 0 || y >= H) continue;
          total++; const hit = document.elementFromPoint(x, y);
          if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) && !pinnedTop(hit) && !(unit && unit.contains(hit) && !/^(IMG|VIDEO|CANVAS|PICTURE|svg)$/.test(hit.tagName) && !hit.closest('svg')) && paints(hit, el)) hidden++;
        }
        // the glyph box starts above the capitals (ascender room), so only a cut well into it is a real crop
        if (size >= 40 && r.top >= -4 && clips.some((c) => c.top > r.top + r.height * 0.3 && c.top < r.bottom - r.height * 0.2)) cutTop = true;
        if (pinned) { area += r.width * r.height; for (const { m, r: pr } of pictures) { if (m.contains(el) || el.contains(m)) continue; const ox = Math.min(r.right, pr.right) - Math.max(r.left, pr.left), oy = Math.min(r.bottom, pr.bottom) - Math.max(r.top, pr.top); if (ox > 0 && oy > 0) over += ox * oy; } }
      }
    }
    if (total >= 4 && hidden / total >= 0.2) covered.push({ text: label(el, direct), share: Math.round((hidden / total) * 100) / 100 });
    if (cutTop) clipped.push({ text: label(el, direct) });
    if (pinned && area && over / area >= 0.15) pinnedOver.push({ text: label(el, direct), share: Math.min(1, Math.round((over / area) * 100) / 100) });
  }
  hitStyle.remove();
  // 2. how much of the screen holds anything: text glyph boxes, media, or a ground that differs from the page's
  const opaque = (c) => c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c);
  const pageBg = [document.body, document.documentElement].map((e) => getComputedStyle(e).backgroundColor).find(opaque) || 'rgb(255, 255, 255)';
  const textRects = [];
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
    if (!n.nodeValue.trim() || !n.parentElement) continue; const pcs = getComputedStyle(n.parentElement); if (pcs.visibility === 'hidden' || +pcs.opacity < 0.2) continue;
    rg.selectNodeContents(n); for (const r of rg.getClientRects()) if (r.bottom > 0 && r.top < H && r.width > 1) textRects.push(r);
  }
  let filled = 0, samples = 0;
  for (let gx = 0; gx < 24; gx++) for (let gy = 0; gy < 15; gy++) {
    const x = (gx + 0.5) * (W / 24), y = (gy + 0.5) * (H / 15); samples++;
    if (textRects.some((r) => x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 4 && y <= r.bottom + 4)) { filled++; continue; }
    let el = document.elementFromPoint(x, y);
    for (let i = 0; el && i < 6 && el !== document.body && el !== document.documentElement; i++, el = el.parentElement) {
      if (/^(IMG|VIDEO|CANVAS|PICTURE|svg|IFRAME)$/.test(el.tagName) || el.closest('svg')) { filled++; break; }
      const cs = getComputedStyle(el);
      if (cs.backgroundImage !== 'none') { filled++; break; }
      const bg = cs.backgroundColor; if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { if (bg !== pageBg) filled++; break; }
    }
  }
  // 3. the large text in view, to spot one statement held on screen for screen after screen
  const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return !el.closest('.scene.is-live') && parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
  return { covered, clipped, pinnedOver, filled: Math.round((filled / samples) * 100) / 100, big, y: Math.round(scrollY) };
}

// layout() runs once, back at the top after the page has been scrolled through (so reveals have fired).
// It measures the frame of the page rather than its details: the two outer margins, sections whose content
// huddles on one side, blank stretches with nothing in them, the title, untitled sections, text pressed
// against the edge of the screen and how many left edges the text uses.
export function layout() {
  const W = innerWidth, H = innerHeight, SY = scrollY;
  const D = document.documentElement, B = document.body;
  const pageH = Math.max(D.scrollHeight, B.scrollHeight);
  const pct = (arr, p) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * p)))]; };
  const shown = (el) => { for (let p = el, i = 0; p && p !== B && i < 30; p = p.parentElement, i++) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; if (i < 4 && (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath))) return false; } return true; };
  const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; };
  const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
  const boxes = [];
  // text, one box per text node (its line boxes merged)
  const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
  for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) {
    const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t.length < 2) continue;
    const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue;
    rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
    if (!shown(el)) continue;
    const cs = getComputedStyle(el);
    boxes.push({ kind: 'text', el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
  }
  // pictures, drawings and controls
  for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
    const tag = el.tagName;
    let kind = null;
    if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(tag)) kind = 'media';
    else if (tag === 'svg') { if (el.parentElement && el.parentElement.closest('svg')) continue; kind = 'media'; }
    else if (/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(tag) || el.getAttribute('role') === 'button') kind = 'control';
    else { const bi = getComputedStyle(el).backgroundImage; if (bi && bi.includes('url(')) kind = 'media'; else continue; }
    const r = el.getBoundingClientRect(); if (r.width < 24 || r.height < 24) continue;
    if (kind === 'media' && r.width * r.height < 48 * 48) continue;
    if (!shown(el)) continue;
    boxes.push({ kind, el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: 0, size: 0, layer: layerOf(el), tag });
  }
  const flow = boxes.filter((b) => b.layer !== 'fixed');
  // 1. the frame: how far the page's content comes to each edge (full-bleed things say nothing about margins)
  // a row that runs off one edge (a carousel, a bled display line) says nothing about that side's margin
  const framed = flow.filter((b) => b.r - b.l < W * 0.97 && b.l >= -1 && b.r <= W + 1);
  const L = framed.length ? Math.max(0, pct(framed.map((b) => b.l), 0.02)) : null;
  const R = framed.length ? Math.max(0, W - pct(framed.map((b) => b.r), 0.98)) : null;
  // 2. sections: descend through single wrappers, then take the blocks
  const tall = (c) => { const r = c.getBoundingClientRect(); return r.height >= 120 && r.width >= W * 0.6 && getComputedStyle(c).position !== 'fixed' && shown(c); };
  let root = B;
  for (let i = 0; i < 12; i++) { const kids = [...root.children].filter(tall); if (kids.length === 1) root = kids[0]; else break; }
  // a tall block is split into its parts; the parts of a block that has its own heading continue it
  let blocks = [...root.children].filter(tall).map((el) => ({ el, cont: false }));
  for (let pass = 0; pass < 2; pass++) blocks = blocks.flatMap((b) => { const k = [...b.el.children].filter(tall); if (b.el.getBoundingClientRect().height > H * 1.6 && k.length >= 2) { const titled = b.cont || !!b.el.querySelector('h1, h2, h3, h4, [role="heading"]'); return k.map((el) => ({ el, cont: titled })); } return [b]; });
  const continues = new Set(blocks.filter((b) => b.cont).map((b) => b.el));
  blocks = blocks.map((b) => b.el);
  const bodySize = (() => { const m = new Map(); for (const b of flow) if (b.kind === 'text' && b.chars >= 40) m.set(Math.round(b.size), (m.get(Math.round(b.size)) || 0) + b.chars); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 16; })();
  const sections = blocks.slice(0, 40).map((s, i) => {
    const r = s.getBoundingClientRect(); const top = r.top + SY, bot = r.bottom + SY;
    const inside = flow.filter((b) => b.t >= top - 2 && b.b <= bot + 2 && s.contains(b.el));
    const own = inside.filter((b) => b.r - b.l < W * 0.97);
    const text = inside.filter((b) => b.kind === 'text');
    const media = inside.filter((b) => b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= 160 * 120);
    const words = text.reduce((a, b) => a + b.text.split(' ').length + Math.max(0, Math.round((b.chars - 50) / 6)), 0);
    const headRe = /^H[1-4]$/;
    const heading = text.filter((b) => (b.el.closest('h1,h2,h3,h4,[role="heading"]') || b.size >= Math.max(22, bodySize * 1.35)) && b.t <= top + Math.max(520, (bot - top) * 0.45)).sort((a, b) => b.size - a.size)[0];
    const cs = getComputedStyle(s);
    // the widest stretch across the section's content band that nothing covers, edge to edge excluded
    let middle = 0;
    if (own.length >= 2) {
      const iv = own.map((b) => [Math.max(0, b.l), Math.min(W, b.r)]).sort((a, b) => a[0] - b[0]);
      let end = iv[0][1];
      for (const [a, b2] of iv.slice(1)) { if (a > end) middle = Math.max(middle, a - end); end = Math.max(end, b2); }
    }
    return {
      sel: sel(s), tag: s.tagName.toLowerCase(), top: Math.round(top), height: Math.round(r.height), words, media: media.length, middle: Math.round(middle),
      heading: heading ? { text: heading.text, size: Math.round(heading.size), tag: (heading.el.closest('h1,h2,h3,h4,h5,h6') || heading.el).tagName.toLowerCase() } : null,
      left: own.length ? Math.round(Math.max(0, Math.min(...own.map((b) => b.l)))) : null, right: own.length ? Math.round(Math.max(0, W - Math.max(...own.map((b) => b.r)))) : null,
      textLeft: text.length ? Math.round(Math.min(...text.map((b) => b.l))) : null, textRight: text.length ? Math.round(W - Math.max(...text.map((b) => b.r))) : null,
      fullBleed: inside.some((b) => b.kind === 'media' && b.r - b.l >= W * 0.97), footer: s.tagName === 'FOOTER' || !!s.querySelector('footer') || i === blocks.length - 1,
      hero: top < H * 0.5, continues: continues.has(s), pinned: cs.position === 'sticky' || !!s.querySelector('.pin-spacer') || [...s.querySelectorAll('*')].slice(0, 400).some((e) => getComputedStyle(e).position === 'sticky' && e.getBoundingClientRect().height > H * 0.5),
    };
  });
  // 3. blank stretches: rows of the page with no text, picture or control, between the first and the last content.
  // A sticky element's track (its parent) and a pinning spacer are scroll distance, not blank page.
  const STEP = 4, rows = Math.ceil(pageH / STEP), occ = new Uint8Array(rows);
  const mark = (t, b, v) => { for (let y = Math.max(0, Math.floor(t / STEP)); y < Math.min(rows, Math.ceil(b / STEP)); y++) occ[y] = v; };
  for (const b of flow) if (b.layer !== 'sticky') mark(b.t, b.b, 1);
  for (const el of B.querySelectorAll('.pin-spacer')) { const r = el.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); }
  let scanned = 0;
  for (const el of B.querySelectorAll('*')) { if (++scanned > 9000) break; const cs = getComputedStyle(el); if (cs.position === 'sticky' && el.parentElement) { const r = el.parentElement.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } }
  let first = 0; while (first < rows && occ[first] !== 1) first++;
  let last = rows - 1; while (last > 0 && occ[last] !== 1) last--;
  const gaps = []; let run = 0;
  for (let y = first; y <= last + 1; y++) { if (y <= last && occ[y] === 0) run++; else { if (run * STEP >= H * 0.3) gaps.push({ top: (y - run) * STEP, height: run * STEP }); run = 0; } }
  // 4. the title
  const h1s = [...document.querySelectorAll('h1')].filter((h) => { const r = h.getBoundingClientRect(); return r.width > 0 && r.height > 0 && shown(h); }).map((h) => { const r = h.getBoundingClientRect(); return { text: (h.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80), size: Math.round(parseFloat(getComputedStyle(h).fontSize)), top: Math.round(r.top + SY), firstScreen: r.top + SY < H * 0.95 }; });
  // 5. text pressed against the edge of the screen (type deliberately bled past the edge is a different device)
  // fixed headers and corner labels count here: that is where text most often creeps to the edge
  const edge = 16;
  const edgeText = boxes.filter((b) => b.kind === 'text' && b.chars >= 3 && b.size < 40 && b.l >= -1 && b.r <= W + 1 && (b.l < edge || b.r > W - edge) && !b.el.closest('[class*="marquee" i],[class*="ticker" i],marquee,[data-ui],table')).slice(0, 8).map((b) => ({ text: b.text, side: b.l < edge ? 'left' : 'right', px: Math.round(b.l < edge ? b.l : W - b.r) }));
  // display type that runs off one edge by a sliver while it starts inside the margin: a fit error, not a bleed
  const slivers = boxes.filter((b) => b.kind === 'text' && b.size >= 40 && b.chars >= 3 && ((b.l >= 8 && b.r > W + 1 && b.r - W < b.size * 0.25) || (b.r <= W - 8 && b.l < -1 && -b.l < b.size * 0.25))).slice(0, 4).map((b) => ({ text: b.text.slice(0, 30), px: Math.round(b.r > W ? b.r - W : -b.l), side: b.r > W ? 'right' : 'left' }));
  // the largest empty rectangle on the first screen (below a 90px band for the navigation), on a 24 x 15 grid
  let voidShare = 0, voidBox = null;
  if (W >= 1000) {
    const C = 24, Rn = 15, cw = W / C, ch = H / Rn, occ2 = Array.from({ length: Rn }, () => new Array(C).fill(0));
    for (const b of boxes) { if (b.t - SY > H || b.b - SY < 0) continue; const c0 = Math.max(0, Math.floor(b.l / cw)), c1 = Math.min(C - 1, Math.floor((b.r - 1) / cw)), r0 = Math.max(0, Math.floor((b.t - SY) / ch)), r1 = Math.min(Rn - 1, Math.floor((b.b - SY - 1) / ch)); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) occ2[r][c] = 1; }
    const top = Math.ceil(90 / ch), hgt = new Array(C).fill(0);
    for (let r = top; r < Rn; r++) {
      for (let c = 0; c < C; c++) hgt[c] = occ2[r][c] ? 0 : hgt[c] + 1;
      for (let c = 0; c < C; c++) { let mn = Infinity; for (let k = c; k < C && hgt[k]; k++) { mn = Math.min(mn, hgt[k]); const a = mn * (k - c + 1); if (a > voidShare) { voidShare = a; voidBox = { x: Math.round(c * cw), y: Math.round((r - mn + 1) * ch), w: Math.round((k - c + 1) * cw), h: Math.round(mn * ch) }; } } }
    }
    voidShare = Math.round((voidShare / (C * Rn)) * 100) / 100;
  }
  // a fixed picture layer that covers the screen (a canvas scene, a film) hides what the DOM cannot see
  const stage = boxes.some((b) => b.layer === 'fixed' && b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= W * H * 0.9);
  // the first screen: is the title a bare name, with nothing at reading size saying what this is?
  const firstText = flow.filter((b) => b.kind === 'text' && b.t < H * 0.95 && b.size >= 18 && !b.el.closest('h1, nav, header nav, button, a'));
  const explains = firstText.some((b) => b.text.split(' ').length >= 6 || b.chars >= 36);
  // 6. left edges used by running text and headings (left-aligned blocks only)
  // headings and paragraphs only: the items of a grid or a list each start their own column on purpose
  const lefts = flow.filter((b) => b.kind === 'text' && b.size >= 14 && !/center|right|end/.test(b.align) && b.l >= 0 && (b.el.closest('h1, h2, h3') || (b.chars >= 60 && b.el.closest('p, blockquote'))) && !b.el.closest('nav, footer, [data-ui], table, li, figcaption')).map((b) => b.l).sort((a, b) => a - b);
  const edges = []; for (const x of lefts) { const e = edges.find((g) => Math.abs(g.x - x) <= 10); if (e) e.n++; else edges.push({ x: Math.round(x), n: 1 }); }
  edges.sort((a, b) => b.n - a.n);
  return { viewport: { w: W, h: H }, pageH, margins: { left: L == null ? null : Math.round(L), right: R == null ? null : Math.round(R) }, sections, gaps, h1s, edgeText, edges: edges.slice(0, 12), leftBlocks: lefts.length, bodySize, stage, explains, slivers, voidShare, voidBox };
}

// app() measures what an app screen (dashboard, admin, settings, inbox) gets right or wrong: the shell,
// how much of the canvas is used, panels and their edges, numbers in tables and KPIs, chart colours,
// the icon-in-a-tinted-square habit, placeholder data, and what happens to the sidebar on a phone.
export function app() {
  const W = innerWidth, H = innerHeight, SY = scrollY, B = document.body;
  const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
  const ctx = cvs.getContext('2d', { willReadFrequently: true });
  const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; };
  const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
  const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
  const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100; };
  const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; };
  const shown = (el, cs) => { cs = cs || getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
  const groundOf = (el) => { for (let p = el; p; p = p.parentElement) { const v = rgba(getComputedStyle(p).backgroundColor); if (v.a >= 0.9) return v; } return { r: 255, g: 255, b: 255, a: 1 }; };
  const pageBg = groundOf(B);
  const all = [...B.querySelectorAll('*')].slice(0, 12000);
  // 1. the shell: a tall column at one side (sidebar) and a bar across the top
  let sidebar = null, topbar = null;
  for (const el of all) {
    const r = el.getBoundingClientRect(); if (r.width < 40) continue;
    if (!sidebar && r.left <= 1 && r.top <= 80 && r.height >= H * 0.7 && r.width >= 48 && r.width <= Math.min(360, W * 0.45) && (el.querySelectorAll('a, button').length >= 3) && shown(el)) sidebar = { w: Math.round(r.width), tag: el.tagName.toLowerCase(), links: el.querySelectorAll('a, button').length, share: Math.round((r.width / W) * 100) / 100 };
    if (!topbar && r.top <= 1 && r.left <= (sidebar ? sidebar.w + 2 : 2) && r.width >= W * 0.6 && r.height >= 36 && r.height <= 96 && shown(el)) topbar = { h: Math.round(r.height) };
    if (sidebar && topbar) break;
  }
  // 2. how much of the canvas the content uses (right of the sidebar)
  const x0 = sidebar ? sidebar.w : 0;
  // the text itself (a block element's box runs to the edge whatever its text does), pictures, charts and controls
  const boxes = [];
  const tw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const trg = document.createRange();
  for (let n = tw.nextNode(); n && boxes.length < 5000; n = tw.nextNode()) {
    if (n.nodeValue.trim().length < 2 || !n.parentElement || n.parentElement.closest('svg')) continue;
    const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue;
    trg.selectNodeContents(n); const r = trg.getBoundingClientRect(); if (r.width < 2 || r.left < x0 - 1 || (cs.position === 'fixed' && r.left > W * 0.5)) continue;
    boxes.push(r);
  }
  for (const el of B.querySelectorAll('svg, canvas, img, video, input, select, textarea, button, table')) {
    if (el.parentElement && el.parentElement.closest('svg')) continue;
    const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || r.left < x0 - 1 || !shown(el)) continue;
    boxes.push(r);
  }
  const rights = boxes.map((r) => r.right).sort((a, b) => a - b), lefts = boxes.map((r) => r.left).sort((a, b) => a - b);
  const canvas = boxes.length ? { left: Math.round(lefts[Math.floor(lefts.length * 0.02)] - x0), right: Math.round(W - rights[Math.floor(rights.length * 0.98)]) } : null;
  // 3. panels: boxes that read as cards (a fill one step from the ground, a border, or a shadow)
  const panels = []; const radii = new Map(); let heavyBorders = 0, heavyShadows = 0, softShadows = 0, glass = 0; const borderSamples = [], shadowSamples = [];
  for (const el of all) {
    const r = el.getBoundingClientRect(); if (r.width < 140 || r.height < 72 || r.width > W * 0.98) continue;
    const cs = getComputedStyle(el); if (!shown(el, cs)) continue;
    const bg = rgba(cs.backgroundColor), bw = parseFloat(cs.borderTopWidth) || 0, bc = rgba(cs.borderTopColor);
    const ground = groundOf(el.parentElement || B);
    const filled = bg.a >= 0.9 && contrast(bg, ground) >= 1.04, bordered = bw >= 0.5 && cs.borderTopStyle !== 'none' && bc.a > 0.05;
    const sh = cs.boxShadow !== 'none' ? cs.boxShadow : '';
    if (!filled && !bordered && !sh) continue;
    if (!(el.innerText || '').trim()) continue;
    panels.push({ el, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top + SY), left: Math.round(r.left), bottom: Math.round(r.bottom + SY), right: Math.round(r.right) });
    const rad = Math.round(parseFloat(cs.borderTopLeftRadius) || 0); radii.set(rad, (radii.get(rad) || 0) + 1);
    if (bordered) { const c = contrast({ ...bc, a: 1 }, bg.a >= 0.9 ? bg : ground); if (c >= (lum(ground) > 0.4 ? 1.45 : 1.9)) { heavyBorders++; if (borderSamples.length < 3) borderSamples.push(`${hex(bc)} on ${hex(bg.a >= 0.9 ? bg : ground)} (${c}:1)`); } }
    if (sh) {
      for (const part of sh.split(/,(?![^(]*\))/)) {
        if (/inset/.test(part)) continue; const col = part.match(/rgba?\([^)]+\)/); const nums = part.replace(/rgba?\([^)]+\)/, '').trim().split(/\s+/).map(parseFloat);
        const a = col ? rgba(col[0]).a : 1; const blur = nums[2] || 0, y = Math.abs(nums[1] || 0);
        if (a <= 0.02 || (blur === 0 && y === 0 && !(nums[3] > 0))) continue;
        // on a dark ground a black shadow barely shows; there it only counts when it is near-opaque
        const darkGround = lum(ground) < 0.2;
        if (darkGround ? a >= 0.45 : ((a >= 0.12 && blur >= 12) || y >= 8 || a >= 0.25)) { heavyShadows++; if (shadowSamples.length < 3) shadowSamples.push(part.trim().slice(0, 70)); } else softShadows++;
      }
    }
    if (cs.backdropFilter && cs.backdropFilter !== 'none') glass++;
  }
  // top-level panels only (a card inside a panel is part of it): rows that end ragged, and how many
  // panels start on the first screen
  const panelEls = new Set(panels.map((p) => p.el));
  const tops = panels.filter((p) => { for (let a = p.el.parentElement; a && a !== B; a = a.parentElement) if (panelEls.has(a)) return false; return p.w < W * 0.97 || p.h < H * 2; });
  // a column of cards stacked with a gutter of 24px or less is one block: two cards on the left that end level with
  // one tall card on the right are not ragged. A pair is ragged only when the cards and their columns both end apart.
  const below = (p) => tops.find((q) => q !== p && Math.abs(q.left - p.left) <= 4 && Math.abs(q.w - p.w) <= 4 && q.top >= p.bottom - 2 && q.top - p.bottom <= 24);
  const columnBottom = (p) => { let b = p.bottom; for (let q = below(p), n = 0; q && n < 12; q = below(q), n++) b = q.bottom; return b; };
  const ragged = [];
  for (let i = 0; i < tops.length; i++) for (let k = i + 1; k < tops.length; k++) {
    const a = tops[i], b = tops[k];
    if (Math.abs(a.top - b.top) > 4 || a.w < 200 || b.w < 200 || (a.right > b.left - 4 && b.right > a.left - 4)) continue; // side by side, same row
    const diff = Math.min(Math.abs(a.bottom - b.bottom), Math.abs(columnBottom(a) - columnBottom(b))), tall = Math.max(a.h, b.h);
    if (diff > Math.max(24, tall * 0.12)) ragged.push({ diff, top: a.top });
  }
  // the largest blank band inside a card: the vertical gap between consecutive pieces of its content (text, charts,
  // pictures, controls, rules, fills), or from the last piece to the card's padding edge. A legend pinned to the foot
  // of a stretched card, or four rows in a card kept at its neighbour's height, leave one.
  const voids = [];
  const seen = new Map();
  const visibleIn = (el, root) => { if (seen.has(el)) return seen.get(el); let ok = true; for (let p = el; p && p !== root.parentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05 || cs.position === 'fixed') { ok = false; break; } } seen.set(el, ok); return ok; };
  for (const p of tops) {
    if (p.el.matches('nav, aside, header, footer, dialog, [role="navigation"], [role="dialog"]') || p.el.querySelector('nav') || (sidebar && p.left < sidebar.w - 4) || p.h < 120) continue;
    if (panels.filter((q) => q.el !== p.el && p.el.contains(q.el)).length >= 2) continue; // a frame of cards, not a card
    const cs = getComputedStyle(p.el), pr = p.el.getBoundingClientRect();
    const inTop = pr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0), inBottom = pr.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0);
    const bg = rgba(cs.backgroundColor), spans = [];
    const tw2 = document.createTreeWalker(p.el, NodeFilter.SHOW_TEXT), rg = document.createRange();
    // text that opens a section (a heading, a summary, or text at 14px/600 or 15px/500 and up) may follow a wider, placed gap
    for (let n = tw2.nextNode(); n; n = tw2.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement || n.parentElement.closest('svg') || !visibleIn(n.parentElement, p.el)) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { const pcs = getComputedStyle(n.parentElement); const fs = parseFloat(pcs.fontSize), fw = +pcs.fontWeight; spans.push([r.top, r.bottom, !!n.parentElement.closest('h1, h2, h3, h4, h5, h6, [role="heading"], summary, legend, caption') || (fw >= 600 && fs >= 14) || (fw >= 500 && fs >= 15)]); } }
    for (const el of p.el.querySelectorAll('*')) {
      if (el.parentElement && el.parentElement.closest('svg')) continue;
      const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 1 || !visibleIn(el, p.el)) continue;
      if (/^(svg|canvas|img|video|input|select|textarea|button|hr|progress|meter|iframe)$/i.test(el.tagName)) { spans.push([r.top, r.bottom]); continue; }
      const ecs = getComputedStyle(el), fillC = rgba(ecs.backgroundColor);
      if (fillC.a >= 0.1 && (bg.a < 0.9 || contrast({ ...fillC, a: 1 }, bg) >= 1.03) && r.height >= 2) spans.push([r.top, r.bottom]);
      if ((parseFloat(ecs.borderTopWidth) || 0) >= 0.5 && ecs.borderTopStyle !== 'none' && rgba(ecs.borderTopColor).a > 0.05) spans.push([r.top, r.top + 1]);
      if ((parseFloat(ecs.borderBottomWidth) || 0) >= 0.5 && ecs.borderBottomStyle !== 'none' && rgba(ecs.borderBottomColor).a > 0.05) spans.push([r.bottom - 1, r.bottom]);
    }
    const ys = spans.filter(([a, b]) => b > inTop - 2 && a < inBottom + 2).sort((a, b) => a[0] - b[0]);
    if (!ys.length) continue;
    // a band counts when it is over 40px, or over 64px when the content after it opens a new section
    let end = ys[0][1], gap = 0, at = null;
    for (const [a, b] of ys.slice(1)) { const g = a - end, section = ys.some(([t, , hd]) => hd && Math.abs(t - a) < 6); if (g > (section ? 64 : 40) && g > gap) { gap = g; at = end; } end = Math.max(end, b); }
    if (inBottom - end > 40 && inBottom - end > gap) { gap = inBottom - end; at = end; }
    const head = (p.el.querySelector('h1, h2, h3, h4, [role="heading"], header') || p.el).innerText || '';
    if (gap > 40) voids.push({ gap: Math.round(gap), at: Math.round(at + SY), panel: head.trim().split('\n')[0].slice(0, 40) });
  }
  voids.sort((a, b) => b.gap - a.gap);
  const firstScreen = tops.filter((p) => p.top < H - 48).length;
  // the icon-in-a-tinted-square habit: small boxes with a coloured fill holding one icon, repeated
  const chips = []; let tiles = 0;
  for (const el of all) {
    const r = el.getBoundingClientRect(); if (r.width < 24 || r.width > 64 || Math.abs(r.width - r.height) > 4) continue;
    const cs = getComputedStyle(el); const bg = rgba(cs.backgroundColor); if (bg.a < 0.08) continue;
    const icon = el.querySelector('svg, i, img, [class*="icon" i]'); if (!icon || (el.innerText || '').trim().length > 2) continue;
    const c = hsl(bg); if (c.s < 0.18 && bg.a > 0.5) { if (r.width >= 28 && !el.closest('button, a, nav, aside, [role="navigation"]')) tiles++; continue; } // neutral: an avatar, a button, or a boxed icon
    chips.push({ hue: c.h, size: Math.round(r.width), radius: cs.borderTopLeftRadius });
  }
  const chipHues = new Set(chips.map((c) => Math.round(c.hue / 30)));
  // 5. numbers: tables and KPI figures
  const numRe = /^[−\-+]?[$€£¥]?\s?\d[\d,.\s]*(%|[kKmMbB]|\s?(ms|s|h|min|GB|MB|kB|x))?$/;
  const cells = [...B.querySelectorAll('td, [role="cell"], [role="gridcell"]')].filter((el) => numRe.test((el.innerText || '').trim()));
  const leftNums = cells.filter((el) => { const a = getComputedStyle(el).textAlign; const inner = el.firstElementChild ? getComputedStyle(el.firstElementChild).textAlign : a; return /left|start/.test(a) && /left|start/.test(inner) && getComputedStyle(el).justifyContent !== 'flex-end'; }).length;
  const figures = [];
  for (const el of all) { if (el.children.length) continue; const t = (el.innerText || '').trim(); if (!numRe.test(t) || t.length < 2) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; figures.push({ size: parseFloat(cs.fontSize), tabular: /tabular-nums/.test(cs.fontVariantNumeric) || /"tnum"/.test(cs.fontFeatureSettings), weight: +cs.fontWeight }); }
  const tabularShare = figures.length ? Math.round((figures.filter((f) => f.tabular).length / figures.length) * 100) / 100 : null;
  const rows = [...B.querySelectorAll('tbody tr, [role="row"]')].map((tr) => Math.round(tr.getBoundingClientRect().height)).filter((h) => h > 0);
  // 6. chart colours: saturated fills and strokes inside SVG charts and canvas-free charts
  const hues = new Map();
  for (const svg of B.querySelectorAll('svg')) {
    const r = svg.getBoundingClientRect(); if (r.width < 120 || r.height < 60) continue;
    for (const s of svg.querySelectorAll('path, rect, circle, line, polyline, polygon')) {
      const cs = getComputedStyle(s);
      for (const c of [cs.fill, cs.stroke]) { if (!c || c === 'none' || c.startsWith('url')) continue; const v = rgba(c); if (v.a < 0.3) continue; const h = hsl(v); if (h.s < 0.25 || h.l < 0.12 || h.l > 0.92) continue; const k = Math.round(h.h / 24); hues.set(k, (hues.get(k) || 0) + 1); }
    }
  }
  // bars and segments drawn with divs count too (a common way to fake a chart)
  // 7. placeholder data
  const text = B.innerText || '';
  const placeholders = [...new Set((text.match(/\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bLorem ipsum\b|\bexample\.com\b|\bUser Name\b|\bFirstname Lastname\b|\b123 Main St\b|\bfoo(bar)?\b/gi) || []).map((s) => s.toLowerCase()))];
  // 8. type at app sizes
  const sizes = new Map(); let tiny = 0; const tinySamples = [];
  const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
  for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n.nodeValue.trim(); if (t.length < 2 || !n.parentElement) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const s = Math.round(parseFloat(cs.fontSize)); sizes.set(s, (sizes.get(s) || 0) + t.length); if (s < 11 && t.length >= 3) { tiny++; if (tinySamples.length < 4) tinySamples.push(t.slice(0, 24) + ' ' + s + 'px'); } }
  const total = [...sizes.values()].reduce((a, b) => a + b, 0) || 1;
  const under13 = Math.round(([...sizes.entries()].filter(([s]) => s < 13).reduce((a, [, c]) => a + c, 0) / total) * 100) / 100;
  const h1 = document.querySelector('h1'); const h1r = h1 && h1.getBoundingClientRect();
  // the screen's hero: its h1 when that is on the first screen, else the largest text there. Text is read with a
  // space between element children, so a figure and its unit ("£55.94" and "at cost") do not run together.
  const spaced = (el) => { const out = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t && n.parentElement && !n.parentElement.closest('svg, script, style') && getComputedStyle(n.parentElement).display !== 'none') out.push(t); } return out.join(' ').replace(/\s+([,.;:!?%])/g, '$1').trim(); };
  let heroEl = h1 && h1r && h1r.width > 0 && h1r.top + SY < H * 0.95 ? h1 : null;
  if (!heroEl) {
    let best = null; const hw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
    for (let n = hw.nextNode(); n; n = hw.nextNode()) {
      const el = n.parentElement; if (!el || n.nodeValue.trim().length < 2 || el.closest('svg, nav, aside, [role="navigation"]')) continue;
      const r = el.getBoundingClientRect(); if (r.top + SY > H * 0.95 || r.width < 1 || (sidebar && r.left < sidebar.w - 2)) continue;
      const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || +cs.opacity < 0.05) continue;
      const size = parseFloat(cs.fontSize); if (!best || size > best.size) best = { el, size };
    }
    if (best) { heroEl = best.el; for (let p = heroEl; p && p !== B; p = p.parentElement) if (!/^inline/.test(getComputedStyle(p).display)) { heroEl = p; break; } }
  }
  let hero = null;
  if (heroEl) {
    const big = [...heroEl.querySelectorAll('*'), heroEl].map((e) => ({ e, s: parseFloat(getComputedStyle(e).fontSize) })).filter((o) => [...o.e.childNodes].some((c) => c.nodeType === 3 && c.nodeValue.trim())).sort((a, b) => b.s - a.s)[0]?.e || heroEl;
    const cs = getComputedStyle(big), size = parseFloat(cs.fontSize), txt = spaced(heroEl), r = heroEl.getBoundingClientRect();
    const tops2 = []; const lw = document.createTreeWalker(heroEl, NodeFilter.SHOW_TEXT); const lr = document.createRange();
    for (let n = lw.nextNode(); n; n = lw.nextNode()) { if (!n.nodeValue.trim()) continue; lr.selectNodeContents(n); for (const rc of lr.getClientRects()) { const mid = rc.top + rc.height / 2; if (rc.width >= 1 && !tops2.some((t) => Math.abs(t - mid) < size * 0.55)) tops2.push(mid); } }
    const lh = parseFloat(cs.lineHeight);
    hero = { text: txt.slice(0, 200), words: txt.split(' ').filter(Boolean).length, chars: txt.length, lines: Math.max(1, tops2.length), size: Math.round(size), vw: Math.round((size / W) * 1000) / 10, font: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), weight: +cs.fontWeight, tracking: Math.round(((parseFloat(cs.letterSpacing) || 0) / size) * 100) / 100, leading: Number.isFinite(lh) ? Math.round((lh / size) * 100) / 100 : 1.2, top: Math.round(r.top + SY), tag: heroEl.tagName.toLowerCase(), kind: 'heading', align: 'left', position: 'left' };
  }
  // charts and timelines on the screen (for the data-hue check): sizeable SVGs with marks, or blocks named as one
  const chartRe = /(^|[\s_-])(chart|plot|graph|timeline|calendar|cal|gantt|heatmap|matrix|strip)([\s_-]|$)/i;
  const chartCount = [...B.querySelectorAll('svg')].filter((s) => { if (s.parentElement && s.parentElement.closest('svg')) return false; const r = s.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && s.querySelectorAll('path, rect, line, polyline, circle').length >= 6 && shown(s); }).length
    + [...B.querySelectorAll('[class]')].filter((e) => typeof e.className === 'string' && chartRe.test(e.className) && !e.closest('svg') && (() => { const r = e.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && shown(e); })()).length;
  return {
    viewport: { w: W, h: H }, dark: lum(pageBg) < 0.2, ground: hex(pageBg),
    sidebar, topbar, canvas, hero, charts: chartCount,
    layout: { panels: tops.length, firstScreen, ragged: ragged.slice(0, 6), voids: voids.slice(0, 4) },
    tiles,
    panels: { count: panels.length, radii: [...radii.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), heavyBorders, borderSamples, heavyShadows, softShadows, shadowSamples, glass },
    chips: { count: chips.length, hues: chipHues.size, samples: chips.slice(0, 4) },
    numbers: { cells: cells.length, leftAligned: leftNums, figures: figures.length, tabularShare, kpiMax: figures.length ? Math.max(...figures.map((f) => f.size)) : null },
    rows: rows.length ? { count: rows.length, median: rows.sort((a, b) => a - b)[Math.floor(rows.length / 2)] } : null,
    chartHues: hues.size,
    placeholders,
    type: { under13, tiny, tinySamples, sizes: [...sizes.keys()].sort((a, b) => a - b) },
    title: h1 && h1r.width > 0 ? { text: (h1.innerText || '').trim().slice(0, 60), size: Math.round(parseFloat(getComputedStyle(h1).fontSize)), top: Math.round(h1r.top + SY) } : null,
  };
}

// Share of a screenshot's pixels that are strongly coloured (saturated, neither near-black nor near-white).
// The studied app screens keep it near 3%; pale tints barely count. Runs inside any page; takes a data: URL.
export async function colourShare(dataUrl) {
  const img = new Image(); img.src = dataUrl; await img.decode();
  const w = 240, h = Math.max(1, Math.round((240 * img.height) / img.width));
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
  const d = x.getImageData(0, 0, w, h).data; let n = 0;
  for (let i = 0; i < d.length; i += 4) {
    const r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
    const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn);
    if (s >= 0.4 && l >= 0.18 && l <= 0.82) n++;
  }
  return Math.round((n / (w * h)) * 1000) / 1000;
}

// Dominant colours of a screenshot. Runs inside any page; takes a data: URL.
export async function palette(dataUrl) {
  const img = new Image(); img.src = dataUrl; await img.decode();
  const w = 96, h = Math.max(1, Math.round((96 * img.height) / img.width));
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
  const d = x.getImageData(0, 0, w, h).data; const bins = new Map();
  for (let i = 0; i < d.length; i += 4) {
    const k = (d[i] >> 4) << 8 | (d[i + 1] >> 4) << 4 | (d[i + 2] >> 4);
    const b = bins.get(k) || [0, 0, 0, 0]; b[0] += d[i]; b[1] += d[i + 1]; b[2] += d[i + 2]; b[3]++; bins.set(k, b);
  }
  const total = w * h;
  return [...bins.values()].sort((a, b) => b[3] - a[3]).slice(0, 6).map((b) => ({
    color: '#' + [0, 1, 2].map((i) => Math.round(b[i] / b[3]).toString(16).padStart(2, '0')).join(''), share: Math.round((b[3] / total) * 100) / 100,
  }));
}
scripts/lib/rules.mjs›
// Turns probe() metrics into findings. Each finding: { level: 'FAIL'|'WARN', id, message, fix }.
// FAIL = a banned tell or a broken page; the work is not done while one remains.
// WARN = usually wrong; keep only with a reason you could say out loud to a designer.

const inRange = (h, a, b) => (a <= b ? h >= a && h <= b : h >= a || h <= b);

// Colour families this skill treats as generated-page signatures.
export function colourFamily(hsl) {
  if (!hsl) return null;
  const { h, s, l } = hsl;
  if (inRange(h, 22, 62) && s >= 0.16 && l >= 0.88 && l < 0.995) return 'cream';
  if (inRange(h, 6, 30) && s >= 0.38 && s <= 0.85 && l >= 0.42 && l <= 0.72) return 'terracotta';
  if (inRange(h, 244, 295) && s >= 0.42 && l >= 0.3 && l <= 0.8) return 'ai-purple';
  return null;
}

const hexToHsl = (hex) => {
  const n = parseInt(hex.slice(1), 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0;
  if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; }
  return { h: Math.round(h), s, l };
};

export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
  const out = [];
  const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
  const mobile = width < 600;

  // --- headlines
  // A title holds to two lines. A statement (a paragraph, quotation or list item set large) may run
  // longer, but only at statement size; set at display size it is a headline again. In blind tests jurors
  // read any large sentence of three lines or more as a long headline, so statement size is kept modest.
  const stmtMaxPx = width < 600 ? 24 : width < 1100 ? 32 : 36;
  const stmtMaxLines = width < 600 ? 4 : 3;
  const isStatement = (h) => h.kind === 'statement' && h.size <= stmtMaxPx;
  const long = m.headings.filter((h) => h.lines > 2 && !isStatement(h));
  if (long.length) {
    add('FAIL', 'headline-lines', `${long.length} headline(s) wrap past two lines at ${width}px: ` + long.slice(0, 4).map((h) => `"${h.text.slice(0, 48)}" → ${h.lines} lines (${h.words} words, ${h.size}px${h.kind === 'statement' ? ', a paragraph set above ' + stmtMaxPx + 'px' : ''})`).join('; '),
      'Cut words first (a headline is a title, not a sentence). Then give it a wider measure. Lower the size only as a last resort and never below the scale you chose. A real statement paragraph belongs in a <p> or <blockquote> at ' + stmtMaxPx + 'px or less.');
  }
  const longStatements = m.headings.filter((h) => isStatement(h) && h.lines > stmtMaxLines);
  if (longStatements.length) {
    add('WARN', 'statement-length', `${longStatements.length} large-set statement(s) run past ${stmtMaxLines} lines at ${width}px: ` + longStatements.slice(0, 3).map((h) => `"${h.text.slice(0, 40)}…" → ${h.lines} lines at ${h.size}px`).join('; '),
      'A statement set large is about 30 words. Cut it, or set the rest as ordinary body text.');
  }
  if (m.hero && m.hero.words > 10) add('WARN', 'hero-words', `Hero headline is ${m.hero.words} words ("${m.hero.text.slice(0, 60)}"). Gallery median is 5.`, 'Say the one thing. Move the rest to the supporting line.');
  if (m.headingSummary.mixedFontHeadings) add('WARN', 'mixed-font-headline', `${m.headingSummary.mixedFontHeadings} headline(s) switch typeface or to italic mid-line (the "one italic serif word" move).`, 'Set the whole headline in one face. Emphasis comes from the words.');

  // --- banned furniture
  if (m.eyebrows.length) {
    add('FAIL', 'eyebrow', `${m.eyebrows.length} eyebrow/kicker label(s) above headings: ` + m.eyebrows.slice(0, 5).map((e) => `"${e.text}"${e.pill ? ' (pill)' : ''} above "${e.above}"`).join('; '),
      'Delete them. If the label carried real information, fold it into the headline or the supporting sentence.');
  }
  if (m.type.monoProseShare > 0) {
    add('FAIL', 'mono', `Monospace type outside code: ${m.type.monoFamilies.join(', ')} — e.g. ${m.type.monoSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}`,
      'Set it in the text face. Monospace is for literal code inside <pre>/<code> only.');
  }
  if (m.numbered.length) {
    add('FAIL', 'numbering', `Decorative index numbers: ${m.numbered.slice(0, 6).map((n) => `"${n.text}"`).join(', ')}`,
      'Remove them. If order truly matters, write it into the words ("First, …") or use a real <ol>.');
  }
  if (m.type.microLabels >= 3) add('FAIL', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced labels (e.g. ${m.type.microSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}).`, 'Use sentence-case text at a readable size, or delete the label.');
  else if (m.type.microLabels > 0) add('WARN', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced label(s): ${m.type.microSamples.slice(0, 3).map((s) => `"${s}"`).join(', ')}`, 'Prefer sentence case at body size.');
  if (m.page.emoji.length) add('FAIL', 'emoji', `Emoji in the page: ${m.page.emoji.join(' ')}`, 'Remove. Use words or real imagery.');

  // --- surfaces
  const s = m.surfaces;
  if (s.gradients) add('FAIL', 'gradient', `${s.gradients} gradient fill(s): ${s.gradientSamples.slice(0, 3).join(' | ')}`, 'Use a flat colour or a real image. The only gradient allowed by default is a neutral scrim over a photo for legibility.');
  if (s.gradientText) add('FAIL', 'gradient-text', `${s.gradientText} gradient-filled text element(s).`, 'Solid colour text.');
  if (s.shadowStatic) add('FAIL', 'shadow', `${s.shadowStatic} box-shadow(s) on elements at rest: ${s.shadowSamples.slice(0, 3).join(' | ')}`, 'Remove. Separate surfaces with a tone step or space. Shadows are for things that really float (menus, dialogs, sticky bars).');
  if (s.textShadow) add('FAIL', 'text-shadow', `${s.textShadow} text-shadow(s).`, 'Remove; fix legibility with a scrim or a different crop.');
  if (m.buttons.withGradient || m.buttons.withShadow) add('FAIL', 'button-fx', `Buttons with ${m.buttons.withGradient ? 'gradient fill' : ''}${m.buttons.withGradient && m.buttons.withShadow ? ' and ' : ''}${m.buttons.withShadow ? 'shadow/glow' : ''}.`, 'Flat fill, one colour.');
  if (s.hairlines) add(s.hairlines >= 2 ? 'FAIL' : 'WARN', 'hairline', `${s.hairlines} thin rule(s)/divider line(s): ${s.hairlineSamples.slice(0, 4).join(' | ')}`, 'Delete. Separate with space, a tone change, or scale. Lines are only for real tables and form fields.');
  if (s.outlinedBoxes >= 3) add('FAIL', 'outlined-box', `${s.outlinedBoxes} boxes drawn with a 1px outline.`, 'Define a surface by its fill (one tone step from the page) or leave the content unboxed.');
  else if (s.outlinedBoxes) add('WARN', 'outlined-box', `${s.outlinedBoxes} box(es) drawn with a 1px outline.`, 'Prefer a filled surface or no box.');
  if (s.dashed) add('WARN', 'dashed', `${s.dashed} dashed/dotted border(s).`, 'Remove unless it is a real drop zone.');
  if (s.blurred > 1) add('WARN', 'glass', `${s.blurred} backdrop-blur surfaces.`, 'One blurred sticky bar at most; glass cards are a tell.');
  const iconRows = s.cardRows.filter((c) => c.iconTopped);
  if (iconRows.length) add('FAIL', 'icon-cards', `${iconRows.length} row(s) of identical cards each topped with a small icon (${iconRows.map((c) => c.count).join(', ')} cards).`, 'Replace with one composed section from the direction: a narrative split, a list set in large type, or real imagery per item.');
  else if (s.cardRows.length >= 2) add('WARN', 'card-rows', `${s.cardRows.length} rows of identical boxed cards.`, 'Vary the composition; not every section is a card grid.');
  if (m.nav?.floatingPill) add('WARN', 'pill-nav', 'Floating rounded pill navigation.', 'Use a plain bar: wordmark left, a few links, one action.');

  // --- colour
  const fams = new Map();
  const note = (label, hsl) => { const f = colourFamily(hsl); if (f) fams.set(f, [...(fams.get(f) || []), label]); };
  note('page background ' + m.color.bodyBg, m.color.bodyBgHsl);
  for (const sb of m.color.sectionBgs) note('section ' + sb.color, sb.hsl || hexToHsl(sb.color));
  if (m.color.button) note('button ' + m.color.button.bg, m.color.button.hsl || hexToHsl(m.color.button.bg));
  for (const t of m.color.textColors) if (t.share >= 0.01) note('text ' + t.color, hexToHsl(t.color));
  if (fams.has('terracotta') && fams.has('cream')) add('FAIL', 'palette-claude', `Cream + terracotta palette (${[...fams.get('cream'), ...fams.get('terracotta')].slice(0, 4).join(', ')}).`, 'Re-draw the palette from the library. This pairing reads as an assistant brand, not yours.');
  else if (fams.has('terracotta')) add('FAIL', 'palette-terracotta', `Terracotta / rust-orange accent (${fams.get('terracotta').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
  else if (fams.has('cream')) add('WARN', 'palette-cream', `Yellow-cream background (${fams.get('cream').slice(0, 3).join(', ')}).`, 'Use the drawn palette. If you want warmth, take a low-chroma stone or bone, not a yellow cream.');
  if (fams.has('ai-purple')) add('FAIL', 'palette-purple', `Indigo / violet (${fams.get('ai-purple').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
  if (m.color.textColors.some((t) => t.color === '#0000ee' || t.color === '#551a8b')) add('WARN', 'default-link', 'Browser-default blue/purple link colour is showing.', 'Style every link: inherit the text colour and mark it with weight, underline offset, or the accent.');
  const veryLow = m.color.lowContrast.filter((c) => c.ratio < 2.6);
  if (veryLow.length) add('FAIL', 'contrast', `Unreadable text: ` + veryLow.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 (${c.fg} on ${c.bg})`).join('; '), 'Raise contrast to at least 4.5:1 for text, 3:1 for large display.');
  else if (m.color.lowContrast.length) add('WARN', 'contrast', `Low-contrast text: ` + m.color.lowContrast.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 needs ${c.need}:1`).join('; '), 'Darken or lighten the text colour.');

  // --- type system
  const realFams = m.type.families.filter((f) => f.share >= 0.02);
  if (realFams.length > 3) add('FAIL', 'font-count', `${realFams.length} typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most: one display, one text. Often one is enough.');
  else if (realFams.length === 3) add('WARN', 'font-count', `3 typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most.');
  if (m.type.notLoaded.length) add('WARN', 'font-fallback', `Typeface(s) declared but not loaded, so a fallback is rendering: ${m.type.notLoaded.join(', ')}`, 'Fix the font URL / family name, or the screenshots you are judging are not the design.');
  if (m.type.sizeCount > 9) add('WARN', 'size-count', `${m.type.sizeCount} different font sizes (${m.type.sizes.join(', ')}).`, 'A page needs about five: display, heading, lead, body, small.');
  // Tiny text is the fault the study's best sites share (about a quarter of them); it is not copied here.
  if (m.type.body && m.type.body.size < 15) add('FAIL', 'body-size', `Body text is ${m.type.body.size}px (the size most of the running text is set in).`, 'Body copy 16–20px. Map small sizes from blueprints onto your body step.');
  else if (m.type.body && m.type.body.size < 16 && !mobile) add('WARN', 'body-size', `Body text is ${m.type.body.size}px.`, 'Body copy 16–20px.');
  if (m.type.body?.contrast && m.type.body.contrast < 7) add('WARN', 'grey-body', `The running text is a soft grey on its ground (${m.type.body.contrast}:1).`, 'Set body copy in the full ink (7:1 or more). Keep the muted ink for secondary notes; pages read thin and unfinished when everything is grey.');
  if (m.buttons.inHero === 0) add('WARN', 'first-action', `No visible button in the first ${mobile ? 'phone ' : ''}screen.`, mobile ? 'On a phone the first screen still holds the headline, the supporting sentence and the action; move navigation into one row or a menu so it does not push them down.' : 'Give the one primary action a real button in the first screen (a filled or solid-bordered control, 44px or taller), not only a link in the navigation.');
  // Models skip pictures when nothing makes them: the page must carry real ones (see SKILL.md, imagery).
  if (width >= 1100 && m.page.realPictures != null) {
    const pics = m.page.realPictures + m.page.uiPictures;
    if (pics < 4) add('FAIL', 'few-pictures', `Only ${pics} picture(s) on the page (${m.page.realPictures} photographs, footage or raster images, ${m.page.uiPictures} interface fragments). Drawings in SVG do not count.`, 'Find real pictures of the subject\'s world with scripts/assets.mjs (photographs, archival images, maps, engravings, footage), download them to assets/ and give them room: a hero picture, one per section that has a subject, a full-width band. At least four, usually six to twelve.');
    else if (m.page.realPictures < 2) add('WARN', 'no-real-pictures', `Only ${m.page.realPictures} real photograph(s) or clip(s); the rest are interface fragments.`, 'Add two or more real pictures of the customer\'s world from scripts/assets.mjs, not stock of people at laptops.');
    if (!m.page.videoCount) add('WARN', 'no-footage', 'No video anywhere on the page.', 'Look for one honest clip with scripts/assets.mjs --video (the place, the material, the process) and use it as a muted loop with a poster frame. Defend its absence only if no clip fits the subject.');
  }
  if (m.page.videosNoPoster) add('WARN', 'no-poster', `${m.page.videosNoPoster} clip(s) without a poster.`, 'Give every clip a poster frame: in the study of 100 Sites of the Day only 31% had one, and the blank or grey boxes before the clips played were recorded as faults (references/imagery.md §11).');
  if (m.page.sideScrollers) add('WARN', 'side-scroll', `${m.page.sideScrollers} row(s) scroll sideways with content past their edge.`, 'Jurors and most visitors read a row cut at the edge as clipped, not as scrollable. Wrap it onto more lines, or end it inside the grid.');
  if (m.page.blendText) add('WARN', 'blend-text', `${m.page.blendText} text element(s) use mix-blend-mode difference or exclusion.`, 'Over anything but black, white or grey, blended text takes random hues (blue on orange, lilac on green) that jurors read as a glitch. Use a solid ink chosen for the ground, or keep the blended text over neutral grounds only.');
  if (m.type.measureChars && m.type.measureChars > 88 && !mobile) add('WARN', 'measure', `Longest paragraph runs about ${m.type.measureChars} characters per line.`, 'Cap paragraphs at 45–75 characters (max-width: 60ch).');
  if (m.type.smallText > 3) add('FAIL', 'small-text', `${m.type.smallText} text blocks under 13px.`, 'Nothing meant to be read goes below 13px.');
  else if (m.type.smallText > 0) add('WARN', 'small-text', `${m.type.smallText} text block(s) under 13px.`, 'Nothing meant to be read goes below 13px.');
  if (m.type.centerShare > 0.72 && m.headings.length >= 4) add('WARN', 'all-centred', `${Math.round(m.type.centerShare * 100)}% of the text is centre-aligned.`, 'Centre one moment at most; anchor the rest to a shared left edge.');

  // --- breakage
  if (m.page.uiAbuse) add('FAIL', 'ui-wrapper', 'A data-ui element wraps a heading or more than a screen and a half of the page.', 'data-ui marks a cropped interface fragment only. Remove it from page-level wrappers.');
  if (m.page.horizontalOverflow) add('FAIL', 'overflow', `Page scrolls sideways by ${m.page.horizontalOverflow}px at ${width}px.${m.page.overflowCulprits?.length ? ' Widest offenders: ' + m.page.overflowCulprits.join(', ') + '.' : ''}`, 'Constrain the element that is wider than the viewport (max-width: 100%, or overflow: clip on its section).');
  if (m.page.brokenImages.length) add('FAIL', 'broken-image', `Broken image(s): ${m.page.brokenImages.slice(0, 4).join(', ')}`, 'Fix the URL or replace the asset.');
  if (!m.hero) add('WARN', 'no-hero', 'No headline found in the first viewport.', 'The first screen needs a clear focal statement or image.');
  // One dominant thing per first screen: large type, or a picture that holds a third of it, or (for software) an interface fragment.
  else if (!mobile && m.hero.vw < 4.5 && (m.heroMedia[0]?.ratio || 0) < 0.3 && !m.page.uiFragments) {
    add('FAIL', 'focal-point', `The first screen has no dominant element: the largest type is ${m.hero.size}px (${m.hero.vw}vw) and no single picture holds a third of the viewport.`, 'Make one thing lead: the headline at display size, or one large picture. A grid of many small pictures is not one thing; in blind tests jurors marked a first screen without a headline down every time. On the strongest studied sites the largest type has a median of 8.4vw.');
  }

  return out;
}

// The frame of the page, measured once per width by layout(): margins, balance, blank stretches, titles.
// These catch the failures a reader names first: "no clear title", "random empty space", "it is all
// pushed to one side", "text touching the edge".
export function judgeLayout(L, { width = L.viewport.w, allow = [] } = {}) {
  const out = [];
  const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
  const mobile = width < 600, H = L.viewport.h;
  // the outer frame: the two margins match (asymmetry belongs inside the grid, never in the frame)
  const { left, right } = L.margins;
  if (left != null && right != null) {
    const diff = Math.abs(left - right), wide = Math.max(left, right);
    if (diff >= Math.max(mobile ? 14 : 40, width * 0.045) && wide >= (mobile ? 28 : width * 0.07)) {
      add('FAIL', 'off-centre', `The page sits off-centre: its content comes to ${left}px from the left edge but only to ${right}px from the right, at ${width}px.`, `Give the page one pair of equal margins. Check the wrapper for a max-width without margin-inline: auto, a grid with an empty last column, or padding on one side only. If one side is meant to hold air, it must still be closed by something that reaches the margin (a picture, the navigation's last item, a figure).`);
    } else if (diff >= (mobile ? 12 : 16)) {
      // strong sites hold their two margins within a few pixels; a 20-30px drift is the "shifted" look
      add('WARN', 'off-centre', `The margins differ: ${left}px on the left, ${right}px on the right, at ${width}px.`, 'Put the navigation and the content on one pair of margins; check for padding on one side only, a scrollbar gutter, or a header on another inset than the page.');
    }
  }
  // sections whose content huddles on one side, leaving the other half of the screen empty
  if (!mobile && left != null) {
    const lop = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.left != null && s.words >= 12 && s.height >= 180 && Math.min(s.left, s.right) <= Math.max(left, right) + 24 && Math.max(s.left, s.right) >= width * 0.42);
    if (lop.length >= 2) add('FAIL', 'lopsided', `${lop.length} sections keep all their content on one side and leave ${Math.round(Math.max(...lop.map((s) => Math.max(s.left, s.right))) / width * 100)}% of the width empty: ` + lop.slice(0, 4).map((s) => `${s.heading ? '"' + s.heading.text + '"' : s.sel} (${s.right > s.left ? 'right' : 'left'} ${Math.max(s.left, s.right)}px empty)`).join('; '), 'Use the whole width the page has set up: put the section\'s picture, its second column, its list or its figure in the empty half, or set the text larger across the grid. Empty space must be placed, with something on each side of it.');
    else if (lop.length) add('WARN', 'lopsided', `A section keeps its content on one side and leaves ${Math.max(lop[0].left, lop[0].right)}px of the width empty: ${lop[0].heading ? '"' + lop[0].heading.text + '"' : lop[0].sel}.`, 'Fill the other side with what the section has (its picture, a second column, the figure), or defend the void in one sentence.');
    // the same hole with the empty part in the middle: a card at the left, a lone button at the right
    const hollow = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.words >= 12 && s.middle >= width * 0.45);
    if (hollow.length) add('WARN', 'empty-middle', `${hollow.length} section(s) have content at both edges and ${Math.max(...hollow.map((s) => s.middle))}px of nothing between: ` + hollow.slice(0, 3).map((s) => s.heading ? '"' + s.heading.text + '"' : s.sel).join('; '), 'Bring the two parts together on the grid, or put the picture or figure of the section in the middle. An empty middle third reads as a hole, the same as an empty half.');
  }
  // blank stretches: nothing at all for a long run down the page
  const big = L.gaps.filter((g) => g.height >= H * 0.6), mid = L.gaps.filter((g) => g.height >= H * 0.42 && g.height < H * 0.6);
  if (big.length || mid.length >= 2) add(L.stage ? 'WARN' : 'FAIL', 'blank-gap', `${big.length + mid.length} stretch(es) of the page hold nothing at all: ` + [...big, ...mid].slice(0, 4).map((g) => `${g.height}px at ${g.top}px`).join('; ') + ` (the screen is ${H}px tall).`, (L.stage ? 'A fixed picture layer covers the screen here, so the DOM cannot tell whether the stretch is empty: check the screenshots. ' : '') + 'Close the gap: a section gap is 120–240px on desktop and 72–112px on a phone. Look for a min-height: 100vh on a short section, an empty grid row, a hidden element still holding its space, or a reveal that never fired. A deliberate rest is a placed picture or colour field, not nothing.');
  else if (mid.length) add('WARN', 'blank-gap', `A ${mid[0].height}px stretch at ${mid[0].top}px holds nothing.`, 'Check that it is a deliberate rest (once per page at most); otherwise close it to the normal section gap.');
  // the title: one h1, on the first screen, that says what this is
  if (!L.h1s.length) add('FAIL', 'no-title', 'The page has no visible h1.', 'Give the first screen a clear title in an h1 that says what this is (a name with what it does, the product with what it is for, the event with its date and place).');
  else {
    const first = L.h1s[0];
    if (!first.firstScreen) add('FAIL', 'no-title', `The h1 ("${first.text.slice(0, 50)}") is not on the first screen (it starts ${first.top}px down).`, 'The title belongs on the first screen, where a visitor decides whether to stay.');
    else if (first.size < (mobile ? 26 : 32)) add('WARN', 'small-title', `The h1 is ${first.size}px ("${first.text.slice(0, 50)}").`, 'Set the title at display size, or make the large text of the first screen the h1.');
    if (first.firstScreen && first.text.split(' ').length <= 3 && L.explains === false) add('WARN', 'name-only-title', `The h1 is a bare name ("${first.text.slice(0, 40)}") and nothing else on the first screen says what this is.`, 'Keep the name if it leads, and give the line under it (18px or more) the plain words: what it is, for whom, where or when. In the study of 97 Sites of the Day, first screens that named nothing lost usability and content points.');
    if (L.h1s.length > 1) add('WARN', 'many-h1', `${L.h1s.length} h1 elements.`, 'One h1 per page; section titles are h2.');
  }
  // sections with running text but no heading: the reader cannot tell what they are
  const untitled = L.sections.filter((s) => !s.hero && !s.footer && !s.continues && !s.heading && s.words >= 30 && s.height >= 200);
  if (untitled.length >= 2) add('FAIL', 'untitled-section', `${untitled.length} sections carry text but no heading: ` + untitled.slice(0, 4).map((s) => `${s.sel} at ${s.top}px (${s.words} words)`).join('; '), 'Give each section a short heading that names what it holds (the menu, the rooms, how it works, the price), in the heading face at the section size.');
  else if (untitled.length) add('WARN', 'untitled-section', `A section carries text but no heading: ${untitled[0].sel} at ${untitled[0].top}px.`, 'Name it with a short heading unless it is the continuation of the section above.');
  // the first screen: a large empty rectangle that nothing closes reads as "random empty space"
  if (!mobile && L.voidShare >= 0.28 && !L.stage) add('WARN', 'hero-void', `${Math.round(L.voidShare * 100)}% of the first screen is one empty block (${L.voidBox.w}×${L.voidBox.h}px at ${L.voidBox.x},${L.voidBox.y}).`, 'Place the void on purpose or fill it: move the picture, the film or one fact into it, grow the headline to meet it, or shrink the screen to its content. A void reads as placed only when something closes it on the far side and it is the screen\'s one rest.');
  // display type that starts inside the margin but runs off the opposite edge by a sliver: a fit error
  if (L.slivers && L.slivers.length) add('WARN', 'one-sided-bleed', `${L.slivers.length} display line(s) start inside the margin but run off the ${L.slivers[0].side} edge by a few pixels: ` + L.slivers.map((t) => `"${t.text}" (${t.px}px)`).join('; '), 'Fit the line to the content box by measurement (a small fit script, or SVG text with textLength), or bleed it on purpose by a clear amount from the margin. A name sliced by a few pixels on one side reads as a mistake.');
  // text pressed against the screen edge
  const tight = L.edgeText.filter((t) => t.px < (mobile ? 12 : 16)), near = mobile ? L.edgeText.filter((t) => t.px >= 12) : [];
  if (near.length && !tight.length) add('WARN', 'edge-text', `${near.length} text block(s) sit 12–15px from the phone's edge: ` + near.slice(0, 3).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px)`).join('; '), 'The page margin is 16–20px on a phone; the header and the corner labels sit on it too.');
  if (tight.length) add('FAIL', 'edge-text', `${tight.length} text block(s) sit within ${mobile ? 12 : 16}px of the screen edge: ` + tight.slice(0, 4).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px from the ${t.side})`).join('; '), `Keep text inside the page margin (${mobile ? '16–20px' : '16–32px or more'}). Only display type deliberately bled past the edge may touch it.`);
  // too many left edges: nothing lines up
  if (!mobile && L.edges.length > 6 && L.leftBlocks >= 8) {
    const top3 = L.edges.slice(0, 3).reduce((a, e) => a + e.n, 0) / L.leftBlocks;
    if (top3 < 0.6) add('WARN', 'ragged-edges', `Left-aligned text starts at ${L.edges.length} different lines (${L.edges.slice(0, 7).map((e) => e.x + 'px').join(', ')}); the three most used hold only ${Math.round(top3 * 100)}% of it.`, 'Pick two or three anchor lines from the grid and start every heading and paragraph on one of them.');
  }
  return out;
}

// App screens (dashboards, admin, settings, inboxes) keep conventions a marketing page does not: tables
// and panels may have quiet borders, labels are small, cards repeat, there is no hero, no picture floor
// and no motion floor. These page rules do not apply to them; references/app-screens.md has the reasons.
export const APP_SKIP = new Set(['hero-words', 'eyebrow', 'mono', 'name-only-title', 'empty-middle', 'numbering', 'hairline', 'outlined-box', 'dashed', 'icon-cards', 'card-rows', 'pill-nav', 'grey-body', 'measure', 'first-action', 'few-pictures', 'no-real-pictures', 'no-footage', 'focal-point', 'no-hero', 'body-size', 'small-text', 'statement-length', 'shadow', 'glass', 'still-page', 'empty-screens', 'long-pin', 'side-scroll', 'pinned-over-picture', 'off-centre', 'lopsided', 'ragged-edges', 'untitled-section', 'size-count', 'all-centred']);

export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
  const out = [];
  const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
  const mobile = width < 600;
  if (mobile && A.sidebar && A.sidebar.share > 0.3) add('FAIL', 'sidebar-phone', `The sidebar still takes ${Math.round(A.sidebar.share * 100)}% of the width on a ${width}px phone.`, 'On a phone the navigation folds into a top bar with a menu button or a bottom bar of four or five destinations; the screen\'s content gets the full width.');
  if (!mobile && width >= 1100 && A.canvas && A.canvas.right > Math.max(120, width * 0.1) && A.canvas.right > A.canvas.left + 80) add('WARN', 'unused-canvas', `The content stops ${A.canvas.right}px short of the right edge while it starts ${A.canvas.left}px from the sidebar.`, 'Let the grid fill the canvas (columns in fr units), or centre a capped canvas with equal margins. A dead strip on one side reads as unfinished.');
  if (A.panels.heavyShadows) add('FAIL', 'heavy-shadow', `${A.panels.heavyShadows} panel shadow(s) heavy enough to read as floating: ${A.panels.shadowSamples.join(' | ')}`, 'Panels sit on the ground: separate them with a fill one step from the ground or a quiet 1px border. A shadow, if any, is under 8% black with a small blur. Real overlays (menus, dialogs, popovers) may float.');
  if (A.panels.glass) add('FAIL', 'glass', `${A.panels.glass} blurred glass panel(s).`, 'Opaque surfaces only.');
  if (A.panels.heavyBorders >= 3) add('WARN', 'heavy-borders', `${A.panels.heavyBorders} panel borders are dark against their ground: ${A.panels.borderSamples.join(' | ')}`, 'A panel border is a whisper: about 1.1–1.3:1 against its ground on a light screen (#e5e7eb–#ededed on white), and about 1.2–1.5:1 on a dark one (#1a1a1a on #000, #2e2e2e on #111). Darker lines turn the screen into a grid of boxes; keep the stronger line for controls.');
  const radii = A.panels.radii.filter(([, n]) => n >= 2).map(([r]) => r);
  if (radii.length > 3) add('WARN', 'radius-mix', `Panels use ${radii.length} corner radii (${radii.join(', ')}px).`, 'One radius for panels, one for controls, full round only for pills and avatars.');
  if (A.chips.count >= 3) add(A.chips.hues >= 2 ? 'FAIL' : 'WARN', 'icon-chips', `${A.chips.count} icons sit in tinted squares or circles${A.chips.hues >= 2 ? ` in ${A.chips.hues} different colours` : ''}.`, 'This is the commonest tell of a generated dashboard. Drop the tinted square: a KPI is a label and a number; an icon, if it helps, is plain and in the text colour.');
  if (A.numbers.cells >= 6 && A.numbers.leftAligned / A.numbers.cells >= 0.5) add('WARN', 'numeric-align', `${A.numbers.leftAligned} of ${A.numbers.cells} numeric table cells are left-aligned.`, 'Right-align numbers, amounts and percentages (and their column headers) so the digits line up.');
  if (A.numbers.figures >= 6 && A.numbers.tabularShare != null && A.numbers.tabularShare < 0.5) add('WARN', 'tabular', `Only ${Math.round(A.numbers.tabularShare * 100)}% of the figures use tabular numerals.`, 'Set font-variant-numeric: tabular-nums on tables, KPIs and anything that updates, so digits keep their width.');
  if (A.chartHues > 6) add('FAIL', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'One hue for the series that matters, a neutral for the rest, and at most four series colours in total; status colours only for status.');
  else if (A.chartHues > 4) add('WARN', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'Four series colours at most; prefer one accent and neutrals.');
  if (A.colourShare != null && A.colourShare > 0.12) add('WARN', 'colour-sprawl', `${Math.round(A.colourShare * 100)}% of the first screen is strongly coloured.`, 'Keep the screen neutral and spend colour on data and status: the studied screens sit near 3%, and only tinted styles reach 10–15%, as pale tints rather than saturated fills.');
  if (A.placeholders.length) add('FAIL', 'placeholder-data', `Placeholder data on screen: ${A.placeholders.join(', ')}.`, 'Use believable data for this product: real-looking names, amounts that add up, dates in order, units on every figure.');
  if (A.type.tiny > 2) add('FAIL', 'tiny-text', `${A.type.tiny} text blocks under 11px: ${A.type.tinySamples.join(', ')}.`, 'Nothing below 11px; secondary text 12px, data 13–14px.');
  else if (A.type.under13 > 0.45) add('WARN', 'tiny-text', `${Math.round(A.type.under13 * 100)}% of the text is under 13px.`, 'Data and labels read at 13–14px; keep 11–12px for sublines and axis ticks.');
  if (!mobile && A.layout) {
    if (A.layout.ragged.length) add('WARN', 'ragged-row', `${A.layout.ragged.length} pair(s) of cards in one row end at different heights (by ${A.layout.ragged.map((r) => r.diff + 'px').slice(0, 3).join(', ')}).`, 'Cards that share a row share a bottom edge: balance them by their content (the same number of rows, a block of similar height beside it), or stack them. In testing, ragged rows were marked down every time.');
    if (width >= 1100 && A.layout.panels >= 4 && A.layout.firstScreen / A.layout.panels < 0.5) add('WARN', 'below-fold', `Only ${A.layout.firstScreen} of ${A.layout.panels} blocks start on the first ${width}px screen.`, 'Every block the brief names shows its header and first rows on the first desktop screen: size the headline block to its question, cap long lists with "View all", and put time-sensitive blocks before long tables.');
    if (width >= 1100 && A.layout.voids?.length) add('WARN', 'panel-void', `${A.layout.voids.length} card(s) hold a blank band inside: ${A.layout.voids.slice(0, 3).map((v) => `${v.gap}px in "${v.panel}"`).join(', ')}.`, 'A card stretches to its neighbour only when it has content to fill. Keep a legend or a note directly under its chart (only an action or a total may sit at a stretched card\'s foot), let a short card keep its own height (align-items: start), and let a filtered block shrink to its rows.');
  }
  // an achromatic screen with a chart or a timeline and no hue on any data reads as a kit: the one chart hue goes on
  // the main data element (today's line, the booked bars, the margin that breaks)
  if (!mobile && A.charts > 0 && A.chartHues === 0 && A.colourShare != null && A.colourShare < 0.002) add('WARN', 'no-data-hue', `The screen has ${A.charts === 1 ? 'a chart' : A.charts + ' charts or timelines'} but no hue on any of its data (${(A.colourShare * 100).toFixed(1)}% coloured pixels).`, 'Put the style\'s one chart hue (or the brand\'s) on the screen\'s main data element: today\'s line, the booked bars, the current period, the cover margin. Keep the chrome neutral; the hue still stays under the style\'s colour budget.');
  // in app mode the screen's hero is its h1 or the largest text on the first screen, not the page probe's guess
  // (which can be a figure below the fold read without its spaces); the report and the MEASURED line use this one
  if (A.hero && m) m.hero = A.hero;
  if (A.title && A.title.size > (mobile ? 28 : 34)) add('WARN', 'app-title-size', `The h1 is ${A.title.size}px ("${A.title.text.slice(0, 40)}").`, 'An app screen is not a landing page: the title is 18-32px (20-24px on a phone).');
  if (A.numbers.kpiMax && A.numbers.kpiMax >= 48) add('WARN', 'kpi-size', `A figure is set at ${Math.round(A.numbers.kpiMax)}px.`, 'Figures run 16-40px on app screens; none of the studied screens reaches 48px. Size and isolation make a number loud, not display type.');
  if (A.type.sizes.length > 10) add('WARN', 'size-count', `${A.type.sizes.length} font sizes (${A.type.sizes.join(', ')}).`, 'App screens use 4-9 sizes (median 6): a scale such as 12/14/16/24 with one figure size.');
  if (A.tiles >= 4) add('WARN', 'icon-tiles', `${A.tiles} icons sit in neutral boxes repeated down the screen.`, 'A boxed icon on every row or card is still the template habit, tinted or not. Keep one plain 16px icon where it helps the eye, or none.');
  if (!A.title) add('FAIL', 'no-title', 'The screen has no h1.', 'Name the screen in an h1 at the top of the canvas (18–28px): "Overview", "Payouts", the project\'s name.');
  if (m && m.type && m.type.monoProseShare > 0.15) add('WARN', 'mono', `Monospace is ${Math.round(m.type.monoProseShare * 100)}% of the text.`, 'Monospace for IDs, keys and code only; numbers use tabular figures in the text face.');
  return out;
}

export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy'];
scripts/lint.mjs›
#!/usr/bin/env node
// tasteskill / lint — static scan of source files for the tells this skill bans.
// Catches them before a render, plus the ones a render cannot see (copy habits, motion values).
//
//   node lint.mjs [files or directories ...] [--app] [--json]
//
// Exit code 1 when any FAIL is found. Zero dependencies.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname, relative, resolve } from 'node:path';

const args = process.argv.slice(2);
const asJson = args.includes('--json');
// App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels
// and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself.
let appMode = args.includes('--app');
try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {}
const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null };
const roots = args.filter((a) => !a.startsWith('--'));
if (!roots.length) roots.push('.');
const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']);
const SKIP = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', 'dist', 'build', 'out', '.vercel', '.taste-skill', 'coverage', '.turbo', 'vendor']);

const files = []; const tooBig = [];
const walk = (p) => {
  let st; try { st = statSync(p); } catch { return; }
  if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); }
  else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
};
for (const r of roots) {
  try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); }
  walk(resolve(r));
}
if (!files.length) { console.error(`No source files (${[...EXT].join(' ')}) under: ${roots.join(', ')}`); process.exit(2); }
if (tooBig.length) console.error(`Skipped (over 4 MB, check by hand): ${tooBig.join(', ')}`);

// Each rule: [level, id, regex, message]. Tested per line. `code` lines (inside <pre>/<code>) skip the mono rule.
const R = [
  ['FAIL', 'mono', /\bfont-mono\b|font-family\s*:[^;{}]*(mono\b|monospace|Courier|Consolas|Menlo|Monaco)|(JetBrains|Geist|IBM\+?Plex|Space|DM|Roboto|Fragment|Azeret|Red\+?Hat|Martian|Commit|Departure|Fira)[+_ ]?Mono|Geist_Mono|GeistMono|Fira[+ ]Code|ui-monospace/i, 'Monospace type. Only literal code may be monospace.'],
  ['FAIL', 'gradient', /(linear|radial|conic)-gradient\(|\bbg-(gradient|linear|radial|conic)-|\b(from|via)-(?!\[?0)[a-z]+-\d{2,3}\b/, 'Gradient fill. Use a flat colour or a real image (a neutral scrim over a photo is the one exception).'],
  ['FAIL', 'gradient-text', /bg-clip-text|background-clip\s*:\s*text|text-transparent/, 'Gradient / clipped text. Use solid colour.'],
  ['FAIL', 'shadow', /box-shadow\s*:\s*(?!none|0 0 #0000|var\(--tw)|\bshadow(-(sm|md|lg|xl|2xl|inner|\[[^\]]+\]))?(?=["'\s`])(?<!-shadow)|drop-shadow\(|\bdrop-shadow(-|\b)|text-shadow\s*:\s*(?!none)/, 'Shadow. Elements at rest carry none; only real overlays (menu, dialog, sticky bar) may.'],
  ['FAIL', 'numbering', />\s*[(\[]?0[1-9][)\]]?\.?\s*<|["'`]\s*0[1-9]\s*["'`]|decimal-leading-zero|>\s*(Step|Phase|No\.?|Chapter)\s*0?\d\s*</i, 'Decorative index numbering (01 / 02 / 03).'],
  ['WARN', 'numbering', /padStart\(\s*2\s*,\s*["']0["']\s*\)/, 'Zero-padding in script. Fine for a clock, a date or a price; if it builds 01 / 02 / 03 labels, remove it.'],
  ['FAIL', 'eyebrow', /class(Name)?\s*=\s*["'`{][^"'`}]*\b(eyebrow|kicker|overline|pretitle|pre-title|suptitle|sup-title|tagline-label|section-label|section-tag)\b|\.(eyebrow|kicker|overline|pretitle|section-label|section-tag)\b\s*[{,]/i, 'Eyebrow / kicker label above a heading.'],
  ['FAIL', 'micro-label', /(?=[^"'`]*\buppercase\b)(?=[^"'`]*\btracking-(wide|wider|widest|\[[^\]]+\])\b)(?=[^"'`]*\btext-(xs|\[1[0-3]px\]|\[0?\.[67]\d*rem\])\b)[^"'`]+/, 'Tiny uppercase letter-spaced label.'],
  ['FAIL', 'purple', /\b(bg|text|from|to|via|border|ring|fill|stroke|shadow|decoration|outline)-(purple|violet|indigo|fuchsia)-\d{2,3}\b|#(6366f1|8b5cf6|7c3aed|a855f7|4f46e5|a78bfa|818cf8|9333ea|6d28d9|c084fc)\b/i, 'Indigo / violet — the generated-page accent.'],
  ['FAIL', 'claude-palette', /#(d97757|da7756|c15f3c|cc785c|bd5d3a|f0eee6|faf9f5|f5f4ed|eeece2|e8e6dc)\b/i, 'Claude-brand cream / terracotta colour.'],
  ['FAIL', 'emoji', /[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}]/u, 'Emoji used as UI.'],
  ['FAIL', 'lorem', /lorem ipsum|dolor sit amet/i, 'Placeholder copy shipped.'],
  ['FAIL', 'hairline', /<hr\b|\bdivide-[xy](-\d|\b)|\b(h|w)-px\b|border-(top|bottom|left|right)\s*:\s*(0?\.5|1)px|\bborder-[tblrxy](?![-\w])|\bborder-[tblrxy]-(?!0\b|none)\[?1?(px)?\]?(?=["'\s`])|height\s*:\s*1px/, 'Thin rule / divider line. Separate with space, tone or scale; lines belong to real tables and form fields.'],
  ['WARN', 'outlined-box', /border\s*:\s*1px\s+solid|\bborder\b(?=[^"'`]*\brounded)/, 'A 1px outlined box. Prefer a filled surface or no box.'],
  ['WARN', 'glass', /backdrop-blur|backdrop-filter\s*:/, 'Backdrop blur. One sticky bar at most; glass cards are a tell.'],
  ['WARN', 'copy', /\b(unlock|unleash|supercharge|seamless(ly)?|elevate|empower(s|ing)?|revolutioni[sz]e|game[- ]chang(er|ing)|cutting[- ]edge|next[- ]gen(eration)?|world[- ]class|best[- ]in[- ]class|all[- ]in[- ]one|effortless(ly)?|blazing(ly)?[- ]fast|lightning[- ]fast|reimagin(e|ed|ing)|delve|tapestry|leverage|synergy|streamline|robust|holistic|turbocharge|transform the way|take your .{1,30} to the next level|say goodbye to|in today'?s|the future of|built for the modern|not just .{1,40}[,—-] (it'?s|but)|more than (just )?a)\b/i, 'Stock marketing phrase. Say the specific thing the product does.'],
  ['WARN', 'cta-copy', />\s*(Learn more|Get started|Click here|Read more|Discover more|Explore now|Submit)\s*</i, 'Generic button label. Name the action or the thing ("Book a table", "See the plans").'],
  ['WARN', 'default-font', /family=(Inter|Poppins|Montserrat|Roboto|Open\+Sans|Space\+Grotesk|Instrument\+Serif|Playfair\+Display|Plus\+Jakarta\+Sans|Outfit|Sora|Lato|Nunito|Raleway)\b|font-family\s*:\s*["']?(Inter|Poppins|Montserrat|Roboto|Space Grotesk|Instrument Serif|Playfair Display|Plus Jakarta Sans|Outfit|Sora)\b|from ["']next\/font\/google["'].*\b(Inter|Poppins|Montserrat|Space_Grotesk|Instrument_Serif|Playfair_Display|Plus_Jakarta_Sans|Outfit|Sora)\b|\b(Inter|Poppins|Space_Grotesk|Instrument_Serif|Playfair_Display)\s*\(\s*\{/, 'A default typeface. Use the faces named by the drawn direction.'],
  ['WARN', 'transition-all', /transition\s*:\s*all\b|\btransition-all\b/, 'transition: all. Name the properties (transform, opacity).'],
  ['WARN', 'ease-in', /(?<![-\w])ease-in(?!-out)\b/, 'ease-in on UI feels sluggish. Use a strong ease-out for enters and exits.'],
  ['WARN', 'scale-zero', /scale\(\s*0\s*\)|\bscale-0\b/, 'Entering from scale(0). Start at 0.95–0.98 with opacity 0.'],
  ['WARN', 'h-screen', /\bh-screen\b|height\s*:\s*100vh/, '100vh jumps on mobile. Use 100svh / 100dvh.'],
  ['WARN', 'bounce', /animate-(bounce|ping|pulse)\b/, 'Looping attention animation (bounce / ping / pulse dot).'],
];
if (appMode) {
  for (const r of R) if (r[1] in APP_LEVEL) r[0] = APP_LEVEL[r[1]];
  R.push(['FAIL', 'placeholder-data', /\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bexample\.com\b|\bFirstname Lastname\b|\b123 Main St\b/, 'Placeholder data. Use believable names, amounts that add up and real units for this product.']);
  R.push(['WARN', 'framework-colour', /#(3b82f6|2563eb|1d4ed8|60a5fa|10b981|22c55e|ef4444|f59e0b)\b/i, 'A framework default colour (Tailwind blue, green, red, amber). Take the accent and status colours from the dealt style.']);
}

const findings = [];
let emDashes = 0;
for (const file of files) {
  const rel = relative(process.cwd(), file).split('\\').join('/');
  const lines = readFileSync(file, 'utf8').split(/\r?\n/);
  let inCode = false; let selector = ''; let pending = '';
  lines.forEach((line, i) => {
    // track the CSS selector we are inside, so rules scoped to [data-ui] (interface fragments) keep their own lines
    if (line.includes('{')) { selector = pending + line.slice(0, line.indexOf('{')); pending = ''; } else if (!line.includes('}')) pending = (pending + ' ' + line).slice(-300);
    const inUi = /data-ui/.test(selector) || /data-ui/.test(line);
    if (line.includes('}') && !line.includes('{')) { selector = ''; pending = ''; }
    if (/<(pre|code)\b/i.test(line)) inCode = true;
    const codeLine = inCode || /<(pre|code|kbd|samp)\b/i.test(line);
    if (/<\/(pre|code)>/i.test(line)) inCode = false;
    if (line.length > 2000) return; // minified or data
    if (/taste-skill-ignore/.test(line)) return;
    if (!/^\s*(\/\/|\/\*|\*|<!--)/.test(line)) emDashes += (line.replace(/\/\/.*$|\/\*.*?\*\/|<!--.*?-->/g, '').match(/—/g) || []).length; // comments are not copy
    for (const [level, id, re, message] of R) {
      if (!level) continue;
      if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
      if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
      if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
      if (id === 'hairline' && /sr-only|visually-hidden|clip(-path)?\s*:|outline/i.test(line + selector)) continue; // screen-reader helpers and focus outlines are not rules
      if ((id === 'copy' || id === 'cta-copy' || id === 'emoji') && /^\s*(\/\/|\/\*|\*|import |@import|<!--)/.test(line)) continue;
      const m = line.match(re);
      if (m) findings.push({ level, id, file: rel, line: i + 1, match: m[0].trim().slice(0, 70), message });
    }
  });
}
if (emDashes > 4) findings.push({ level: 'WARN', id: 'em-dash', file: '(all files)', line: 0, match: `${emDashes} em dashes`, message: 'Em-dash habit. Use a full stop or a comma; keep at most one or two on a page.' });

if (asJson) { console.log(JSON.stringify({ files: files.length, findings }, null, 1)); process.exit(findings.some((f) => f.level === 'FAIL') ? 1 : 0); }

const byId = new Map();
for (const f of findings) { const k = f.level + ' ' + f.id; if (!byId.has(k)) byId.set(k, []); byId.get(k).push(f); }
console.log(`TASTESKILL · LINT   ${files.length} file(s)`);
if (!findings.length) console.log('\nNo banned patterns in source. Now run inspect.mjs on the rendered page.');
for (const lvl of ['FAIL', 'WARN']) {
  const keys = [...byId.keys()].filter((k) => k.startsWith(lvl));
  if (!keys.length) continue;
  console.log(`\n${lvl}`);
  for (const k of keys) {
    const list = byId.get(k);
    console.log(`  [${list[0].id}] ${list[0].message}  (${list.length}×)`);
    for (const f of list.slice(0, 6)) console.log(`      ${f.file}:${f.line}  ${f.match}`);
    if (list.length > 6) console.log(`      … ${list.length - 6} more`);
  }
}
const fails = findings.filter((f) => f.level === 'FAIL').length;
console.log(`\nRESULT: ${fails} FAIL, ${findings.length - fails} WARN${fails ? ' — fix the FAILs before rendering.' : ''}`);
console.log('Interface fragments marked data-ui keep their own borders and shadows. Any other line that must keep a flagged pattern for a real reason can carry the comment taste-skill-ignore.');
process.exit(fails ? 1 : 0);
scripts/scenes.mjs›
#!/usr/bin/env node
// tasteskill / scenes — photograph every scroll scene on a page at exact points of its progress.
//
//   node scenes.mjs <url | path/to/file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]
//
// For each <section class="scene"> (references/scenes.md): the screen before it, 0, 10, … 100% of its
// hold, and the screen after it, on desktop (1440x900) and on a phone (390x844), as labelled contact
// sheets; then the reduced-motion page screen by screen. Prints each scene's height in screens, frame
// timing while the page is wheeled through its scenes, and console errors. Read every sheet it writes.
//
// Exit code 0 = every scene ran; 1 = a scene never started, fell back to rest on desktop, or the page
// logged errors. Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
import { existsSync, mkdirSync, statSync, createReadStream } from 'node:fs';
import { resolve, join, dirname, extname, sep, relative } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';

const args = process.argv.slice(2);
const flag = (k, d) => { const i = args.indexOf('--' + k); if (i === -1) return d; const v = args[i + 1]; args.splice(i, 2); return v; };
const outDir = resolve(flag('out', '.taste-skill/scenes'));
const STEPS = Math.max(2, +flag('steps', 10)), WAIT = +flag('wait', 900);
const only = flag('scene', '');
const target = args[0];
if (!target || target.startsWith('--')) { console.error('usage: node scenes.mjs <url | file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]'); process.exit(2); }

// A local file is served from 127.0.0.1 (the working directory when the file is inside it, else the
// file's folder), so relative assets and CDN scripts behave as they will when the page is hosted.
let url = target, server = null;
if (!/^https?:\/\//.test(target)) {
  const file = resolve(target);
  if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
  const cwd = resolve('.');
  const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
  const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff': 'font/woff', '.woff2': 'font/woff2' };
  server = createServer((req, res) => {
    try {
      let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
      if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
      if (statSync(f).isDirectory()) f = join(f, 'index.html');
      res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
      createReadStream(f).pipe(res);
    } catch { res.writeHead(404); res.end('not found'); }
  });
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
  url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
mkdirSync(outDir, { recursive: true });
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const problems = [];

async function sheet(frames, labels, cellW, cellH, cols, path) {
  const rows = Math.ceil(frames.length / cols);
  await helper.setViewport({ width: cols * cellW, height: rows * (cellH + 22), dsf: 1 });
  await helper.eval((a) => {
    document.body.style.cssText = `margin:0;background:#777;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px);font:12px/22px system-ui;color:#fff`;
    document.body.innerHTML = a.frames.map((s, i) => `<figure style="margin:0"><img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #777"><figcaption style="padding-left:6px;background:#111">${a.labels[i]}</figcaption></figure>`).join('');
    return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
  }, { frames, labels, cellW, cellH, cols });
  await helper.screenshot({ path, quality: 78 });
}

const SCENES = `[...document.querySelectorAll(${JSON.stringify(only || 'section.scene, .scene[data-scene]')})].map((el, i) => { const r = el.getBoundingClientRect(); return { id: el.id || el.dataset.scene || ('scene-' + (i + 1)), top: Math.round(r.top + scrollY), height: Math.round(r.height), live: el.classList.contains('is-live'), rest: el.classList.contains('is-rest') }; })`;
const go = (y) => `(() => { const y = ${y}; if (window.Stage && Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true }); else window.scrollTo(0, y); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); })()`;
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');

async function run(label, vp) {
  const page = await browser.newPage(vp);
  await page.goto(url, { timeoutMs: 40000, settleMs: 1200 });
  await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
  await sleep(500);
  const scenes = await page.eval(SCENES);
  if (!scenes.length) console.log(`${label}: no <section class="scene"> on the page`);
  const H = vp.height;
  for (const sc of scenes) {
    if (!sc.live && !sc.rest) problems.push(`${label} ${sc.id}: never started (stage.js, the scene file or Stage.init() missing)`);
    if (label === 'desktop' && sc.rest) problems.push(`${label} ${sc.id}: shows its finished composition instead of running (GSAP not loaded, or the scene threw)`);
    const frames = [], labels = [];
    const span = Math.max(0, sc.height - H);
    const points = [{ y: Math.max(0, sc.top - H * 0.5), l: 'before' }, ...Array.from({ length: STEPS + 1 }, (_, k) => ({ y: sc.top + (span * k) / STEPS, l: `${Math.round((k / STEPS) * 100)}%` })), { y: sc.top + span + H * 0.6, l: 'after' }];
    for (const p of points) {
      await page.eval(go(Math.round(p.y))).catch(() => {});
      await sleep(WAIT);
      frames.push(await page.screenshot({ quality: 72 }));
      labels.push(`${sc.id} ${p.l}`);
    }
    const cw = vp.width >= 1000 ? 480 : 240, ch = Math.round((cw * H) / vp.width);
    const path = join(outDir, `${sc.id}-${label}.jpg`);
    await sheet(frames, labels, cw, ch, vp.width >= 1000 ? 4 : 7, path);
    console.log(`${label.padEnd(8)} ${sc.id}: ${(sc.height / H).toFixed(1)} screens tall, ${sc.live ? 'running' : sc.rest ? 'finished composition' : 'not started'} → ${rel(path)}`);
  }
  if (label === 'desktop' && scenes.length) {
    // frame timing while the page is wheeled through its scenes
    await page.eval(go(Math.max(0, scenes[0].top - H))).catch(() => {});
    await sleep(400);
    await page.eval(`(() => { window.__f = []; let last = performance.now(); const tick = (t) => { window.__f.push(t - last); last = t; if (window.__f.length < 600) requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()`);
    await page.wheel(Math.min(scenes.reduce((a, s) => a + s.height, 0), 9000), { step: 120, pauseMs: 16 });
    await sleep(600);
    const f = await page.eval('window.__f.slice(2)');
    const sorted = f.slice().sort((a, b) => a - b);
    console.log(`smoothness: ${f.length} frames, median ${(sorted[Math.floor(f.length / 2)] || 0).toFixed(1)} ms, ${f.filter((d) => d > 34).length} over 34 ms (headless timing is rough; a handful is normal)`);
  }
  if (page.consoleErrors?.length) { problems.push(`${label} console: ${page.consoleErrors.slice(0, 3).join(' | ')}`); }
  await page.close();
}

try {
  await run('desktop', { width: 1440, height: 900, dsf: 1 });
  await run('phone', { width: 390, height: 844, dsf: 1, mobile: true });
  const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
  await rp.goto(url, { timeoutMs: 40000, settleMs: 1200 });
  const h = await rp.eval('document.documentElement.scrollHeight');
  const frames = [], labels = [];
  for (let y = 0, k = 0; y < h && k < 16; y += 900, k++) { await rp.eval(`window.scrollTo(0, ${y})`); await sleep(350); frames.push(await rp.screenshot({ quality: 70 })); labels.push(`reduced motion, screen ${k + 1}`); }
  await sheet(frames, labels, 360, 225, 4, join(outDir, 'reduced-desktop.jpg'));
  console.log(`reduced  ${frames.length} screens → ${rel(join(outDir, 'reduced-desktop.jpg'))}`);
  await rp.close();
} finally {
  await browser.close();
  if (server) server.close();
}
if (problems.length) { console.log('\nPROBLEMS'); for (const p of problems) console.log('  ' + p); }
console.log(problems.length ? `\nRESULT: ${problems.length} problem(s). Fix them, then look at every sheet.` : '\nRESULT: every scene ran. Now look at every sheet: does each frame read, and is the scene as bold as it should be?');
process.exit(problems.length ? 1 : 0);
SKILL.md›
---
name: design-taste-frontend
description: >-
  tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of
  247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions
  (idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict
  no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use
  this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site,
  portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React,
  Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated,
  "AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other
  site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens
  people work in, with their own run and rules.
---

# tasteskill v2

A page built by a model usually looks like the average of every page the model has seen: a centred
headline over three lines, a little label above it, a row of icon cards, a soft gradient, a few thin
rules and a monospace caption. Nothing in it is wrong and nothing in it was decided.

This skill replaces those defaults with decisions, in four ways:

1. **A script deals the directions, you choose among them.** Asked to invent a look, a model picks
   the category default every time. `scripts/draw.mjs` deals three of twenty researched directions;
   you choose the one that carries the subject's idea, and the script then deals its type system,
   palette, hero and section compositions from a library distilled from 247 real sites.
2. **The idea comes from the subject.** In the study, what separated the best sites from the forgettable
   ones was a concept taken from the thing itself, not the styling.
3. **A house style with no ornaments,** measured in a real browser by `scripts/inspect.mjs`, so the
   rules are checked rather than hoped for.
4. **You look at what you made.** Screenshots at three widths, a juror's rubric, fix, look again.

One warning learned in testing. Taking the ornaments away is only half the job; a page that has lost its
labels, rules and cards and gained nothing in their place reads as thin, and blind jurors rightly prefer
a richer page with tells over an empty page without them. The other half is substance: type set
larger, whole fields of colour, real pictures of this subject's world (found or drawn), and every
section carrying its full content. The skill is built to give you that; do not trade it for restraint.

In the commands below `<skill>` is this skill's folder. Run them from the project's root, so the
working files land in `.taste-skill/` there. The scripts need Node 22+ and an installed Chrome, Edge
or Chromium, and install nothing. `assets.mjs` also needs network access; it uses no API keys.

## Page or app screen

Decide first which of two kinds of work this is.

- **A page** is read and scrolled: a landing page, a marketing or product site, a portfolio, an
  event, a restaurant, a one-pager. It follows **the run** below.
- **An app screen** is worked in: a dashboard, an admin or analytics view, settings, billing, an inbox
  or queue to work through, a wallet, a list with filters, an editor or builder. It follows
  **`references/app-screens.md`**: its own run, its own draw (`draw.mjs --app`), its own house rules
  (quiet borders, small labels and repeated panels are normal there; gradients, glows, icons in tinted
  squares, rainbow charts and invented vanity numbers are not) and `inspect.mjs --app`. Read that file
  whole before you start; the page run below does not apply.

A product site that shows its app is a page; the interface fragments on it follow the app-screen
rules for their insides. Deal the product's app style once, in its own folder, with the page's colours
and type kept:

```bash
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<the product>" --keep palette,type --out .taste-skill/app
```

and build every fragment the page shows (the hero's live screen, a feature's crop, a chart) as a real
piece of that app: `assets/app.css` for its panels, tables and controls, `assets/charts.js` for its
charts, real data that adds up, one finding stated, cropped and scaled the way the page's blueprint
treats a picture. A fragment that looks like a working product is the strongest picture a software page
has; grey bars and lorem numbers are the weakest. When a request mixes both (a landing page and the
dashboard behind it), build each with its own run and one shared palette and typeface.

## The run

Do these in order. Steps 1–5 take a few minutes and decide whether the page is any good.

### 1. Read the brief and the project

Find out what is real: the subject, the audience, the one thing the page must get someone to do, the
stack, any existing brand (colours, fonts, logo), and above all **what imagery truly exists**: the
project's own photographs or product shots, a real interface that can be shown, an image generation
tool you can call, or nothing. Do not plan around pictures you do not have. Nothing of its own is
rarely nothing at all: the subject's city, street, materials and history are in open archives and
museums, free to use with a credit (step 6, `scripts/assets.mjs`).

If the request is vague, decide and state your reading in one line instead of asking. Ask only when a
missing fact blocks the work (for example, no product name).

Then **inventory the subject** in your notes: eight to twelve things that belong to this client and to
no competitor. Look for its name and what the name means; its address, door number, street and
neighbourhood, and what that place looks like (its tiles, signs, paint colours, typefaces on its shop
fronts, its light, its weather); the numbers in the brief (seats, rooms, minutes to the sea, the
price, the year); the constraint it lives with (only one batch a month, closed in winter, by
appointment); its materials and colours; its rituals and hours (the oven lit at five, the ferry at
seven); the printed things of its world (a timetable, a label, a ticket, a sign). This list is where
the idea comes from in step 4. In blind tests, the page that won each brief was built from an item on
such a list (the client's name and what it means, the street it stands in, the one constraint that
makes it what it is), and the pages that lost had a good composition with a category idea. The list
is also your picture search: each place, material and printed thing on it is a query for step 6.

### 2. Write the argument

Before any design, list the page as claims with their proof, one line per section, the way
`references/copy.md` §1 shows. The claims decide which sections exist and in what order. Delete any
section that has no claim. This is how you avoid the stock sequence of hero, logos, three features,
testimonial, pricing, call to action.

### 3. Draw the direction

```bash
node <skill>/scripts/draw.mjs --sector <sector> --brief "<one line about the subject>" \
  --sections <roles in the order of your argument> [--tone <tone>] [--have <assets>] [--keep palette,type] \
  --options 3
```

This deals three directions and prints a short summary of each. Hold each one against your inventory:
which way of composing can carry something only this client has? Choose that one, not the most
familiar, and write one line in your notes on why. In testing, a single random direction that did not
suit the subject was the commonest cause of a weak page. Then deal the full sheet:

```bash
node <skill>/scripts/draw.mjs <the same arguments, without --options> --direction <chosen id>
```

- `--sector`: `node <skill>/scripts/draw.mjs --list sectors`. Pick the closest.
- `--sections`: roles from `--list roles` (features, showcase, how-it-works, testimonial, proof, stats,
  pricing, faq, cta, footer, intro, about, work, gallery, list, contact). The hero is implied.
- `--tone` (optional): quiet, bold, warm, technical, playful, luxury, editorial. Only pass it when the
  brief clearly asks for one.
- `--have`: assets that really exist, comma-separated: `photography` (the project's own photographs of
  the place, people or work), `product-photo` (photographs of the specific product, or an image tool
  that can make them), `product-ui` (real screenshots), `illustration`, `video`. Leave it out if you
  have none: the draw then avoids compositions that depend on them and the sheet says how to fill
  picture slots honestly (software shows its own interface built in HTML; a product with no photographs
  is drawn and set among real pictures of its world, never a fake product shot). Pictures you will
  find in the open collections are not the project's own; leave them out of `--have`.
- `--keep palette,type`: when the brand already fixes colours or typefaces. The draw then deals only
  the composition, and you map the brand's values onto the roles.

The command prints a **direction sheet**. It is the art direction for this build:

- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
  good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
  reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
  exist. Never redraw to get something more comfortable. Record every redraw in your report.
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
  it wants five quotations and there are none), do not bend the content to fit. List the others with
  `--alternatives <role>`, pick the one whose assumptions match the content, and take it with
  `--redraw section:<role>=<id>`.
- **Check the palette against the subject.** A dealt palette is a set of proportions (one ground, one
  ink, one accent, fields) as much as a set of colours. If its colours fight the subject's own material
  (candy pastels for a peat whisky, neon for a mountain hut), redraw the palette once; if the subject
  has unmistakable colours of its own (its material, the paint of its street, the landscape outside
  its windows), keep the dealt proportions and take the colours from the subject instead, and say so
  in the report.
- Reprint it any time with `node <skill>/scripts/draw.mjs --show`. In a long session, re-read it before each
  section you build.

### 4. Find the idea

The direction is a way of composing. The idea is what makes the page belong to this subject and no
other. Write this sentence in your notes and do not start building until both blanks are filled from
the subject itself:

> On this page, **[something that belongs to the subject]** becomes **[the device the direction calls for]**.

Places to look for the first blank:

- **Its output.** What does the thing produce? Put the real output on the page as material. (A camera
  that prints poems delivered its copy as paper slips.)
- **Its object or shape.** What does it physically look like, in cross-section, in silhouette?
  Enlarge it, crop it, make it the frame or the one motif.
- **Its name.** Can the letters hold a photograph, stack into a wall, split to two corners?
- **Its place.** Is there one view that makes someone want to be there?
- **Its ritual.** Is there a sequence the page can perform as it scrolls?
- **Its artefact.** What printed thing already exists in its world: ticket, menu, label, receipt,
  invitation, folder, manual?
- **Its opposite.** Which other category owns the feeling this one lacks?

Fill the first blank from your inventory (step 1), not from the category. **The competitor test:** put
the nearest real competitor in the sentence (the bakery two streets away, another oak furniture maker,
another hut on the same mountain). If the sentence still works, the idea belongs to the category: a
loaf of bread is every bakery; the seven doors of the street this one is named after are only this one.
"Clean and modern" is not an idea. The sites the study's panel scored lowest were the ones whose idea
was recorded as "none beyond the standard formula".

**One live thing.** Give the page one thing the visitor can do, or that is different for each visit,
made from the idea: pick a wood and see the chair in it; type your name and see it cut in the
foundry's letters; set the loan and read the real monthly figure; see whether the hut's stove is lit
right now and how long until the last ferry; choose a size and see it against the room. In blind tests every winning page had one and the jurors named it first. Rules: it uses the subject's real content, it works on a phone with a thumb, it has a plain
fallback without script, it never blocks reading, and it is one thing, not a playground. It is
built in step 6 as part of the page, not added as motion in step 9.

**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
Decide what each one shows from your inventory before building: the fly-through passes through this
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
to anyone is a screensaver.

### 5. Write the words

Write the whole page as a copy deck (plain text) before markup, following `references/copy.md`:

- **Every headline fits in two lines or fewer at every width.** Do the arithmetic in copy.md §2 before
  committing to a sentence: about 32 characters is safe everywhere at a confident size. The studied
  gallery heroes have a median of 5 words and 32 characters.
- One supporting sentence, directly under the headline (32–48px below it) and shown with it, never
  docked at the foot of the first screen or held back by an animation. When the headline is a name or
  a figure of speech, this sentence says plainly what the thing is. One primary action, named for what
  it does, as a real button on the first screen.
- **The first screen answers the visitor's first question in large type.** For an event: what it is,
  when and where (the dates belong in the headline or at the second size, never only in small text).
  For a product: what it is and what it costs or does. For a person: who and what they do. Every
  section the visitor came for (the line-up, the menu, the rooms, the price) has its own heading and
  its own place in the navigation.
- **Write the full content.** Every section the brief asks for delivers its information completely:
  the whole menu with prices, each room with its own details, every speaker and talk, the price with
  what it includes, an answer for each real question. Thin sections are the commonest reason a clean
  page loses to a busier one.
- Sample details are expected where the brief leaves gaps (dish names, room names and rates, project
  titles) so the design is seen at full density; list them in the report for the client to confirm.
  Proof is never invented: no customers, quotes, reviews, press, logos or business statistics.

### 6. Build it, still

Build the page with motion switched off in your head; a page that needs animation to look finished is
not finished. Order of work:

1. **Tokens.** Colour and type roles from the sheet as CSS custom properties. Everything downstream
   references tokens only. `<skill>/assets/base.css` is a neutral foundation you may start from (reset, token
   names, fluid type steps, grid, focus and selection styles, the motion helpers); it has no look of
   its own.
2. **The hero, exactly as the blueprint describes it,** at desktop and at phone width. Get the big
   thing big and the small things small before anything else exists.
3. **Each section from its blueprint,** with this project's content. Keep the geometry, change the
   subject. Neighbouring sections must not share a layout. **Carry the idea into every section,**
   pricing, FAQ and footer included: the commonest failure in the study, even among good sites, is a
   distinctive first screen followed by stock blocks.
4. **Imagery,** in this order of preference:
   1. the project's own;
   2. generated, if you have an image tool;
   3. **real pictures of the subject's world from the open collections,** whenever an honest one
      exists. Search them with `node <skill>/scripts/assets.mjs "<query>"` (Openverse, Wikimedia
      Commons, the Art Institute of Chicago and the Met; only licences that allow commercial use, by
      default). Search the inventory, not the category: its street in old postcards, an old map of the
      neighbourhood, its material up close, its trade in engravings, a museum object of its kind, a
      botanical plate, a star chart. Use them large and with intent: one archival photograph as the
      hero, an engraving enlarged into the page's motif, the real map of the street as the contact
      section. Look at each before choosing; `--get <ref>` downloads it into `assets/` and records
      its source and licence, and `--credits` prints the credit line for the footer. A found picture
      shows the world around the client and is never presented as the client's own product, room,
      dish, person or team;
   4. **drawn in code from the subject itself,** for what no honest picture exists of (its objects,
      its tiles, its windows, its product's interface, made as one consistent flat graphic language in
      the palette's real colours);
   5. the photographs dealt on the sheet, only where an honestly generic picture fits (the sea for a
      sea house), never as a stand-in for the subject;
   6. type and colour alone.

   When the project has footage or a real picture of its own process or product, the first screen
   shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
   the pictures come from or what they are not ("none of these shows our bar"): a caption says what
   the picture is, and the credit line sits small in the footer. A portfolio whose work is invented
   for the brief draws each piece as a finished still of that work (its title card with its own type,
   an end frame, a poster), with real detail, never as a pictogram of the client's product.

   **The floor, which the inspector checks:** at least four real pictures on the page, usually six to
   twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
   cropped interface fragments count too). SVG drawings do not count toward the floor: draw what no
   picture exists of, and still carry real pictures. Add one moving picture wherever an honest clip
   exists: search it with `assets.mjs --video "<query>"` and use it as a muted loop with a poster
   frame. Models skip pictures and footage because finding them is work; the page fails without them.

   `references/imagery.md` covers searching, licences and credits, video, drawing, crops and treatments.
5. **The live thing** from step 4, built into the section where it proves the claim (often the hero
   or the booking), with real content, a thumb-sized phone version and a plain fallback.
6. **The scroll scenes,** when the sheet deals them (section 9), each with the section it carries: the page's big
   moments, the camera that flies through the work, the picture that opens to the whole screen, the
   word that buckles under the scroll. Copy the shared stage and the scene's files from
   `<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
   page's tokens so it takes the page's look. A scene is composition: it decides how long the page is
   and what each screen shows, so it is built now and seen in every look round, not added at the end.
   `references/scenes.md` says how to put one on a page, fill it, fit it and check it;
   `assets/scenes/demo/<id>.html` is a working example of each.
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
   carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
   rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
   Running text is in the full ink at 16px or more; grey is for secondary notes.
8. **The frame.** The page has one pair of equal outer margins at every width, and every section uses
   the width the page has set up: when its text sits in the left half, the right half holds its
   picture, its second column, its list or its figure, never nothing. Asymmetry lives inside the grid,
   never in the frame: a void is placed only when something closes it on the far side. No stretch of
   the page taller than about half a screen holds nothing at all; sections are 120–240px apart on
   desktop. Every section the visitor came for opens with a heading that names it, and the first
   screen carries the page's title in an `h1`. These are what readers name first when a page looks
   wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
   inspector measures each of them.
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
   its own composition.

Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
behind a decision; the laws below are the short form.

### 7. Gate

```bash
node <skill>/scripts/lint.mjs <source files or folder>
node <skill>/scripts/inspect.mjs <url or path/to/index.html>
node <skill>/scripts/scenes.mjs <url or path/to/index.html>     # when the page has scroll scenes
```

`scenes.mjs` photographs every scroll scene at 0, 10 … 100% of its hold, on desktop and on a phone,
and the reduced-motion page; with scenes on the sheet, `inspect.mjs` also films the whole scroll
(`desktop-film-NN.jpg`). A scene that never started or rests on desktop is a `FAIL`.

`lint.mjs` scans source for banned patterns and stock phrases. `inspect.mjs` opens the page in a real
browser at 1440, 768 and 390 pixels, measures it (headline line counts, type sizes, fonts actually
loaded, colours, rules, shadows, gradients, contrast, overflow, and the frame: equal margins, sections
huddled on one side, blank stretches, the title, untitled sections, text against the screen edge) and
saves screenshots to `.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with the meter and do not
hide things from it. The inspector serves local files over a throwaway local server; if the page must
also open straight from the file system, open `index.html` that way once before you finish.

### 8. Look

Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
screenshots the inspector lists (every viewport, with your image viewer or Read tool) and critique them
with `references/critique.md`: the fourteen passes, the Awwwards-weighted score, the caps. Write the critique
down in its format, fix the top three problems, run the inspector again, look again. Three rounds at
most.

Judge only the pictures. If a problem is visible in a screenshot, it is real, whatever the code says.

### 9. Move

Now build the motion layer. It is required, and it is more than one fade: the inspector counts the
elements that really move while the page loads and is scrolled, and fails a page under eight (it warns
under sixteen). Any scroll scenes are already built (step 6); around them the layer has four parts:

1. **The signature move** named on the sheet: one, done well (a dealt scene can be it).
2. **An entrance for the parts of every section:** the heading, then the text, then the picture,
   staggered by 60–90ms, rising 16–32px with opacity, triggered once as the section enters. Not one
   fade for the whole block, and never content that stays hidden if the script fails.
3. **Feedback on every control:** press, hover and focus on buttons, links, cards and the live thing.
4. **One moving picture:** a found clip as a muted loop, a slow scale or drift on a photograph, or a
   scroll-linked move on the hero picture.

Transform and opacity only; every move has a `prefers-reduced-motion` fallback that keeps the content
and drops the travel. A move that changes the colour or the layout of whole screens (colour by
chapter, a pinned story) is part of the composition, so build it in step 6 and let every look round see
it. `references/motion.md` has the tokens and tested recipes. Then run the inspector once more and check
every screenshot for content that failed to appear.

### 10. Report

Do not report until all of these are true; each one was skipped in a test run that came out badly:

- `inspect.mjs` shows **0 FAIL** at 1440, 768 and 390, on the final version of the page.
- You opened every screenshot it listed after the last change, not only the first screen.
- With scroll scenes: `scenes.mjs` ran on the final page with no problems, and you read every sheet
  (desktop, phone, reduced motion).
- A stranger reading the first screen for five seconds can say what this is and what to do.
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
- The critique of your last round is written down in the shape of `references/critique.md` §4.

Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
sentence and the live thing; the final scores from your last critique round and what still falls short;
every found picture and clip with its title, creator, licence and source page (`assets.mjs --credits` prints
the list) and where its credit sits on the page; which bundled photographs and which drawings stand in
for photography (so they can be replaced); and any sample content that must be swapped before launch.
Do not claim a check you did not run.

## House rules

These hold on every build, whatever the direction. `inspect.mjs` measures them.

| Rule | Instead |
| --- | --- |
| **Headlines: two lines or fewer, at every width.** Every `h1`–`h6`, and the main line of the first screen whatever its tag. | Cut words. Then widen the measure. Size last. |
| **A clear title on the first screen**, in the page's one `h1`, and a heading on every section. | Say what this is: the name with what it does, the product with what it is for, the event with its date and place. |
| **Equal outer margins; no section huddled on one side** with the other half of the screen empty. | Close every void with something on its far side: the section's picture, a second column, a list, a figure. |
| **No blank stretches** taller than about half a screen. | The normal section gap. A deliberate rest is a placed picture or a field of colour, once per page. |
| **Statements are paragraphs.** An intro sentence or a quotation set large is marked up as `<p>` or `<blockquote>`, stays at statement size (36px or less on desktop, 24px on a phone) and runs to about 25 words, three lines. | Larger than that it is a headline and the two-line rule applies. In blind tests jurors read a 42px sentence over four lines as a four-line headline. |
| **No eyebrows.** No small label above a heading, no pill badge above the headline, no "New ·" chip. | Put the information in the heading or the first sentence, or drop it. |
| **No monospace** outside real code blocks. | The text face. Tabular figures where numbers align. |
| **No index numbering.** No 01 / 02 / 03, no (001), no "Step 2". | Order shown by position, size, or the words. A real `<ol>` if order matters. |
| **No thin rules.** No hairline dividers, no 1px outlined boxes, no grid lines, crosshairs or crop marks. | Space. A step in background tone. A change of scale. A filled shape. |
| **No gradients.** Not as backgrounds, not on buttons, not in text. | Flat colour, or a real photograph. A neutral scrim under text on a photo is the one exception. |
| **No shadows on things at rest.** No glows. | A tone step between surfaces. Shadows only on what truly floats: a menu, a dialog. |
| **No assistant palette.** No yellow-cream ground with terracotta or rust; no violet or indigo. | The dealt palette. |
| **No tiny uppercase letter-spaced labels.** | Sentence case at a readable size. |
| **No icon-topped feature cards, no rows of identical cards.** | The dealt section blueprint. |
| **No glass.** No backdrop blur panels, no floating pill navigation. | A plain bar: wordmark, a few links, one action. |
| **No emoji, no gradient blobs, no decorative shapes, no fake dashboards of grey bars.** | A real picture, a real interface fragment, or nothing. |
| **No styled accent word** in a headline (italic, serif, coloured, underlined). | One face, one colour. The words carry the emphasis. |
| **Two typefaces at most,** usually one. | The dealt type system. |

Three places keep their own conventions and are left alone by the inspector: real code in
`<pre>`/`<code>` (monospace is correct there), real tables and form fields (they may have lines), and
a real interface fragment whose root carries the attribute `data-ui` (the product's own borders and
menus; see `references/imagery.md` §7).

Why these, honestly. Some are what the study measured: among the 247 sites, those the panel scored
7.5 or higher used icon feature cards 1% of the time against 28% for sites under 6.5, pill badges 14%
against 34%, gradients 22% against 52%, shadows 24% against 48%, tiny tracked caps 16% against 57%.
Others are the owner's taste and go against current gallery fashion: monospace labels, index numbers
and hairline grids are actually *more* common on award sites than on weak ones, which is exactly why
generators imitate them and why they now read as costume. They are banned here without exception.
`references/tells.md` keeps the dated registry.

Removing ornament leaves space. Never fill it with another ornament. Fill it with scale (make the main
thing larger), with the full content, with a picture made for this subject, or with a field of flat
colour. Leave it empty on purpose once per page at most.

## Laws of the craft

Short form. Numbers are from the study unless marked as house style.

**Type**
- Two or three sizes do the visible work: one very large, one small, nothing in between. Median
  headline-to-body ratio on the studied sites is 4.3; the best go far beyond (largest type: median
  8.4vw on the top sites against 5.0vw on the weak ones).
- Display type: tracking -0.02 to -0.05em, leading 0.85–1.05. Text: 16–20px, leading 1.45–1.6,
  45–75 characters per line.
- Five sizes on a page is the ceiling. One family is normal (a third of the studied sites); two is the
  limit.
- Left-aligned by default. Centre at most one moment.

**Space**
- One grid, one pair of tight margins (16–32px), used everywhere; large things run to the edges. Text
  blocks share two or three left edges on the whole page.
- Asymmetry inside the grid, never in the frame: the two outer margins are equal, and an off-centre
  heading is answered by something at the far side of the grid.
- The gap between sections is clearly larger than any gap inside one: 120–240px on desktop (the study's median is about 240).
- Each section has a different composition from its neighbours; none is a card grid.

**Colour**
- One ground, one ink, one accent. The accent appears on the action and perhaps one other thing.
- Running text in the full ink. A page set in soft grey reads unfinished.
- Surfaces are separated by a small step in tone, never by a line or a shadow.
- A section may change the whole field colour. Nothing fades into anything.

**Imagery**
- One dominant image beats six small ones. On the top-scored sites the hero media covers at least half
  the first screen 68% of the time.
- Same crop ratios, same treatment, square corners unless the direction says otherwise (73% of the
  studied sites leave media square).
- No image is better than a placeholder, an icon, or an abstract shape. A picture drawn for this
  subject, in its colours and with care, is better than either. A real picture of its world, found
  and credited, is better still.
- Every found picture is downloaded into the project, credited on the page and listed in the report.
  None is presented as the client's own.
- Nothing ever lies on top of a headline or a statement (the inspector fails it).

**Components**
- Buttons: flat, one fill colour, either square-cornered or fully round, decided once (top sites: 52%
  square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
  button at all, or one.
- Navigation: wordmark, three to five plain links, one action. No bar chrome.
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.

## Special cases

**An existing brand or design system.** Pass `--keep palette,type`, map the brand's colours and faces
onto the roles, and take only composition from the draw. The house rules still apply; if the brand
itself requires a banned device (its logo sits in a gradient, its accent is violet), keep the brand
asset, say so in the report, and pass `--allow <rule>` to the inspector for that rule only.

**Redesign, restyle or review of an existing page or app screen.**

1. **Record the before.** Run the project as it is and inspect it into its own folder
   (`inspect.mjs <url> --out .taste-skill/before`, with `--app` for an app screen) and lint its
   source. Read the code: the framework, the styling method (Tailwind v3 or v4, CSS modules,
   styled-components, plain CSS), the components, and what must survive: routes, forms, data,
   behaviour, the brand's own assets.
2. **A review stops here.** Critique the before screenshots with `references/critique.md`, list the
   findings with the inspector's measurements, and name the three changes that would move the page
   most.
3. **Keep the stack, the content and what works.** No framework or styling-library migration; check the
   dependency file before adding anything. A restyle keeps the layout and takes type, colour and
   components from a dealt direction (`--keep` whatever the brand fixes); a redesign deals a full
   direction and rebuilds the sections from its blueprints inside the existing components. Either way
   proceed from step 2 of the run with the page's real content.
4. **Order of work, biggest effect for least risk:** type (faces and scale), colour (one ground, one
   ink, one accent), the frame (margins, section rhythm, a title on the first screen and a heading on
   every section), states (hover, focus, loading, empty, error), then the stock components (icon
   cards, pill badges, gradient blobs, card grids) replaced by the dealt blueprints.
5. **Prove it.** Inspect again into `.taste-skill/after`, compare before and after at every width,
   click through every link, form and menu that worked before, and report the before and after
   measurements side by side.

**A component or a single section, not a page.** Skip the draw unless there is no direction to follow.
Match the surrounding design, apply the house rules and the laws, and still inspect the result.

**No checkout, no form backend.** The primary button goes to the real destination when the brief names
one. Otherwise it opens an email with a written subject and body (`mailto:` with the visitor's choices
filled in), or, for a shop, it points at `#checkout` and the report says it must be wired before
launch. Never fake a payment form.

**No Chrome or Node 22.** Run `lint.mjs` (Node 18+), take screenshots with whatever browser tool you
have, and critique those. Say in the report that the inspector could not run.

**No network.** If `assets.mjs` reaches no source, draw the pictures and say in the report that the
open collections could not be searched.

## Files

| File | Read it when |
| --- | --- |
| `references/app-screens.md` | Any dashboard, admin, settings or other app screen: its own run, rules, tokens and checks. |
| `references/copy.md` | Writing the argument and the copy deck (steps 2 and 5). The two-line arithmetic. |
| `references/type.md` | Setting type: scales, tracking, measure, loading fonts, pairing. |
| `references/colour.md` | Applying the palette: roles, proportions, tone steps, dark pages, contrast. |
| `references/layout.md` | Grid, margins, rhythm, section variety, navigation, footer, the phone composition. |
| `references/imagery.md` | Choosing, finding, crediting, cropping and treating pictures; what to do with none. |
| `references/motion.md` | Step 9. Budget, tokens, recipes, reduced motion. |
| `references/scenes.md` | Step 6, when the sheet deals scroll scenes: putting one on a page, filling, fitting, the phone, checking, the catalogue. |
| `references/critique.md` | Step 8. How to look, how to score, what to fix first. |
| `references/tells.md` | The dated registry of generated-page tells and what each is replaced by. |
| `references/research.md` | The study: method, numbers, what the best and worst sites do. Cite it, do not guess. |
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references; `--app` deals an app style instead. |
| `scripts/assets.mjs` | Step 6: searches open collections for real pictures of the subject's world; `--get` downloads with the credit, `--credits` prints them. |
| `scripts/lint.mjs` · `scripts/inspect.mjs` | The gate. |
| `scripts/scenes.mjs` | The gate for scroll scenes: each scene at 0-100% of its hold, desktop, phone, reduced motion. |
| `assets/base.css` · `assets/base.js` | An optional neutral foundation to build on. |
| `assets/scenes/` | The scroll scenes: the shared stage (`stage.js`, `stage.css`) and, for each scene in the library, its `.js`, `.css` and a working demo page in `demo/`. |
| `assets/app.css` · `assets/charts.js` | For app screens: a neutral shell, panels, tables and controls, and small SVG charts drawn to the app rules. |
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions, scroll scenes (`scenes.json`), and the app styles, shells and screen patterns (`app.json`). Do not read it whole; use the script. |