Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
1 parent
b3d81ca21f
commit
c03a1a6924
41 files changed
+30660
-1237
No files matched your search
@@ -114,8 +114,8 @@
|
||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "2e08b9177c07bb7249a8a2d1a6758e5db281d002",
|
||||
"syncedAt": "2026-10-08T15:59:59Z"
|
||||
"commit": "d262ab3882952a59312ff3537cd86d894523c5ab",
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
},
|
||||
{
|
||||
"id": "taste-skill",
|
||||
@@ -123,8 +123,8 @@
|
||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||
"ref": "main",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "d237dd399d57524945c0c81327da5da19f7a468d",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"commit": "a60a36dc2bb3bb77ccf17f2f62041f5ca8a458b9",
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
},
|
||||
{
|
||||
"id": "shadcn",
|
||||
@@ -132,8 +132,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "993eb2c3151997ce08a49635c10fd8c8ef3713c7",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"commit": "30a58d4498f270af1c1e981a7d78a15cf5740841",
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -168,8 +168,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "ca04346de145ed6462ddf34682848e71df5e20a2",
|
||||
"syncedAt": "2026-10-11T16:00:00Z"
|
||||
"commit": "bf89859cbf3c5f5974de70e0dd2ccf2239c8c51b",
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
},
|
||||
{
|
||||
"id": "grill-me",
|
||||
@@ -258,8 +258,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "a7fd0aa6d4e0eb3cbea4b24e27dc82ad65b8a388",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"commit": "6cc25f4f27cb01b10cab07e9c81cf016b5251274",
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
},
|
||||
{
|
||||
"id": "shuorenhua",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "caveman",
|
||||
"version": "3.2.0",
|
||||
"version": "4.0.1",
|
||||
"description": "Caveman 是面向 Codex 的精简表达、代码工作流和上下文压缩 skill 集合。",
|
||||
"author": {
|
||||
"name": "Julius Brussee",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "caveman",
|
||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||
"ref": "main",
|
||||
"commit": "2e08b9177c07bb7249a8a2d1a6758e5db281d002",
|
||||
"commit": "d262ab3882952a59312ff3537cd86d894523c5ab",
|
||||
"adapter": "codex-plugin",
|
||||
"sourcePath": "plugins/caveman",
|
||||
"syncedAt": "2026-10-08T15:59:59Z"
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
}
|
||||
@@ -7,12 +7,16 @@ description: "压缩 Markdown 等自然语言文件并保留可读备份;需
|
||||
|
||||
## Purpose
|
||||
|
||||
> **Data leaves the machine.** Compression sends the file's full text to the model provider that `scripts/compress.py` is configured for: the `claude` CLI, the Anthropic API, or an OpenAI-compatible endpoint. Files whose names look like secrets (`.env`, `credentials`, `secrets`, `.netrc`, …) are refused before they are read. Don't compress a file the provider shouldn't see.
|
||||
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/`, or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` on Windows) so skill auto-loaders don't re-ingest it as a live file.
|
||||
|
||||
## Trigger
|
||||
|
||||
`/caveman-compress <filepath>` or when user asks to compress a memory file.
|
||||
|
||||
**Requires an explicit filepath.** If the user pastes raw text instead of naming a file, do NOT improvise an inline compression — ask them for the filepath to compress, or offer to save the pasted text to a file first. Writing your own reasoning/commentary about the request into the compressed output (e.g. "User passed text directly, not a filepath...") is the exact failure this guards against — never let that leak into a saved file.
|
||||
|
||||
## Process
|
||||
|
||||
1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md.
|
||||
|
||||
@@ -625,9 +625,15 @@ def call_opencode_cli(prompt: str) -> str:
|
||||
"OPENCODE_CONFIG_CONTENT must be a strict JSON object (no comments or "
|
||||
"trailing commas) so compress can add its tool-deny rule; fix or unset it."
|
||||
)
|
||||
agents = config.get("agent") or {}
|
||||
if not isinstance(agents, dict):
|
||||
raise RuntimeError(
|
||||
'OPENCODE_CONFIG_CONTENT "agent" must be a JSON object so compress '
|
||||
"can add its tool-deny agent; fix or unset it."
|
||||
)
|
||||
config["permission"] = OPENCODE_PERMISSION_DENY
|
||||
config["agent"] = {
|
||||
**(config.get("agent") or {}),
|
||||
**agents,
|
||||
OPENCODE_AGENT: {"permission": OPENCODE_PERMISSION_DENY},
|
||||
}
|
||||
env = {**os.environ, "OPENCODE_CONFIG_CONTENT": json.dumps(config)}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-10-12T16:02:35Z"
|
||||
"syncedAt": "2026-10-14T02:11:59Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "a7fd0aa6d4e0eb3cbea4b24e27dc82ad65b8a388",
|
||||
"commit": "6cc25f4f27cb01b10cab07e9c81cf016b5251274",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
> 🆕 **As a finance professional (CPA · CPV · Consulting Engineer (Investment)), I drew on my expertise and project experience to build a tool website: [Construction Project Economic Evaluation](https://cs.hehugo.com/)** (in Chinese) — enter your project data and get a full financial evaluation online: investment, revenue, cost, debt service, profit, cash flow and balance sheet statements, plus IRR, NPV, sensitivity and break-even analysis, exportable to Excel / Word. Built for energy projects such as wind, solar and storage, with a general-purpose mode for other projects. The site is in an early trial phase; new accounts get 3 free runs.
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fan/register?aff=PPT-MASTER">APIKEY.FAN</a>, <a href="https://runapi.host/register?aff=WMLJ">RunAPI</a>, <a href="https://go.apimart.ai/gh-ppt-master1">APIMart</a> and other sponsors.
|
||||
@@ -428,6 +430,8 @@ If PPT Master has been helpful to you, individual support of any amount helps ke
|
||||
|
||||
<img src="docs/assets/alipay-qr.jpg" alt="Alipay QR Code" width="220" />
|
||||
|
||||
> 🎁 Sponsor ¥100 or more (in multiples of ¥100) via Alipay and put the email you registered on [Construction Project Economic Evaluation](https://cs.hehugo.com/) in the payment note to receive compute credits on that site. During the early trial phase, every ¥100 earns 50 credits.
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by [Hugo He](https://www.hehugo.com/) — if this project helps you, please give it a ⭐ and consider [sponsoring](#sponsors--support).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "ca04346de145ed6462ddf34682848e71df5e20a2",
|
||||
"commit": "bf89859cbf3c5f5974de70e0dd2ccf2239c8c51b",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-10-11T16:00:00Z"
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "993eb2c3151997ce08a49635c10fd8c8ef3713c7",
|
||||
"commit": "30a58d4498f270af1c1e981a7d78a15cf5740841",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "taste-skill",
|
||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||
"ref": "main",
|
||||
"commit": "d237dd399d57524945c0c81327da5da19f7a468d",
|
||||
"commit": "a60a36dc2bb3bb77ccf17f2f62041f5ca8a458b9",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-10-12T15:59:59Z"
|
||||
"syncedAt": "2026-10-14T02:09:15Z"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
||||
/* 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);
|
||||
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);
|
||||
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--t-press: 140ms;
|
||||
--t-hover: 180ms;
|
||||
--t-ui: 240ms;
|
||||
--t-reveal: 800ms;
|
||||
}
|
||||
|
||||
/* ---------- 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; } }
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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));
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"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 (mesa@osterianove.pt). 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 (mara@lindqvist.studio). 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 reservas@casamirante.pt). 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,894 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
[
|
||||
{
|
||||
"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. Keep the pinned run under six screens. 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: 'List with image preview' 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."
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,141 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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 (` `).
|
||||
- `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.
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.** 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.
|
||||
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.** 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.
|
||||
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**.
|
||||
- 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.
|
||||
@@ -0,0 +1,377 @@
|
||||
# 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
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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. 720p is plenty behind type.
|
||||
- **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.
|
||||
- **One per page, two at most.** A page of moving tiles is 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.
|
||||
- Under `prefers-reduced-motion: reduce`, stop it and show the poster:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const calm = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
for (const v of document.querySelectorAll('video[autoplay]')) {
|
||||
const sync = () => { if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); } else { v.play().catch(() => {}); } };
|
||||
sync(); calm.addEventListener('change', sync);
|
||||
}
|
||||
</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. A headline over a clip gets the same neutral scrim as text on a photograph,
|
||||
and 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.
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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. Sections: carry the idea, vary the arrangement
|
||||
6. Navigation
|
||||
7. Endings
|
||||
8. The phone is a second composition
|
||||
9. 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: an empty left half, a blank right third, 300px of nothing above
|
||||
the work.
|
||||
|
||||
- Decide where the void is and keep it clean: no stray caption, no decorative shape, no faint
|
||||
watermark.
|
||||
- The gap between sections is clearly larger than any gap inside one (120–220px on desktop,
|
||||
72–112px on a phone).
|
||||
- Related things sit close; unrelated things sit far. If a heading is equidistant from the paragraph
|
||||
above and the one below, fix it: at least twice as much space above as below.
|
||||
- When a section looks empty, the answer is almost never "add something". Make the main element
|
||||
bigger, or cut the section.
|
||||
|
||||
## 5. 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).
|
||||
|
||||
## 6. 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.
|
||||
|
||||
## 7. 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.
|
||||
|
||||
## 8. 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.
|
||||
|
||||
## 9. 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.
|
||||
@@ -0,0 +1,292 @@
|
||||
# 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. Four questions before any animation
|
||||
3. Tokens
|
||||
4. Recipes (copy, then adapt)
|
||||
5. Signature moves
|
||||
6. Reduced motion and performance
|
||||
7. What gives generated motion away
|
||||
|
||||
## 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 four parts:
|
||||
|
||||
- **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. 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%).
|
||||
|
||||
## 3. Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* enters, exits, reveals */
|
||||
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* things that travel while visible */
|
||||
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* long quiet reveals, image scale */
|
||||
--t-press: 140ms;
|
||||
--t-hover: 180ms;
|
||||
--t-ui: 240ms;
|
||||
--t-reveal: 800ms;
|
||||
}
|
||||
```
|
||||
|
||||
Name the properties you animate. `transition: all` animates things you did not intend (and costs
|
||||
layout); write `transition: transform var(--t-press) var(--ease-out)`.
|
||||
|
||||
## 4. Recipes
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
### Hero load sequence (CSS only)
|
||||
|
||||
Stagger at most four things. With a headline of two lines this is short and reads as one gesture.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.js [data-rise] {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
animation: rise var(--t-reveal) var(--ease-out) forwards;
|
||||
animation-delay: calc(var(--i, 0) * 90ms + 120ms);
|
||||
}
|
||||
}
|
||||
@keyframes rise { to { opacity: 1; transform: none; } }
|
||||
```
|
||||
|
||||
```html
|
||||
<h1 data-rise style="--i:0">…</h1>
|
||||
<p data-rise style="--i:1">…</p>
|
||||
<a data-rise style="--i:2" class="button">…</a>
|
||||
```
|
||||
|
||||
### 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; } }
|
||||
```
|
||||
|
||||
### List with image preview (work index, menu, team)
|
||||
|
||||
Rows of large text; hovering a row shows its image in a fixed frame beside or behind the list.
|
||||
|
||||
```css
|
||||
.index { position: relative; }
|
||||
.index__row { display: flex; justify-content: space-between; align-items: baseline; padding-block: 0.35em; font-size: var(--step-4); }
|
||||
.index__frame { position: absolute; inset-block: 0; right: 0; width: 34%; pointer-events: none; }
|
||||
.index__frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity var(--t-ui) ease; }
|
||||
.index__frame img.is-on { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .index:hover .index__row:not(:hover) { opacity: 0.35; } .index__row { transition: opacity var(--t-hover) ease; } }
|
||||
```
|
||||
|
||||
On touch there is no hover, so each row shows its image inline above the title instead.
|
||||
|
||||
### 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.
|
||||
|
||||
## 5. 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 | List with image preview recipe |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## 6. 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, no smooth-scroll library by default, no custom cursor. They cost usability,
|
||||
which is 30% of an Awwwards score and the line most winners score lowest on.
|
||||
|
||||
## 7. 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.
|
||||
@@ -0,0 +1,306 @@
|
||||
# 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
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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` §7.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,170 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,865 @@
|
||||
#!/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
function text(v) {
|
||||
if (v === undefined || v === null) return '';
|
||||
return String(v).replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/ /g, ' ').replace(/"/g, '"').replace(/�?39;|'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/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));
|
||||
@@ -0,0 +1,525 @@
|
||||
#!/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|section:<role>|direction re-deal one slot
|
||||
// node draw.mjs --list sectors|directions|roles|tones
|
||||
// node draw.mjs --site <id> print one studied reference site in full
|
||||
//
|
||||
// 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' };
|
||||
|
||||
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 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') };
|
||||
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.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, 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 });
|
||||
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);
|
||||
return s;
|
||||
}
|
||||
function dealHero(dir, input, exclude) {
|
||||
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id));
|
||||
if (!pool.length) pool = L.heroes.filter((h) => (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(?)/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);
|
||||
const sameKind = L.sites.filter((s) => s.kind === input.sector && s.score >= 7.2 && !named.includes(s));
|
||||
const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)];
|
||||
return out.length ? out : shuffle(L.sites.filter((s) => s.score >= 7.6)).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}`);
|
||||
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), 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 (d.richness) { p('## 9. Where the richness comes from'); p(d.richness); p(); }
|
||||
if (d.pitfalls) { p('## 10. How this direction goes wrong'); p(d.pitfalls); p(); }
|
||||
if (s.references?.length) {
|
||||
p('## 11. 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. `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');
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/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] [--json]
|
||||
//
|
||||
// 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 } from './lib/probe.mjs';
|
||||
import { judge } 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');
|
||||
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] [--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 { allow = [...new Set([...allow, ...(JSON.parse(readFileSync(p, 'utf8')).allow || [])])]; } 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+)\.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 }; };
|
||||
})();`;
|
||||
|
||||
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 page.eval('document.fonts.ready.then(() => true)').catch(() => {});
|
||||
await sleep(1400); // 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 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);
|
||||
}
|
||||
const findings = judge(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, consoleErrors: page.consoleErrors.slice(0, 5) });
|
||||
report.findings.push(...findings);
|
||||
report.images.push({ viewport: label, width: w, fold, pages: sheets });
|
||||
await page.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)}`); }
|
||||
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);
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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 } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } 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;
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ad-chrome-'));
|
||||
const args = [
|
||||
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
|
||||
'--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'] });
|
||||
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 };
|
||||
@@ -0,0 +1,569 @@
|
||||
// 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) },
|
||||
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()); }
|
||||
const pinned = size >= 32 && pinnedLow(el);
|
||||
// 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 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) };
|
||||
}
|
||||
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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.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;
|
||||
}
|
||||
|
||||
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'];
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/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 ...] [--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');
|
||||
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\.|lock|\.d\.ts$/.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).'],
|
||||
];
|
||||
|
||||
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 (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);
|
||||
Reference in new issue
Block a user