chore(01-design-tokens-rams-foundation-01): commit design baseline — Rams docs, mockups, agent skills

- Root Rams design docs (DESIGN.md, IDEAS.md) and GSD install-fix notes (GSD_FIX.md, gsd-fix.sh)
- Four Telemetry Rams mockup screens + telemetry_rams/DESIGN.md (canonical token frontmatter)
- .agents/ project skills (design-taste-frontend) + skills-lock.json
- .gitignore: node_modules/dist/env secrets/OS junk; .planning stays local-only; Zone.Identifier scrubbed (9 files deleted)
This commit is contained in:
araharah-executor
2026-10-22 07:33:04 +08:00
parent d4ee39d08b
commit 506ff68976
54 changed files with 39105 additions and 0 deletions
@@ -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.
@@ -0,0 +1,529 @@
---
name: design-taste-frontend
description: >-
tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of
247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions
(idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict
no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use
this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site,
portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React,
Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated,
"AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other
site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens
people work in, with their own run and rules.
---
# tasteskill v2
A page built by a model usually looks like the average of every page the model has seen: a centred
headline over three lines, a little label above it, a row of icon cards, a soft gradient, a few thin
rules and a monospace caption. Nothing in it is wrong and nothing in it was decided.
This skill replaces those defaults with decisions, in four ways:
1. **A script deals the directions, you choose among them.** Asked to invent a look, a model picks
the category default every time. `scripts/draw.mjs` deals three of twenty researched directions;
you choose the one that carries the subject's idea, and the script then deals its type system,
palette, hero and section compositions from a library distilled from 247 real sites.
2. **The idea comes from the subject.** In the study, what separated the best sites from the forgettable
ones was a concept taken from the thing itself, not the styling.
3. **A house style with no ornaments,** measured in a real browser by `scripts/inspect.mjs`, so the
rules are checked rather than hoped for.
4. **You look at what you made.** Screenshots at three widths, a juror's rubric, fix, look again.
One warning learned in testing. Taking the ornaments away is only half the job; a page that has lost its
labels, rules and cards and gained nothing in their place reads as thin, and blind jurors rightly prefer
a richer page with tells over an empty page without them. The other half is substance: type set
larger, whole fields of colour, real pictures of this subject's world (found or drawn), and every
section carrying its full content. The skill is built to give you that; do not trade it for restraint.
In the commands below `<skill>` is this skill's folder. Run them from the project's root, so the
working files land in `.taste-skill/` there. The scripts need Node 22+ and an installed Chrome, Edge
or Chromium, and install nothing. `assets.mjs` also needs network access; it uses no API keys.
## Page or app screen
Decide first which of two kinds of work this is.
- **A page** is read and scrolled: a landing page, a marketing or product site, a portfolio, an
event, a restaurant, a one-pager. It follows **the run** below.
- **An app screen** is worked in: a dashboard, an admin or analytics view, settings, billing, an inbox
or queue to work through, a wallet, a list with filters, an editor or builder. It follows
**`references/app-screens.md`**: its own run, its own draw (`draw.mjs --app`), its own house rules
(quiet borders, small labels and repeated panels are normal there; gradients, glows, icons in tinted
squares, rainbow charts and invented vanity numbers are not) and `inspect.mjs --app`. Read that file
whole before you start; the page run below does not apply.
A product site that shows its app is a page; the interface fragments on it follow the app-screen
rules for their insides. Deal the product's app style once, in its own folder, with the page's colours
and type kept:
```bash
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<the product>" --keep palette,type --out .taste-skill/app
```
and build every fragment the page shows (the hero's live screen, a feature's crop, a chart) as a real
piece of that app: `assets/app.css` for its panels, tables and controls, `assets/charts.js` for its
charts, real data that adds up, one finding stated, cropped and scaled the way the page's blueprint
treats a picture. A fragment that looks like a working product is the strongest picture a software page
has; grey bars and lorem numbers are the weakest. When a request mixes both (a landing page and the
dashboard behind it), build each with its own run and one shared palette and typeface.
## The run
Do these in order. Steps 1–5 take a few minutes and decide whether the page is any good.
### 1. Read the brief and the project
Find out what is real: the subject, the audience, the one thing the page must get someone to do, the
stack, any existing brand (colours, fonts, logo), and above all **what imagery truly exists**: the
project's own photographs or product shots, a real interface that can be shown, an image generation
tool you can call, or nothing. Do not plan around pictures you do not have. Nothing of its own is
rarely nothing at all: the subject's city, street, materials and history are in open archives and
museums, free to use with a credit (step 6, `scripts/assets.mjs`).
If the request is vague, decide and state your reading in one line instead of asking. Ask only when a
missing fact blocks the work (for example, no product name).
Then **inventory the subject** in your notes: eight to twelve things that belong to this client and to
no competitor. Look for its name and what the name means; its address, door number, street and
neighbourhood, and what that place looks like (its tiles, signs, paint colours, typefaces on its shop
fronts, its light, its weather); the numbers in the brief (seats, rooms, minutes to the sea, the
price, the year); the constraint it lives with (only one batch a month, closed in winter, by
appointment); its materials and colours; its rituals and hours (the oven lit at five, the ferry at
seven); the printed things of its world (a timetable, a label, a ticket, a sign). This list is where
the idea comes from in step 4. In blind tests, the page that won each brief was built from an item on
such a list (the client's name and what it means, the street it stands in, the one constraint that
makes it what it is), and the pages that lost had a good composition with a category idea. The list
is also your picture search: each place, material and printed thing on it is a query for step 6.
### 2. Write the argument
Before any design, list the page as claims with their proof, one line per section, the way
`references/copy.md` §1 shows. The claims decide which sections exist and in what order. Delete any
section that has no claim. This is how you avoid the stock sequence of hero, logos, three features,
testimonial, pricing, call to action.
### 3. Draw the direction
```bash
node <skill>/scripts/draw.mjs --sector <sector> --brief "<one line about the subject>" \
--sections <roles in the order of your argument> [--tone <tone>] [--have <assets>] [--keep palette,type] \
--options 3
```
This deals three directions and prints a short summary of each. Hold each one against your inventory:
which way of composing can carry something only this client has? Choose that one, not the most
familiar, and write one line in your notes on why. In testing, a single random direction that did not
suit the subject was the commonest cause of a weak page. Then deal the full sheet:
```bash
node <skill>/scripts/draw.mjs <the same arguments, without --options> --direction <chosen id>
```
- `--sector`: `node <skill>/scripts/draw.mjs --list sectors`. Pick the closest.
- `--sections`: roles from `--list roles` (features, showcase, how-it-works, testimonial, proof, stats,
pricing, faq, cta, footer, intro, about, work, gallery, list, contact). The hero is implied.
- `--tone` (optional): quiet, bold, warm, technical, playful, luxury, editorial. Only pass it when the
brief clearly asks for one.
- `--have`: assets that really exist, comma-separated: `photography` (the project's own photographs of
the place, people or work), `product-photo` (photographs of the specific product, or an image tool
that can make them), `product-ui` (real screenshots), `illustration`, `video`. Leave it out if you
have none: the draw then avoids compositions that depend on them and the sheet says how to fill
picture slots honestly (software shows its own interface built in HTML; a product with no photographs
is drawn and set among real pictures of its world, never a fake product shot). Pictures you will
find in the open collections are not the project's own; leave them out of `--have`.
- `--keep palette,type`: when the brand already fixes colours or typefaces. The draw then deals only
the composition, and you map the brand's values onto the roles.
The command prints a **direction sheet**. It is the art direction for this build:
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
exist. Never redraw to get something more comfortable. Record every redraw in your report.
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
it wants five quotations and there are none), do not bend the content to fit. List the others with
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with
`--redraw section:<role>=<id>`.
- **Check the palette against the subject.** A dealt palette is a set of proportions (one ground, one
ink, one accent, fields) as much as a set of colours. If its colours fight the subject's own material
(candy pastels for a peat whisky, neon for a mountain hut), redraw the palette once; if the subject
has unmistakable colours of its own (its material, the paint of its street, the landscape outside
its windows), keep the dealt proportions and take the colours from the subject instead, and say so
in the report.
- Reprint it any time with `node <skill>/scripts/draw.mjs --show`. In a long session, re-read it before each
section you build.
### 4. Find the idea
The direction is a way of composing. The idea is what makes the page belong to this subject and no
other. Write this sentence in your notes and do not start building until both blanks are filled from
the subject itself:
> On this page, **[something that belongs to the subject]** becomes **[the device the direction calls for]**.
Places to look for the first blank:
- **Its output.** What does the thing produce? Put the real output on the page as material. (A camera
that prints poems delivered its copy as paper slips.)
- **Its object or shape.** What does it physically look like, in cross-section, in silhouette?
Enlarge it, crop it, make it the frame or the one motif.
- **Its name.** Can the letters hold a photograph, stack into a wall, split to two corners?
- **Its place.** Is there one view that makes someone want to be there?
- **Its ritual.** Is there a sequence the page can perform as it scrolls?
- **Its artefact.** What printed thing already exists in its world: ticket, menu, label, receipt,
invitation, folder, manual?
- **Its opposite.** Which other category owns the feeling this one lacks?
Fill the first blank from your inventory (step 1), not from the category. **The competitor test:** put
the nearest real competitor in the sentence (the bakery two streets away, another oak furniture maker,
another hut on the same mountain). If the sentence still works, the idea belongs to the category: a
loaf of bread is every bakery; the seven doors of the street this one is named after are only this one.
"Clean and modern" is not an idea. The sites the study's panel scored lowest were the ones whose idea
was recorded as "none beyond the standard formula".
**One live thing.** Give the page one thing the visitor can do, or that is different for each visit,
made from the idea: pick a wood and see the chair in it; type your name and see it cut in the
foundry's letters; set the loan and read the real monthly figure; see whether the hut's stove is lit
right now and how long until the last ferry; choose a size and see it against the room. In blind tests every winning page had one and the jurors named it first. Rules: it uses the subject's real content, it works on a phone with a thumb, it has a plain
fallback without script, it never blocks reading, and it is one thing, not a playground. It is
built in step 6 as part of the page, not added as motion in step 9.
**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
Decide what each one shows from your inventory before building: the fly-through passes through this
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
to anyone is a screensaver.
### 5. Write the words
Write the whole page as a copy deck (plain text) before markup, following `references/copy.md`:
- **Every headline fits in two lines or fewer at every width.** Do the arithmetic in copy.md §2 before
committing to a sentence: about 32 characters is safe everywhere at a confident size. The studied
gallery heroes have a median of 5 words and 32 characters.
- One supporting sentence, directly under the headline (32–48px below it) and shown with it, never
docked at the foot of the first screen or held back by an animation. When the headline is a name or
a figure of speech, this sentence says plainly what the thing is. One primary action, named for what
it does, as a real button on the first screen.
- **The first screen answers the visitor's first question in large type.** For an event: what it is,
when and where (the dates belong in the headline or at the second size, never only in small text).
For a product: what it is and what it costs or does. For a person: who and what they do. Every
section the visitor came for (the line-up, the menu, the rooms, the price) has its own heading and
its own place in the navigation.
- **Write the full content.** Every section the brief asks for delivers its information completely:
the whole menu with prices, each room with its own details, every speaker and talk, the price with
what it includes, an answer for each real question. Thin sections are the commonest reason a clean
page loses to a busier one.
- Sample details are expected where the brief leaves gaps (dish names, room names and rates, project
titles) so the design is seen at full density; list them in the report for the client to confirm.
Proof is never invented: no customers, quotes, reviews, press, logos or business statistics.
### 6. Build it, still
Build the page with motion switched off in your head; a page that needs animation to look finished is
not finished. Order of work:
1. **Tokens.** Colour and type roles from the sheet as CSS custom properties. Everything downstream
references tokens only. `<skill>/assets/base.css` is a neutral foundation you may start from (reset, token
names, fluid type steps, grid, focus and selection styles, the motion helpers); it has no look of
its own.
2. **The hero, exactly as the blueprint describes it,** at desktop and at phone width. Get the big
thing big and the small things small before anything else exists.
3. **Each section from its blueprint,** with this project's content. Keep the geometry, change the
subject. Neighbouring sections must not share a layout. **Carry the idea into every section,**
pricing, FAQ and footer included: the commonest failure in the study, even among good sites, is a
distinctive first screen followed by stock blocks.
4. **Imagery,** in this order of preference:
1. the project's own;
2. generated, if you have an image tool;
3. **real pictures of the subject's world from the open collections,** whenever an honest one
exists. Search them with `node <skill>/scripts/assets.mjs "<query>"` (Openverse, Wikimedia
Commons, the Art Institute of Chicago and the Met; only licences that allow commercial use, by
default). Search the inventory, not the category: its street in old postcards, an old map of the
neighbourhood, its material up close, its trade in engravings, a museum object of its kind, a
botanical plate, a star chart. Use them large and with intent: one archival photograph as the
hero, an engraving enlarged into the page's motif, the real map of the street as the contact
section. Look at each before choosing; `--get <ref>` downloads it into `assets/` and records
its source and licence, and `--credits` prints the credit line for the footer. A found picture
shows the world around the client and is never presented as the client's own product, room,
dish, person or team;
4. **drawn in code from the subject itself,** for what no honest picture exists of (its objects,
its tiles, its windows, its product's interface, made as one consistent flat graphic language in
the palette's real colours);
5. the photographs dealt on the sheet, only where an honestly generic picture fits (the sea for a
sea house), never as a stand-in for the subject;
6. type and colour alone.
When the project has footage or a real picture of its own process or product, the first screen
shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
the pictures come from or what they are not ("none of these shows our bar"): a caption says what
the picture is, and the credit line sits small in the footer. A portfolio whose work is invented
for the brief draws each piece as a finished still of that work (its title card with its own type,
an end frame, a poster), with real detail, never as a pictogram of the client's product.
**The floor, which the inspector checks:** at least four real pictures on the page, usually six to
twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
cropped interface fragments count too). SVG drawings do not count toward the floor: draw what no
picture exists of, and still carry real pictures. Add one moving picture wherever an honest clip
exists: search it with `assets.mjs --video "<query>"` and use it as a muted loop with a poster
frame. Models skip pictures and footage because finding them is work; the page fails without them.
`references/imagery.md` covers searching, licences and credits, video, drawing, crops and treatments.
5. **The live thing** from step 4, built into the section where it proves the claim (often the hero
or the booking), with real content, a thumb-sized phone version and a plain fallback.
6. **The scroll scenes,** when the sheet deals them (section 9), each with the section it carries: the page's big
moments, the camera that flies through the work, the picture that opens to the whole screen, the
word that buckles under the scroll. Copy the shared stage and the scene's files from
`<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
page's tokens so it takes the page's look. A scene is composition: it decides how long the page is
and what each screen shows, so it is built now and seen in every look round, not added at the end.
`references/scenes.md` says how to put one on a page, fill it, fit it and check it;
`assets/scenes/demo/<id>.html` is a working example of each.
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
Running text is in the full ink at 16px or more; grey is for secondary notes.
8. **The frame.** The page has one pair of equal outer margins at every width, and every section uses
the width the page has set up: when its text sits in the left half, the right half holds its
picture, its second column, its list or its figure, never nothing. Asymmetry lives inside the grid,
never in the frame: a void is placed only when something closes it on the far side. No stretch of
the page taller than about half a screen holds nothing at all; sections are 120–240px apart on
desktop. Every section the visitor came for opens with a heading that names it, and the first
screen carries the page's title in an `h1`. These are what readers name first when a page looks
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
inspector measures each of them.
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
its own composition.
Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
behind a decision; the laws below are the short form.
### 7. Gate
```bash
node <skill>/scripts/lint.mjs <source files or folder>
node <skill>/scripts/inspect.mjs <url or path/to/index.html>
node <skill>/scripts/scenes.mjs <url or path/to/index.html> # when the page has scroll scenes
```
`scenes.mjs` photographs every scroll scene at 0, 10 … 100% of its hold, on desktop and on a phone,
and the reduced-motion page; with scenes on the sheet, `inspect.mjs` also films the whole scroll
(`desktop-film-NN.jpg`). A scene that never started or rests on desktop is a `FAIL`.
`lint.mjs` scans source for banned patterns and stock phrases. `inspect.mjs` opens the page in a real
browser at 1440, 768 and 390 pixels, measures it (headline line counts, type sizes, fonts actually
loaded, colours, rules, shadows, gradients, contrast, overflow, and the frame: equal margins, sections
huddled on one side, blank stretches, the title, untitled sections, text against the screen edge) and
saves screenshots to `.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with the meter and do not
hide things from it. The inspector serves local files over a throwaway local server; if the page must
also open straight from the file system, open `index.html` that way once before you finish.
### 8. Look
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
screenshots the inspector lists (every viewport, with your image viewer or Read tool) and critique them
with `references/critique.md`: the fourteen passes, the Awwwards-weighted score, the caps. Write the critique
down in its format, fix the top three problems, run the inspector again, look again. Three rounds at
most.
Judge only the pictures. If a problem is visible in a screenshot, it is real, whatever the code says.
### 9. Move
Now build the motion layer. It is required, and it is more than one fade: the inspector counts the
elements that really move while the page loads and is scrolled, and fails a page under eight (it warns
under sixteen). Any scroll scenes are already built (step 6); around them the layer has four parts:
1. **The signature move** named on the sheet: one, done well (a dealt scene can be it).
2. **An entrance for the parts of every section:** the heading, then the text, then the picture,
staggered by 60–90ms, rising 16–32px with opacity, triggered once as the section enters. Not one
fade for the whole block, and never content that stays hidden if the script fails.
3. **Feedback on every control:** press, hover and focus on buttons, links, cards and the live thing.
4. **One moving picture:** a found clip as a muted loop, a slow scale or drift on a photograph, or a
scroll-linked move on the hero picture.
Transform and opacity only; every move has a `prefers-reduced-motion` fallback that keeps the content
and drops the travel. A move that changes the colour or the layout of whole screens (colour by
chapter, a pinned story) is part of the composition, so build it in step 6 and let every look round see
it. `references/motion.md` has the tokens and tested recipes. Then run the inspector once more and check
every screenshot for content that failed to appear.
### 10. Report
Do not report until all of these are true; each one was skipped in a test run that came out badly:
- `inspect.mjs` shows **0 FAIL** at 1440, 768 and 390, on the final version of the page.
- You opened every screenshot it listed after the last change, not only the first screen.
- With scroll scenes: `scenes.mjs` ran on the final page with no problems, and you read every sheet
(desktop, phone, reduced motion).
- A stranger reading the first screen for five seconds can say what this is and what to do.
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
- The critique of your last round is written down in the shape of `references/critique.md` §4.
Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
sentence and the live thing; the final scores from your last critique round and what still falls short;
every found picture and clip with its title, creator, licence and source page (`assets.mjs --credits` prints
the list) and where its credit sits on the page; which bundled photographs and which drawings stand in
for photography (so they can be replaced); and any sample content that must be swapped before launch.
Do not claim a check you did not run.
## House rules
These hold on every build, whatever the direction. `inspect.mjs` measures them.
| Rule | Instead |
| --- | --- |
| **Headlines: two lines or fewer, at every width.** Every `h1`–`h6`, and the main line of the first screen whatever its tag. | Cut words. Then widen the measure. Size last. |
| **A clear title on the first screen**, in the page's one `h1`, and a heading on every section. | Say what this is: the name with what it does, the product with what it is for, the event with its date and place. |
| **Equal outer margins; no section huddled on one side** with the other half of the screen empty. | Close every void with something on its far side: the section's picture, a second column, a list, a figure. |
| **No blank stretches** taller than about half a screen. | The normal section gap. A deliberate rest is a placed picture or a field of colour, once per page. |
| **Statements are paragraphs.** An intro sentence or a quotation set large is marked up as `<p>` or `<blockquote>`, stays at statement size (36px or less on desktop, 24px on a phone) and runs to about 25 words, three lines. | Larger than that it is a headline and the two-line rule applies. In blind tests jurors read a 42px sentence over four lines as a four-line headline. |
| **No eyebrows.** No small label above a heading, no pill badge above the headline, no "New ·" chip. | Put the information in the heading or the first sentence, or drop it. |
| **No monospace** outside real code blocks. | The text face. Tabular figures where numbers align. |
| **No index numbering.** No 01 / 02 / 03, no (001), no "Step 2". | Order shown by position, size, or the words. A real `<ol>` if order matters. |
| **No thin rules.** No hairline dividers, no 1px outlined boxes, no grid lines, crosshairs or crop marks. | Space. A step in background tone. A change of scale. A filled shape. |
| **No gradients.** Not as backgrounds, not on buttons, not in text. | Flat colour, or a real photograph. A neutral scrim under text on a photo is the one exception. |
| **No shadows on things at rest.** No glows. | A tone step between surfaces. Shadows only on what truly floats: a menu, a dialog. |
| **No assistant palette.** No yellow-cream ground with terracotta or rust; no violet or indigo. | The dealt palette. |
| **No tiny uppercase letter-spaced labels.** | Sentence case at a readable size. |
| **No icon-topped feature cards, no rows of identical cards.** | The dealt section blueprint. |
| **No glass.** No backdrop blur panels, no floating pill navigation. | A plain bar: wordmark, a few links, one action. |
| **No emoji, no gradient blobs, no decorative shapes, no fake dashboards of grey bars.** | A real picture, a real interface fragment, or nothing. |
| **No styled accent word** in a headline (italic, serif, coloured, underlined). | One face, one colour. The words carry the emphasis. |
| **Two typefaces at most,** usually one. | The dealt type system. |
Three places keep their own conventions and are left alone by the inspector: real code in
`<pre>`/`<code>` (monospace is correct there), real tables and form fields (they may have lines), and
a real interface fragment whose root carries the attribute `data-ui` (the product's own borders and
menus; see `references/imagery.md` §7).
Why these, honestly. Some are what the study measured: among the 247 sites, those the panel scored
7.5 or higher used icon feature cards 1% of the time against 28% for sites under 6.5, pill badges 14%
against 34%, gradients 22% against 52%, shadows 24% against 48%, tiny tracked caps 16% against 57%.
Others are the owner's taste and go against current gallery fashion: monospace labels, index numbers
and hairline grids are actually *more* common on award sites than on weak ones, which is exactly why
generators imitate them and why they now read as costume. They are banned here without exception.
`references/tells.md` keeps the dated registry.
Removing ornament leaves space. Never fill it with another ornament. Fill it with scale (make the main
thing larger), with the full content, with a picture made for this subject, or with a field of flat
colour. Leave it empty on purpose once per page at most.
## Laws of the craft
Short form. Numbers are from the study unless marked as house style.
**Type**
- Two or three sizes do the visible work: one very large, one small, nothing in between. Median
headline-to-body ratio on the studied sites is 4.3; the best go far beyond (largest type: median
8.4vw on the top sites against 5.0vw on the weak ones).
- Display type: tracking -0.02 to -0.05em, leading 0.85–1.05. Text: 16–20px, leading 1.45–1.6,
45–75 characters per line.
- Five sizes on a page is the ceiling. One family is normal (a third of the studied sites); two is the
limit.
- Left-aligned by default. Centre at most one moment.
**Space**
- One grid, one pair of tight margins (16–32px), used everywhere; large things run to the edges. Text
blocks share two or three left edges on the whole page.
- Asymmetry inside the grid, never in the frame: the two outer margins are equal, and an off-centre
heading is answered by something at the far side of the grid.
- The gap between sections is clearly larger than any gap inside one: 120–240px on desktop (the study's median is about 240).
- Each section has a different composition from its neighbours; none is a card grid.
**Colour**
- One ground, one ink, one accent. The accent appears on the action and perhaps one other thing.
- Running text in the full ink. A page set in soft grey reads unfinished.
- Surfaces are separated by a small step in tone, never by a line or a shadow.
- A section may change the whole field colour. Nothing fades into anything.
**Imagery**
- One dominant image beats six small ones. On the top-scored sites the hero media covers at least half
the first screen 68% of the time.
- Same crop ratios, same treatment, square corners unless the direction says otherwise (73% of the
studied sites leave media square).
- No image is better than a placeholder, an icon, or an abstract shape. A picture drawn for this
subject, in its colours and with care, is better than either. A real picture of its world, found
and credited, is better still.
- Every found picture is downloaded into the project, credited on the page and listed in the report.
None is presented as the client's own.
- Nothing ever lies on top of a headline or a statement (the inspector fails it).
**Components**
- Buttons: flat, one fill colour, either square-cornered or fully round, decided once (top sites: 52%
square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
button at all, or one.
- Navigation: wordmark, three to five plain links, one action. No bar chrome.
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
## Special cases
**An existing brand or design system.** Pass `--keep palette,type`, map the brand's colours and faces
onto the roles, and take only composition from the draw. The house rules still apply; if the brand
itself requires a banned device (its logo sits in a gradient, its accent is violet), keep the brand
asset, say so in the report, and pass `--allow <rule>` to the inspector for that rule only.
**Redesign, restyle or review of an existing page or app screen.**
1. **Record the before.** Run the project as it is and inspect it into its own folder
(`inspect.mjs <url> --out .taste-skill/before`, with `--app` for an app screen) and lint its
source. Read the code: the framework, the styling method (Tailwind v3 or v4, CSS modules,
styled-components, plain CSS), the components, and what must survive: routes, forms, data,
behaviour, the brand's own assets.
2. **A review stops here.** Critique the before screenshots with `references/critique.md`, list the
findings with the inspector's measurements, and name the three changes that would move the page
most.
3. **Keep the stack, the content and what works.** No framework or styling-library migration; check the
dependency file before adding anything. A restyle keeps the layout and takes type, colour and
components from a dealt direction (`--keep` whatever the brand fixes); a redesign deals a full
direction and rebuilds the sections from its blueprints inside the existing components. Either way
proceed from step 2 of the run with the page's real content.
4. **Order of work, biggest effect for least risk:** type (faces and scale), colour (one ground, one
ink, one accent), the frame (margins, section rhythm, a title on the first screen and a heading on
every section), states (hover, focus, loading, empty, error), then the stock components (icon
cards, pill badges, gradient blobs, card grids) replaced by the dealt blueprints.
5. **Prove it.** Inspect again into `.taste-skill/after`, compare before and after at every width,
click through every link, form and menu that worked before, and report the before and after
measurements side by side.
**A component or a single section, not a page.** Skip the draw unless there is no direction to follow.
Match the surrounding design, apply the house rules and the laws, and still inspect the result.
**No checkout, no form backend.** The primary button goes to the real destination when the brief names
one. Otherwise it opens an email with a written subject and body (`mailto:` with the visitor's choices
filled in), or, for a shop, it points at `#checkout` and the report says it must be wired before
launch. Never fake a payment form.
**No Chrome or Node 22.** Run `lint.mjs` (Node 18+), take screenshots with whatever browser tool you
have, and critique those. Say in the report that the inspector could not run.
**No network.** If `assets.mjs` reaches no source, draw the pictures and say in the report that the
open collections could not be searched.
## Files
| File | Read it when |
| --- | --- |
| `references/app-screens.md` | Any dashboard, admin, settings or other app screen: its own run, rules, tokens and checks. |
| `references/copy.md` | Writing the argument and the copy deck (steps 2 and 5). The two-line arithmetic. |
| `references/type.md` | Setting type: scales, tracking, measure, loading fonts, pairing. |
| `references/colour.md` | Applying the palette: roles, proportions, tone steps, dark pages, contrast. |
| `references/layout.md` | Grid, margins, rhythm, section variety, navigation, footer, the phone composition. |
| `references/imagery.md` | Choosing, finding, crediting, cropping and treating pictures; what to do with none. |
| `references/motion.md` | Step 9. Budget, tokens, recipes, reduced motion. |
| `references/scenes.md` | Step 6, when the sheet deals scroll scenes: putting one on a page, filling, fitting, the phone, checking, the catalogue. |
| `references/critique.md` | Step 8. How to look, how to score, what to fix first. |
| `references/tells.md` | The dated registry of generated-page tells and what each is replaced by. |
| `references/research.md` | The study: method, numbers, what the best and worst sites do. Cite it, do not guess. |
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references; `--app` deals an app style instead. |
| `scripts/assets.mjs` | Step 6: searches open collections for real pictures of the subject's world; `--get` downloads with the credit, `--credits` prints them. |
| `scripts/lint.mjs` · `scripts/inspect.mjs` | The gate. |
| `scripts/scenes.mjs` | The gate for scroll scenes: each scene at 0-100% of its hold, desktop, phone, reduced motion. |
| `assets/base.css` · `assets/base.js` | An optional neutral foundation to build on. |
| `assets/scenes/` | The scroll scenes: the shared stage (`stage.js`, `stage.css`) and, for each scene in the library, its `.js`, `.css` and a working demo page in `demo/`. |
| `assets/app.css` · `assets/charts.js` | For app screens: a neutral shell, panels, tables and controls, and small SVG charts drawn to the app rules. |
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions, scroll scenes (`scenes.json`), and the app styles, shells and screen patterns (`app.json`). Do not read it whole; use the script. |
@@ -0,0 +1,216 @@
/* tasteskill / app.css
A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults
measured in the study of real product screens (references/app-screens.md). Copy it into the project,
replace the token values with the ones on the app sheet (draw.mjs --app), and build on top.
Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */
/* ---------- tokens (replace with the dealt style) ---------- */
:root {
/* names match the app sheet printed by draw.mjs --app */
--ground: #f9fafb; /* the app chrome and page */
--surface: #ffffff; /* panels and the work sheet */
--raised: #f4f5f6; /* a filled inner well, hover rows */
--active: #eeeeee; /* the active nav pill */
--border: #e8e8e8; /* panel edges: about 1.2:1 on white */
--rule: #f1f2f3; /* row rules inside tables */
--control: #d1d5db; /* inputs and outlined buttons: one step stronger */
--text: #111111;
--muted: #5f6368; /* secondary text, labels (4.5:1 or more on its ground) */
--faint: #6b6b6b; /* meta and placeholders: the lightest grey that holds 4.5:1 on white */
--accent: #1f6f5c; /* one hue: data, focus, the selected thing */
--primary: #1d1f20; /* the primary button is near-black unless the style says otherwise */
--primary-ink: #ffffff;
--positive: #247b50; --negative: #c4242a; --warning: #92400e; /* references/app-screens.md §9.5; a green accent is also --positive */
--pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e;
--pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e;
--pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e;
--pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d;
--chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6;
--chart-mark: var(--text); /* a marked period in Charts.diverging: the second tone */
--chart-span: 0.06; /* opacity of a shaded span (a selling window, an outage) */
--grid: var(--rule); /* chart gridlines (charts.js) */
--axis-ink: var(--muted); /* chart tick labels (charts.js) */
--ink: var(--text);
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px;
--sidebar-width: 232px;
--pad: 24px; /* canvas padding */
--gap: 16px; /* one gutter, horizontal and vertical */
--card-pad: 20px;
--radius-card: 8px; /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */
--radius-control: 6px;
--control-height: 32px; /* every control in the title and filter rows */
--row-height: 48px; /* single-line table row */
--nav-row: 32px; /* sidebar item height, on a 34px pitch */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms;
}
/* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */
:root[data-theme="dark"] {
--ground: #000000; --surface: #111111; --raised: #222222; --active: #222222;
--border: #1a1a1a; --rule: #161616; --control: #444444;
--text: #ffffff; --muted: #b4b4b4; --faint: #949494;
--primary: #ffffff; --primary-ink: #000000;
--positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b; /* a green or yellow-green accent is also --positive */
--pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a;
--pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f;
--pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b;
--pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff;
--chart-muted: #4a4a4a; --grid: #2b2b2b;
--chart-span: 0.12;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; }
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.num, table, .kpi-value { font-variant-numeric: tabular-nums; }
/* ---------- shell: sidebar + canvas ---------- */
.app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; }
.sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; }
.sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); }
.nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; }
.nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; }
.nav-item:hover { background: var(--raised); color: var(--text); }
.nav-item[aria-current="page"] { background: var(--active); color: var(--text); }
.sidebar .bottom { margin-top: auto; }
.canvas { min-width: 0; padding: var(--pad); }
/* ---------- title block and filters ---------- */
.title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.title-row .sub { margin-top: 4px; color: var(--muted); }
.filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; }
.filters .spacer { flex: 1; }
.stamp { color: var(--faint); font-size: var(--fs-small); }
/* ---------- panels and grid ---------- */
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; }
.panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; }
.panel .meta { color: var(--faint); font-size: var(--fs-small); }
/* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps
its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */
.grid.top { align-items: start; }
/* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */
.chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; }
.chart { position: relative; min-width: 0; }
/* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover,
and no inline padding, so its text sits on the card's content edge */
.card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; }
.card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; }
.card-link:hover { text-decoration: underline; }
:root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
/* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row
that carries a rule has square corners */
.rows { display: grid; }
.row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--rule); border-radius: 0; }
/* KPIs as one divided strip: a label above a number, no icons, no tiles */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); }
.kpi { padding: 16px var(--card-pad); }
.kpi + .kpi { border-left: 1px solid var(--border); }
.kpi-label { color: var(--muted); }
.kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; }
.kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; }
.delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); }
.delta .up { color: var(--positive); } .delta .down { color: var(--negative); }
/* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and
their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity
breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */
.kpis[role="tablist"] .kpi { position: relative; cursor: pointer; }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; }
/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
/* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */
td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; }
tbody tr:last-child td { border-bottom: 0; }
/* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */
tbody tr[aria-selected="true"] td { background: var(--active); }
th.n, td.n { text-align: right; }
td .sub { display: block; font-size: var(--fs-small); color: var(--muted); }
.truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* status pill: a pale tint with dark ink of the same hue, one per row */
.pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; }
.pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
.pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); }
.pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); }
/* ---------- controls ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); }
.btn:hover { background: var(--raised); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; }
.btn.ghost { border-color: transparent; background: transparent; }
.field { display: grid; gap: 8px; }
.field label { font-weight: 500; }
.field .hint { color: var(--muted); font-size: var(--fs-small); }
.input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); }
.input::placeholder { color: var(--faint); }
.tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); }
.tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; }
.tabs a[aria-selected="true"] { color: var(--text); }
.tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
/* as wide as its options; full width only on a narrow phone */
.segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); }
.segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
.switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); }
.switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); }
/* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */
.empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; }
.skeleton { background: var(--raised); border-radius: 4px; }
/* ---------- phone: the navigation folds away and the canvas takes the width ---------- */
.menu-toggle { display: none; }
@media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); }
/* the menu opens as a full-screen sheet */
.sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); }
.sidebar.open { transform: none; }
.menu-toggle { display: inline-flex; }
.canvas { padding: 16px; }
.grid > * { grid-column: 1 / -1 !important; }
.kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi + .kpi { border-left: 0; }
.kpi:nth-child(n + 3) { border-top: 1px solid var(--border); }
.kpi:nth-child(2n) { border-left: 1px solid var(--border); }
/* an odd count leaves no half-empty row: the last cell takes the full width */
.kpi:last-child:nth-child(odd) { grid-column: 1 / -1; }
/* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */
.kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; }
.title-row { flex-wrap: wrap; align-items: flex-start; }
}
@media (max-width: 480px) {
.segmented { display: flex; width: 100%; }
.segmented button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } }
/* an empty message spans the block it stands in, never one squeezed column */
.rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; }
@@ -0,0 +1,105 @@
/* tasteskill / base.css
A neutral foundation: reset, token names, fluid type steps, a 12-column grid, states and the motion
helpers from references/motion.md. It has no look of its own. Copy it into the project, replace the
token values with the ones on the direction sheet, and build on top. Delete what you do not use. */
/* ---------- tokens (replace with the dealt palette and type system) ---------- */
:root {
--bg: #ffffff;
--surface: #f2f2f2;
--ink: #111111;
--muted: #5c5c5c;
--accent: #111111;
--accent-ink: #ffffff;
--font-display: system-ui, sans-serif;
--font-text: system-ui, sans-serif;
--step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem);
--step-body: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
--step-lead: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);
--step-head: clamp(2rem, 1.3rem + 3.2vw, 4rem);
--step-display: clamp(3rem, 1rem + 9vw, 9rem); /* set from the hero blueprint */
--margin: clamp(1rem, 2.2vw, 2rem); /* page side margin: tight, so content reaches the edges */
--gutter: clamp(1rem, 2vw, 2rem); /* column gap */
--section: clamp(5rem, 12vw, 13rem); /* space between sections */
--radius: 0px; /* one radius for the whole page: 0, or large, decided once */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals of 800ms and longer, line rises, openings */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */
--t-press: 140ms;
--t-hover: 180ms;
--t-ui: 240ms;
--t-reveal: 800ms;
--t-open: 1200ms; /* picture openings and clip wipes */
--stagger-line: 90ms;
--stagger-item: 70ms;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: auto; }
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font-text); font-size: var(--step-body); line-height: 1.5; font-kerning: normal; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, video, svg, canvas { display: block; max-width: 100%; }
img, video { height: auto; background: var(--surface); }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; }
:target { scroll-margin-top: 6rem; }
::selection { background: var(--accent); color: var(--accent-ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* ---------- type ---------- */
h1, h2, h3 { font-family: var(--font-display); font-weight: inherit; text-wrap: balance; hyphens: none; }
.display { font-size: var(--step-display); line-height: 0.95; letter-spacing: -0.035em; }
.head { font-size: var(--step-head); line-height: 1.02; letter-spacing: -0.025em; }
.lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; }
.small { font-size: var(--step-small); line-height: 1.45; }
.muted { color: var(--muted); }
p { max-width: 62ch; text-wrap: pretty; }
p + p { margin-top: 0.9em; }
.nums { font-variant-numeric: tabular-nums; }
.only-wide { display: none; }
@media (min-width: 48em) { .only-wide { display: inline; } }
/* ---------- layout ---------- */
.wrap { padding-inline: var(--margin); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
.section { padding-block: calc(var(--section) / 2); }
.section + .section { margin-top: 0; }
.bleed { margin-inline: calc(var(--margin) * -1); }
.cover { width: 100%; height: 100%; object-fit: cover; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ---------- controls ---------- */
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: 0 1.4rem; background: var(--accent); color: var(--accent-ink); border-radius: var(--radius); text-decoration: none; white-space: nowrap; transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
.button:active { transform: scale(0.97); }
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
.link-plain { text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 0.07em; }
@media (hover: hover) and (pointer: fine) {
.link:hover { text-decoration-color: currentColor; }
}
.field { width: 100%; min-height: 3rem; padding: 0.7rem 0.9rem; background: var(--surface); border: 0; border-radius: var(--radius); font-size: max(1rem, var(--step-body)); }
/* ---------- motion helpers (content stays visible without JavaScript) ---------- */
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.lines [data-line] { display: block; }
.shutter { overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
.js [data-rise] { opacity: 0; transform: translateY(18px); animation: ad-rise var(--t-reveal) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 90ms + 120ms); }
.js .lines [data-line] { transform: translateY(105%); animation: ad-line 900ms var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 110ms + 150ms); }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
.js .shutter[data-reveal] { opacity: 1; transform: none; clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
.js .shutter[data-reveal] img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
.js .shutter[data-reveal].is-in { clip-path: inset(0); }
.js .shutter[data-reveal].is-in img { transform: none; }
}
@keyframes ad-rise { to { opacity: 1; transform: none; } }
@keyframes ad-line { to { transform: none; } }
@@ -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,617 @@
// tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data.
// They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet
// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no
// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables:
// --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default),
// --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track),
// --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative
// Usage (after this file loads):
// Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact })
// Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices]
// Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' })
// Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside
// Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar
// Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent
// Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target
// Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark
// Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day
// Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day
// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans
// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no
// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its
// label set where no series passes.
// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed)
// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a
// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat:
// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step.
// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more:
// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in
// the card's header, or pass legend: element (or a selector) to have it filled.
// Numbers follow the page's language (<html lang>). Every chart gets role="img" and an aria-label that states what it shows.
(() => {
const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0);
const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb;
const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; };
const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; };
const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; };
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
const fmt = {
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v),
ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`,
// a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4'
signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)),
// minutes since midnight as a 24-hour time: 680 → '11:20'
clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter
};
const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number)));
// Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value.
function ticks(min, max, count = 4) {
if (max === min) max = min + 1;
const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag;
const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step;
const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10));
return out;
}
// x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that
// lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels
// ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00.
function xPicks(labels, span, at) {
const n = labels.length;
if (at) return at.filter((i) => i >= 0 && i < n);
if (n <= 1) return n ? [0] : [];
const longest = Math.max(...labels.map((l) => String(l).length));
const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18))));
if (n <= cap) return [...Array(n).keys()];
const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null;
const d = mins ? mins[1] - mins[0] : 0;
let first = null, land = null;
for (let k = 1; k < n; k++) {
const stepM = k * d, clock = mins && d > 0;
if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue;
// start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps
let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0;
if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0));
const count = Math.floor((n - 1 - s) / k) + 1;
if (count > cap) continue;
if (first == null) first = { k, s };
if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; }
}
const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 };
const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i);
return out;
}
// colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step)
const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none';
const rgb = (el, value) => {
el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value;
const c = getComputedStyle(swatchProbe).color; swatchProbe.remove();
const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null;
return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number);
};
const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; };
// text width, measured in the chart's own face (falls back to an estimate when the chart is hidden)
const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; };
const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; };
function frame(el, h) {
el.innerHTML = '';
const w = Math.max(240, el.clientWidth || 600);
const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el);
return { s, w, h };
}
function tooltip(el) {
let t = el.querySelector('.chart-tip');
if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); }
return t;
}
function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) {
const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
for (const v of yt) {
const yy = y(v);
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink });
}
const n = labels.length;
for (const i of xPicks(labels, w - padL, at)) {
const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i);
// the first and last labels are pulled inside the plot; none runs past either edge
let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2;
left = Math.max(band ? 0 : padL, Math.min(w - tw, left));
if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue;
text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink });
}
}
// Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader
// line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps
// it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched
// (close markers often need the first label on the left so the next leader runs clear): full labels first, then
// short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console.
function lane(s, items, w, maxRows = 3) {
const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2);
// fixed captions first, then spans, then markers from left to right
const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0));
const opts = order.map((it) => {
const out = [];
for (const str of [it.label, it.short].filter(Boolean)) {
const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0;
const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]];
for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 });
}
return out;
});
const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap)
&& !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3)
&& !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3)));
let best = null, steps = 0;
const solve = (i, placed, allowShort, allowDrop) => {
if (++steps > 20000) return false;
if (i === order.length) { best = placed.slice(); return true; }
for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
return false;
};
for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; }
const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped);
return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 };
}
function prepNotes(s, annotations = [], x, w, extra = []) {
const items = [];
for (const a of annotations || []) {
if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour });
else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour });
}
items.push(...extra);
return { items, ...lane(s, items.filter((it) => it.label), w) };
}
// shaded spans go under the data; marker lines and the label rows over it
function notesUnder(g, el, N, { padT, h, padB }) {
const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07);
for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g);
}
function notesOver(g, el, N, { padT, h, padB }) {
const ink = css(el, '--axis-ink', '#6b6b6b');
// a marker line runs to the plot's foot, or stops above the bar it points at (it.end)
for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
for (const p of N.placed) {
const base = padT - 9 - p.row * ROW;
if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g);
text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink });
}
const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set();
for (const d of N.dropped) {
if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); }
if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); }
}
}
// does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 }
function hits(b, lines) {
for (const pts of lines) for (let k = 1; k < pts.length; k++) {
const a = pts[k - 1], c = pts[k]; if (!a || !c) continue;
if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; }
const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0]));
for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; }
}
return false;
}
const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3);
// a reference line (a budget, an SLO) with its label at an end where no series passes
function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) {
const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
for (const r of [].concat(refs || [])) {
const yy = y(r.value);
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g);
if (!r.label) continue;
const tw = measure(s, r.label, 11), right = r.at !== 'left';
const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy }));
const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0];
text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink });
}
}
// One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart.
function paintRect(parent, st, x, y, w, h, r = 0) {
let fill = st.fill || 'none';
if (st.hatch) {
const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8);
const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s));
svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p);
fill = `url(#${id})`;
}
const inset = st.stroke ? (st.width || 1) / 2 : 0;
const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent);
if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent);
return n;
}
const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}';
function legendEl(items, target) {
if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); }
const ul = document.createElement('ul'); ul.className = 'chart-legend';
for (const it of items) {
const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' });
it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li);
}
const t = typeof target === 'string' ? document.querySelector(target) : target;
if (t) t.replaceChildren(ul);
return ul;
}
const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r);
// charts are laid out with measured text: draw again once the page's fonts have arrived
function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } }
const heightOf = (h) => (typeof h === 'function' ? h() : h);
function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) {
el._fmt = format;
const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3')));
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value));
// max caps the range (a flat series with one spike): values above it are clipped and their peak labelled
const top = max ?? Math.max(...all);
const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4);
const tick = tickFormat || format;
const lastVal = series[0].values.filter((v) => v != null).at(-1);
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0;
const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1);
// the peaks clipped by max are labelled in the rows above the plot, where the line leaves it
const peaks = [];
series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } }));
const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; };
const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]]));
let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null;
// the today label goes inside the plot on whichever side of its line no series crosses; failing that, above
if (today != null && todayLabel) {
const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y);
const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b }));
todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null;
if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); }
}
const g = svg('g', {}, s);
axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick });
notesUnder(g, el, N, { padT, h, padB });
series.forEach((sr, k) => {
const col = colourOf(sr, k);
const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))]));
// step: a balance that only changes on given days holds its value until the next one
const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), '');
if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); }
if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g);
else {
// a forecast: the line after today is dashed, so a projection never reads as a fact
const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8);
const defs = svg('defs', {}, g);
const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a);
const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b);
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g);
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g);
if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); }
}
if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); }
});
references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) });
notesOver(g, el, N, { padT, h, padB });
// hover: a rule, a dot per series and a bare tooltip
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
const last = series[0].values.filter((v) => v != null).at(-1);
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
};
draw(); watch(el, draw); settle(draw);
const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null;
return { legend: lg };
}
function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) {
el._fmt = format;
const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i);
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format;
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62);
const x = (i) => padL + band * i + band / 2;
const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows);
const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1));
const g = svg('g', {}, s);
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick });
notesUnder(g, el, N, { padT, h, padB });
for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4;
const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9');
values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); });
references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) });
notesOver(g, el, N, { padT, h, padB });
s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`);
};
draw(); watch(el, draw); settle(draw);
}
// Two directions on one scale: what went out above the zero line and what came in below it (sold and bought,
// income and spend). The axis says which is which, with signed ticks and a caption on each side of zero.
// mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged).
function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) {
if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); }
up = up || labels.map(() => 0); down = down || labels.map(() => 0);
const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } };
const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side);
const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v));
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down);
const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10));
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62);
const x = (i) => padL + band * i + band / 2;
// the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left
const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows);
const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0]));
const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b');
const bwid = measure(s, below, 11) + 9;
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] });
svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink });
notesUnder(g, el, N, { padT, h, padB });
for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4;
labels.forEach((lab, i) => {
const o = partial === i ? 0.5 : null;
if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); }
if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); }
});
svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g);
references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) });
notesOver(g, el, N, { padT, h, padB });
s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`);
};
draw(); watch(el, draw); settle(draw);
const items = [];
if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) });
if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) });
if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) {
el.innerHTML = '';
const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value));
const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`;
const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap);
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
wrap.appendChild(t); el.appendChild(wrap);
}
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
// label instead, ink for the top item, for a ranked list of money in a narrow card.
function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) {
el.innerHTML = '';
const max = Math.max(...rows.map((r) => r.value)) || 1;
const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`;
const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1;
rows.forEach((r, k) => {
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px';
const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right';
if (thin) {
const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`;
li.append(a, b, bar);
} else {
const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0';
const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`;
track.append(bar, a); li.append(track, b);
}
list.appendChild(li);
});
el.appendChild(list);
}
// Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is
// already committed (bills to come), and an overrun past the track's end in the negative colour.
// One row per category: label and figures on one line, the 8px track under them.
function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) {
el.innerHTML = '';
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums';
const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0))));
for (const r of rows) {
const over = r.spent > r.planned;
const li = document.createElement('li');
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
const pct = (v) => (v / r.planned) * 100;
const spent = Math.min(r.spent, r.planned);
track.innerHTML = `<span style="position:absolute;inset:0 auto 0 0;width:${pct(spent)}%;border-radius:999px;background:var(--chart-1,#2f6fdf)"></span>`
+ (r.committed ? `<span style="position:absolute;top:0;bottom:0;left:${pct(spent)}%;width:${Math.min(pct(r.committed), 100 - pct(spent))}%;background:var(--chart-1,#2f6fdf);opacity:.35"></span>` : '')
+ (over ? `<span style="position:absolute;top:-3px;bottom:-3px;left:100%;width:1px;background:var(--text,#111)"></span><span style="position:absolute;top:0;bottom:0;left:calc(100% + 2px);width:max(12px, ${pct(r.spent - r.planned)}%);border-radius:0 999px 999px 0;background:var(--negative,#c4242a)"></span>` : '');
track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`);
li.append(top, track); list.appendChild(li);
}
el.appendChild(list);
}
// Share against a target: one row per part, the current share as a bar and a 1px tick at the target,
// so drift reads at a glance (never two stacked bars to compare).
function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) {
el.innerHTML = '';
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums';
for (const r of rows) {
const drift = r.value - r.target;
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
list.appendChild(li);
}
el.appendChild(list);
}
// shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline.
// A subline needs a 40-44px row pitch; one-line labels sit on 32px.
function rowLabel(g, s, el, r, { y0, pitch, lw }) {
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); }
else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink });
}
const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d));
// day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short
// label with today always shown and its neighbours left out, so no two headers ever touch
function dayHeads(g, s, el, days, { x0, cw, y, today }) {
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1)));
const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6;
const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw));
labs.forEach((t, i) => {
if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return;
text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted });
});
}
// The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one
// chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision).
function kindStyle(el, k = {}) {
const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf');
const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {};
const dash = k.dash ? { dash: '3 2' } : {};
if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash };
if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash };
if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash };
if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash };
return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash };
}
// Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind.
// rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }]
// and { group: 'Brand' } rows for a team subheader
// kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } }
// days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index;
// holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons.
// A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the
// days before it), and is always in the bar's tooltip.
function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) {
const draw = () => {
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30;
const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2;
const { s, w, h } = frame(el, height);
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2)));
const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw;
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf');
const g = svg('g', {}, s);
// weekends one tone step darker, faint day lines, the day header (today in ink)
for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g);
for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today });
for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); }
if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
let y0 = head;
for (const r of rows) {
if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; }
rowLabel(g, s, el, r, { y0, pitch, lw });
const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from);
bs.forEach((b, k) => {
const kind = kinds[b.kind] || {}, st = kindStyle(el, kind);
const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2;
const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g);
paintRect(grp, st, x0, by, x1 - x0, barHeight, 4);
const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink;
const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w;
// over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words
const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {};
if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo });
else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo });
else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted });
titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {});
if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); }
});
y0 += pitch;
}
s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`);
};
draw(); watch(el, draw); settle(draw);
const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind)));
const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } }));
if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
// Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare
// stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip.
// rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }]
// cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash)
function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) {
const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3);
const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; };
const draw = () => {
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26;
const { s, w, h } = frame(el, head + rows.length * pitch);
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22)));
const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111');
const g = svg('g', {}, s);
dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today });
rows.forEach((r, k) => {
const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw });
const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2;
r.values.forEach((v, i) => {
const cx = lw + i * cw + 1;
if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; }
const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3);
text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink });
titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {});
});
});
s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`);
};
draw(); watch(el, draw); settle(draw);
const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level)));
const ST = styles();
const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) }));
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
// When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and
// a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written.
// rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }]
// at or compare may be null (it did not sell out); step: minutes between axis labels (60).
function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) {
const o = minutes(open), c = minutes(close);
const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } };
const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent);
const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent);
const draw = () => {
const narrow = (el.clientWidth || 600) < 560, axisH = 24;
const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32);
const { s, w, h } = frame(el, rows.length * pitch + axisH);
const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26)));
const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0));
const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0);
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0');
const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16));
const g = svg('g', {}, s), plotH = rows.length * pitch;
// the axis: one step from the opening, the labels thinned by the same rule as the charts
const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m);
const picks = xPicks(marks.map(fmt.clock), x1 - x0);
for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); }
rows.forEach((r, k) => {
const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2;
if (narrow) {
const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0;
text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink });
if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 ');
if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted });
} else rowLabel(g, s, el, r, { y0, pitch, lw });
svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
const cm = minutes(r.compare), am = minutes(r.at);
if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {});
if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {});
if (narrow) return;
if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); }
else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
});
s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`);
};
draw(); watch(el, draw); settle(draw);
const items = [];
if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) });
if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); }
window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt };
})();
@@ -0,0 +1,28 @@
/* tasteskill / scenes / stage.css — the held stage under every scroll scene (references/scenes.md).
Without .is-live (no script, a failed CDN, reduced motion, a phone set to 'rest') a scene is an ordinary
section showing its finished composition. With it, the section is (hold + 1) screens tall and the
stage sticks to the viewport for the hold. */
.scene { position: relative; }
.scene > .scene-stage { position: relative; }
html.js .scene.is-live { height: calc((var(--hold, 2) + 1) * 100svh); }
html.js .scene.is-live > .scene-stage {
position: sticky;
top: 0;
height: 100svh;
overflow: clip; /* clip, not hidden: hidden would make the stage a scroll container */
contain: layout paint;
}
/* Everything a scene moves is composited; nothing it moves is laid out again. */
.scene.is-live [data-move] { will-change: transform, opacity; }
/* Lenis */
html.is-smooth { scroll-behavior: auto; }
html.is-smooth body { overscroll-behavior: none; }
@media (prefers-reduced-motion: reduce) {
html.js .scene.is-live { height: auto; }
html.js .scene.is-live > .scene-stage { position: relative; height: auto; }
}
@@ -0,0 +1,133 @@
// tasteskill / scenes / stage.js — the shared stage every scroll scene runs on (references/scenes.md).
//
// A scene is a tall section whose stage sticks to the viewport while the page scrolls past it; a GSAP
// timeline is scrubbed by that scroll. The page always scrolls natively (no wheel capture), the
// scrollbar always tells the truth, and the finished composition is what reduced motion, no script and
// a failed CDN all show.
//
// Load at the end of <body>, in this order (versions pinned):
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script> (only if a scene splits text)
// <script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script> (optional smoothing)
// <script src="assets/scenes/stage.js"></script>
// <script src="assets/scenes/<scene>.js"></script>
// and keep the one-line <script>document.documentElement.classList.add('js')</script> in <head>.
//
// Stage.smooth() weighted wheel scrolling (Lenis), off for touch and reduced motion
// Stage.scene(root, { hold, scrub, build, phone, rest })
// Stage.split(el, 'lines' | 'words' | 'chars') masked split text, SplitText when loaded
// Stage.ready() resolves when fonts and the scenes' pictures are ready
// Stage.init() starts every <section class="scene" data-scene="<id>"> on the page
//
// A page: Stage.smooth(); Stage.init();
(() => {
const html = document.documentElement;
const mq = (q) => window.matchMedia(q).matches;
const live = !!(window.gsap && window.ScrollTrigger);
if (live) gsap.registerPlugin(ScrollTrigger);
if (live && window.SplitText) gsap.registerPlugin(SplitText);
const Stage = {
live,
get reduced() { return mq('(prefers-reduced-motion: reduce)'); },
get phone() { return mq('(max-width: 767px)'); },
get touch() { return mq('(pointer: coarse)'); },
lenis: null,
scenes: [],
};
// Weighted wheel scrolling. lerp 0.1-0.14 feels heavy without lagging behind the hand; touch keeps the
// phone's own scrolling, and reduced motion keeps the browser's.
Stage.smooth = ({ lerp = 0.12 } = {}) => {
if (!live || !window.Lenis || Stage.reduced || Stage.touch || Stage.lenis) return Stage.lenis;
const lenis = new Lenis({ lerp, smoothWheel: true, anchors: true, autoRaf: false });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
html.classList.add('is-smooth');
Stage.lenis = lenis;
return lenis;
};
// One scroll scene.
// root <section class="scene"> holding one <div class="scene-stage">
// hold screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall
// scrub true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight)
// build(tl, ctx) add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q }
// phone 'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build }
// rest(ctx) optional: anything extra the finished composition needs (most scenes need nothing)
// Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too.
Stage.scene = (root, opts = {}) => {
if (!root) return null;
const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts;
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
const q = (sel) => [...root.querySelectorAll(sel)];
const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); };
if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; }
const mm = gsap.matchMedia();
mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => {
const isPhone = c.conditions.phone;
const ctx = { root, stage, phone: isPhone, q };
const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build };
if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); }
root.classList.remove('is-rest');
root.classList.add('is-live');
root.style.setProperty('--hold', plan.hold);
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true,
onUpdate: (self) => {
root.style.setProperty('--p', self.progress.toFixed(4));
if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats)));
},
},
});
plan.build(tl, ctx);
// pad the timeline to exactly 1 so positions in build() read as fractions of the hold
if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration());
return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); };
});
Stage.scenes.push(mm);
return mm;
};
// Masked split text. Lines rise out of their own mask; words and characters are for short headings.
Stage.split = (el, type = 'lines') => {
if (!el) return [];
if (window.SplitText && live) {
const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false });
return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars;
}
// fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `<span aria-hidden="true" style="display:inline-block">${w}</span>`).join(' ');
el.dataset.split = 'words';
}
return [...el.children];
};
// Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in.
Stage.ready = () => {
const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean);
return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); });
};
// Every <section class="scene" data-scene="<id>" data-options='{"hold":3}'> on the page, started by
// the scene file of that id (assets/scenes/<id>.js registers Scenes['<id>']).
Stage.init = () => {
for (const el of document.querySelectorAll('.scene[data-scene]')) {
if (el.__scene) continue;
const make = window.Scenes[el.dataset.scene];
if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; }
let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {}
el.__scene = make(el, o) || true;
}
};
window.Scenes = window.Scenes || {};
window.Stage = Stage;
if (live) { window.addEventListener('load', () => Stage.ready()); }
})();
@@ -0,0 +1,76 @@
{
"skill_name": "design-taste-frontend",
"how_to_run": "For each eval, give the prompt to a fresh agent that has this skill, and to a second fresh agent without it (the baseline). Render both with scripts/inspect.mjs and have a judge who does not know which is which score the screenshots with references/critique.md. Record inspector FAIL counts for both. Evals 7 to 10 are app screens: inspect them with --app (a sheet dealt by draw.mjs --app switches it on by itself) and judge them with the app-screen passes in references/critique.md.",
"evals": [
{
"id": 1,
"name": "saas-invoicing",
"prompt": "Build a landing page for Ledgerly, an invoicing tool for freelance designers. What it does: you write an invoice in under a minute, the client pays by card or bank transfer, and it sends polite reminders by itself when an invoice is overdue. It costs 9 euros a month flat, with a 14-day free trial and no card needed. Include pricing and a short FAQ. There is no existing brand and no screenshots; the product name is all we have. Plain static HTML, CSS and JS in one folder.",
"expected_output": "A static landing page with a clear idea drawn from invoicing itself, headlines of two lines or fewer at 1440/768/390, no eyebrow labels, no monospace, no index numbering, no thin rules, no gradients or shadows, a non-default palette and type system, a pricing section that is not three cards, and no invented customers or statistics.",
"files": []
},
{
"id": 2,
"name": "restaurant-osteria",
"prompt": "Make a one-page website for Osteria Nove, a 38-seat Italian restaurant in Alfama, Lisbon. Handmade pasta, a short natural wine list, dinner only, Tuesday to Saturday from 19:00. Address: Rua dos Remédios 9. Reservations by phone (+351 21 000 0009) or email (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": []
},
{
"id": 7,
"name": "app-budget-overview",
"prompt": "Build the main screen of Ledgerly, a budgeting app for couples who share money: the October overview with their accounts and balances, the spending plan (planned against spent by category), bills due in the next 14 days and recent transactions. Two people use it, Mia and Tom. Desktop first, and it must work on a phone. Static HTML/CSS/JS.",
"expected_output": "An app screen, not a landing page: a quiet shell (sidebar or rail), a clear screen title, numbers that add up and are right-aligned with tabular figures, one chart per question, colour spent on data and status only, no icons in tinted squares, no gradients or glows, no placeholder names, and a phone layout where the navigation folds away. inspect.mjs --app passes.",
"files": []
},
{
"id": 8,
"name": "app-deploy-dashboard",
"prompt": "Dashboard for Relay, an edge deployment platform for web teams: one project's overview with recent deployments (status, branch, commit message, author, duration, region), requests and p95 latency over the last 24 hours, the error rate, and usage against the plan's limits. Dark theme. Static HTML/CSS/JS.",
"expected_output": "A dense, readable dark screen on a neutral (not navy) ground: one surface step or one hairline between regions, a deployments table with honest statuses and aligned numbers, charts with few gridlines and one series each, IDs in monospace at most, no rainbow series, no glow, and app inspector checks passing.",
"files": []
},
{
"id": 9,
"name": "app-settings-billing",
"prompt": "Workspace settings for Proofline, a design-review tool: general settings, members and roles (a members table with an invite form), billing (current plan, seats, invoices) and notifications. Static HTML/CSS/JS, desktop and phone.",
"expected_output": "A settings screen with a label column beside its fields (or stacked fields on a phone), labels above inputs, one control height, quiet borders, honest disabled states, a members table with right-aligned figures and one status pill per row, and no decorative icons or gradients.",
"files": []
},
{
"id": 10,
"name": "app-support-queue",
"prompt": "The queue screen of Hearth, a support inbox for a six-person SaaS team: the list of conversations with assignee, priority, channel and time to the SLA, the open conversation with the customer's details beside it, plus filters and saved views. Static HTML/CSS/JS.",
"expected_output": "A three-region work screen (navigation, list, conversation) that stays readable at 1440 and folds to one region at a time on a phone, with status shown once per row, times in words, no tinted icon squares or emoji, and app inspector checks passing.",
"files": []
}
]
}
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,92 @@
[
{
"id": "masked-headline",
"name": "Masked headline",
"description": "On load, each line of the headline rises from behind its own baseline, about 900ms with a strong ease-out, the second line 110ms after the first; the supporting sentence and the action then fade up together. Nothing else on the page performs. Because headlines are two lines at most, you author the line breaks and wrap each line yourself. Recipe: 'Masked line reveal' in references/motion.md."
},
{
"id": "shutter-image",
"name": "Shutter image",
"description": "The one hero or showcase image opens from an edge with a clip-path over about 1.1 seconds while the picture inside settles from 1.12 scale to rest. Use it once, on the image that matters most; later images simply are there. Recipe: 'Image shutter' in references/motion.md."
},
{
"id": "scaling-wordmark",
"name": "Scaling wordmark",
"description": "The giant name responds to the scroll: as the first screen leaves, the wordmark scales down a few percent (or grows from 0.94 to 1 as the footer arrives) on a scroll-linked timeline, so the monument feels heavy. Travel stays under 8%. Static fallback where scroll timelines are unsupported. Recipe: 'Scroll-linked progress' in references/motion.md, animating `scale`."
},
{
"id": "pinned-story",
"name": "Pinned story",
"description": "One element holds its place with `position: sticky` (a title, a frame, a product fragment) while the items that belong to it scroll past or swap inside it. The page's ordinary scrolling does the work; nothing is hijacked. Hold it for about one screen per step and three screens at most, two held stages per page at most, with a line of text on every held screen. Recipe: 'Pinned story' in references/motion.md."
},
{
"id": "index-preview",
"name": "Index with preview",
"description": "Rows of text are the navigation through the work; pointing at a row brings up its image in one fixed frame and dims the other rows. On touch screens each row shows its image inline instead. Fast and exact (about 240ms crossfade), no lag behind the pointer. Recipe: 'Index with preview, anchored to the row' in references/motion.md."
},
{
"id": "curtain-footer",
"name": "Curtain footer",
"description": "The page content slides up like a sheet to uncover the footer waiting beneath it, so the last screen is revealed rather than scrolled to. Build with a sticky footer at the bottom and a main element with its own background and a higher stacking order. No JavaScript."
},
{
"id": "slow-crossfade",
"name": "Slow crossfade",
"description": "The principal photograph quietly changes: three or four images of the same framing crossfade over about 1.6 seconds, holding five to six seconds each, or the fixed image changes when its matching section arrives. It should feel like light shifting in a room. Pause when the tab is hidden; under reduced motion show only the first image."
},
{
"id": "edge-marquee",
"name": "Edge-to-edge marquee",
"description": "One line of very large type travels sideways across the full width at a slow constant speed, as a band between two sections or as the closing statement. Linear timing, duplicated content for a seamless loop, paused on hover and under reduced motion. One marquee per page, carrying real words (the name, the claim), never a list of buzzwords."
},
{
"id": "drag-row",
"name": "Drag row",
"description": "A row of large items wider than the screen that the visitor pulls sideways: native horizontal scrolling with scroll snapping, the first item aligned to the page margin and the last allowed to run off the edge so the row visibly continues. No carousel library, no arrows, no dots."
},
{
"id": "colour-on-scroll",
"name": "Colour by chapter",
"description": "Each chapter is its own full-bleed field of one palette colour, so the ground changes exactly at section edges as you scroll, never as a global recolouring of the screen. The only movement: the fixed header (or the one pinned element) takes the ink of the chapter beneath it, switching with a 300ms colour transition as each edge passes under it. Do not drive the page background from an observer: in blind tests a page recoloured mid-section, with a paragraph sitting on two colours, read as broken twice."
},
{
"id": "stillness",
"name": "Stillness",
"description": "No showpiece. The page is simply there, instantly, and the discipline is the statement. Motion is limited to exact feedback: a 140ms press on the button, a 180ms colour change on links, focus rings that appear without delay. Do not add a load animation or scroll reveals to this direction."
},
{
"id": "opening-window",
"name": "Opening window",
"description": "A type screen hands over to the one great photograph: the picture first shows as a window about 45% of the width in the middle of a held stage and opens to full-bleed over about one screen of scroll, while the image inside settles from 1.15 scale. Scroll-linked and linear, so the reader sets the pace; it is fully open after three quarters of the hold and rests a moment before the page moves on. Use it once, for the picture that carries the subject. Where scroll timelines are unsupported, or under reduced motion, the picture is simply a full-bleed screen. Recipe: 'Opening window' in references/motion.md."
},
{
"id": "ink-fill",
"name": "Ink-fill statement",
"description": "One statement of three to five lines at 32px or more starts at 45% ink and fills to full ink line by line as it rises from three quarters of the way down the screen to just past half way, so the reading pace follows the scroll. Every word is readable before it fills; the fill only sets the rhythm. Once per page, never on body copy, never on a headline the visitor needs at once. Full ink without scroll timelines or under reduced motion. Recipe: 'Ink fill' in references/motion.md."
},
{
"id": "sheet-over",
"name": "Sheet over a held picture",
"description": "A full-bleed photograph holds its place for one screen and the next chapter, an opaque sheet in the page ground carrying its heading at the top edge, slides up over it like a page being turned. The chapter change is read, not just seen, and nothing is hijacked: the photograph is a 100svh figure with position: sticky; top: 0, followed by a section with its own background and a higher stacking order. Hold for one screen at most, use it at one or two chapter changes, and give each held photograph a caption so the held screen carries a line of text."
},
{
"id": "sideways-gallery",
"name": "Sideways gallery",
"description": "A short set of pictures (eight or fewer: rooms, kitchens, pieces) moves sideways while the vertical scroll holds the stage, one pixel of scroll for one pixel of travel, and the hold ends the moment the last picture reaches the margin. Only on wide screens with motion allowed and only when the row is under three screens long; phones, reduced motion and longer rows get the native drag row with snapping. Focusing a link scrolls its picture into view, and the only copy of a call to action never sits inside the moving row. Recipe: 'Sideways gallery' in references/motion.md."
},
{
"id": "pushed-line",
"name": "Pushed line",
"description": "One line of very large type, the name or the claim, is pushed sideways by the scroll rather than by a clock: it travels from 12% right to 12% left of its place while it crosses the screen, linear and locked to the scrollbar, and reads whole at the middle of its run. One per page, never across running text, and the words must never be cropped at the moment the line sits in the middle of the screen. Build with animation-timeline: view() on the line and a translateX keyframe; static where scroll timelines are unsupported and under reduced motion."
},
{
"id": "rolling-figures",
"name": "Rolling figures",
"description": "For a page whose case is a handful of numbers (a report, a fund, a practice with a founding year): three or four figures at display size roll each digit up to its value once as they come into view, about 1.1 seconds on a strong ease-out, the columns 70ms apart. Tabular figures, the real number kept in the text for screen readers and search, never in running text and never for a value that changes after load. Set at its value under reduced motion. Recipe: 'Rolling figures' in references/motion.md."
},
{
"id": "carried-picture",
"name": "Carried picture",
"description": "Between the index and a case page, the picture the visitor clicked travels into place as the new page's hero while the old page fades out in 200ms and the new one rises in, all within 480ms, using the browser's own cross-document view transitions with no library and no full-screen curtain. Give the card picture and the hero the same view-transition-name; each name appears once per page. Browsers without view transitions simply navigate; reduced motion turns it off. Recipe: 'Page to page' in references/motion.md."
}
]
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
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 (`&nbsp;`).
- `font-variant-numeric: tabular-nums` where numbers line up in columns (prices, tables).
- `text-wrap: balance` on headings, `text-wrap: pretty` on paragraphs.
- No all-caps for anything longer than three words; if you set caps, add 0.02–0.06em tracking.
- No text under 13px. No centred paragraphs longer than three lines. No justified text.
@@ -0,0 +1,182 @@
# Critique — judging the render
`inspect.mjs` passing means nothing banned or broken was measured. It says nothing about whether the
page is good. This file is how you find out. Use it on the screenshots, never on the code: you wrote
the code, so you will read your intentions into it. The pictures only show what is there.
## Contents
1. How to look (fourteen passes, a few minutes)
2. The score
3. Caps
4. Writing the critique
5. What to fix first
6. When to stop
## 1. How to look
Open `desktop-fold.jpg`, then the `desktop-page-*.jpg` sheets, then `mobile-fold.jpg` and the mobile
sheets. Make each pass on its own; do not blend them.
1. **One second, then five.** Glance and look away. What did you see? There should be exactly one
answer: a word, an image, an object. If the answer is "a website", there is no focal point. Then
give the first screen five seconds as a stranger: can you say what this is, who it is for and
what to do next? If the title only makes sense after scrolling, it is not a title.
2. **Squint.** Blur your attention until text becomes grey bars. The page should reduce to two or
three large masses with clear size differences. Many small similar masses means everything is
medium and nothing leads.
3. **Cover the logo.** Could this first screen belong to a competitor, or to a different industry?
If swapping the name and the noun in the headline would produce an equally valid page, the page has
no idea. Go back to the direction sheet; the idea is written there. Then scroll: does the idea reach
the pricing, the FAQ and the footer, or does the page turn into stock blocks after the first screen?
4. **Edges.** Follow the left edge of every text block down the page. Count the distinct x positions.
A composed page has two or three, reused on purpose. Six means things were placed one at a time.
5. **Space and frame.** Are related things close and unrelated things far? Is the gap between sections
clearly larger than any gap inside a section? Is any region empty by accident (a hole) rather than
by decision (a rest)? A hole usually sits beside a short column or under a removed decoration.
Then look at the frame: are the left and right margins of the page the same? Does any section keep
its text in one half and leave the other half empty? Is there a stretch of nothing taller than half
a screen? Readers name these first ("it is pushed to the left", "random empty space"), before any
detail; in the study of 100 Sites of the Day about 1 section in 100 left half the width empty.
6. **Type.** Is the jump from body text to headline decisive (three times or more)? Do you see more
than five sizes? Any headline with one orphan word on its last line? Any paragraph wider than about
75 characters or centred over more than three lines?
7. **Colour.** Name the proportions: which colour holds most of the surface, which supports, where is
the accent? An accent that appears in ten places means nothing. One that appears on the single
action and one detail means "here".
8. **Images.** Count the real pictures: are there at least four, and is there a moving one? A page of
drawings only, or of type only, looks unfinished next to one with real photographs and footage.
Is every image doing a job (showing the thing, setting the place, proving the claim)?
Do the crops share ratios? Is anything a grey box, a stretched photo, a fake screen made of bars?
Does the photography share one light and one grade, or is it a stock mix?
9. **Sameness.** Write one word for each section's layout (split, list, full-bleed, grid, statement,
index …). The same word twice in a row, or three times on the page, is the template showing.
10. **Phone.** Is the mobile screen a composition of its own, with its own crop and its own line
breaks, or the desktop page squeezed? Is the first screen still one clear thing?
11. **Fullness.** Go through the desktop sheets screen by screen. Is each screen a composed poster, or
is it a narrow column of small text with dead ground either side? Is any statement held on screen
for three screens? An austere page is not a restrained one: if the page could be mistaken for an
unfinished wireframe (grey shapes, grey text, one small column), it is underbuilt. A single motif or
drawing blown up to fill a whole screen with nothing to read is a hole too, however bold it looks.
12. **Substance.** Does every section the brief asked for deliver its information completely and at a
readable size (the full menu, each room, every speaker, the price and what it includes)? Would the
visitor leave knowing what they came to find out? Is the primary action a real button on the first
screen?
13. **Collisions.** Is anything lying on top of a headline, a statement or a button at any scroll
position? Does any element run off the edge of the phone screen? A row that scrolls sideways on
the phone reads as cut off in a screenshot and to most visitors; wrap it unless the cut is
plainly deliberate (a partial card showing at the edge).
14. **The competitor and the live thing.** Put the nearest competitor's name on the page: does the
idea still work? Then name the one live thing (what the visitor can do or what differs each visit).
If either answer is weak, that is the first problem of the round, ahead of any detail.
A note from testing: in blind comparisons a richer page full of template devices beat a cleaner page
that was thin, every time. The house rules remove the devices; passes 11–13 are where you make sure the
page did not lose its substance with them.
## 2. The score
Score like an Awwwards juror: four criteria, 1–10 each, weighted **design 40%, usability 30%,
creativity 20%, content 10%**.
**Design (40%)** — composition, typography, colour, imagery, craft.
- 9–10: every screen holds as a poster. Nothing could be removed or moved.
- 8: confident and coherent; one or two soft spots a designer would notice.
- 7: clean and professional, a clear system, little tension.
- 6: tidy but anonymous. Reads as a kit.
- 5 or less: weak hierarchy, accidental spacing, default type, mismatched images.
**Usability (30%)** — orientation in three seconds, readable text, obvious actions, honest mobile.
- 9–10: you know what it is, who it is for and what to do without scrolling; flawless on a phone.
- 8: clear and comfortable; minor friction (a long section, a small target).
- 7: works, with one real problem (weak contrast somewhere, a cramped phone layout).
- 6 or less: you have to hunt, or something breaks.
**Creativity (20%)** — an idea you can state in a sentence, carried through.
- 9–10: a concept specific to this subject that you have not seen applied this way.
- 8: a clear idea with one memorable move.
- 7: a tasteful execution of a known approach.
- 6 or less: the category default.
**Content (10%)** — words and pictures that are specific, true-sounding and edited.
- 9–10: every line could only be about this subject; imagery is the subject.
- 7–8: clear and concrete, some generic lines.
- 6 or less: slogans, filler, stock.
`overall = 0.4·design + 0.3·usability + 0.2·creativity + 0.1·content`
For calibration: real Sites of the Day land around 7.5–8.0 overall from the jury. An honest 8.5 is
rare. If your first score for your own work is 9, score it again as the juror who did not build it.
## 3. Caps
These override the arithmetic.
- Any `FAIL` still reported by `inspect.mjs`: overall cannot exceed **6.0**.
- A headline of three or more lines at any viewport, an eyebrow label, monospace labels, 01/02/03
numbering, a decorative gradient, a shadow on a resting element, or a decorative hairline visible in
a screenshot: overall cannot exceed **6.5**, whatever else is true.
- Pass 3 fails (the page could belong to anyone): creativity cannot exceed **6**.
- The idea passes for the nearest competitor (pass 14): creativity cannot exceed **7**.
- Fewer than four real pictures (photographs, archival images, footage; drawings do not count):
design cannot exceed **6.5**.
- A page that barely moves (the inspector's `still-page`), or motion that is one identical fade on
every block: design cannot exceed **7**.
- No live thing (pass 14): creativity cannot exceed **7.5**.
- Any placeholder (grey box, lorem, "Image here"): content cannot exceed **5**.
- Two or more desktop screens that are mostly empty ground, or a page whose running text is small and
grey: design cannot exceed **6.5**.
- A section the brief asked for that is missing or reduced to a few words: content cannot exceed **6**.
- No real button for the primary action on the first screen: usability cannot exceed **7**.
- The five-second pass fails (no clear title saying what this is on the first screen): usability
cannot exceed **6**.
- Unequal page margins, a section huddled on one side with the other half empty, or a blank stretch
taller than half a screen: design cannot exceed **6.5**.
- A section of running text with no heading naming it: usability cannot exceed **7**.
- Anything covering a headline at any scroll position: usability cannot exceed **6**.
- A blank band where a reveal did not fire, or content clipped off-screen: usability cannot exceed **5**.
## 4. Writing the critique
Write it down; do not just think it. Use exactly this shape so rounds can be compared:
```
ROUND n
Seen in one second: …
Idea (one sentence): …
Live thing: …
Layout words by section: hero=… / …=… / …
Scores: design _ · usability _ · creativity _ · content _ → overall _._
Caps applied: …
Top three problems (most visible first), each with the screenshot it shows in and the fix:
1. …
2. …
3. …
```
Name problems the way a designer would to a colleague: "the supporting paragraph starts 40px left of
the headline's edge", "the testimonial section is the third split layout in a row", "the phone hero
crops the bottle out of frame". "Could be more polished" is not a finding.
## 5. What to fix first
Fix in this order; later items cannot rescue earlier ones.
1. **The idea.** If pass 3 fails, change the hero concept, not the details.
2. **Hierarchy.** One dominant element per screen. Make the big thing bigger and the small things
fewer before touching anything else.
3. **Composition.** Shared edges, section spacing, variety of layout between sections, holes.
4. **Typography.** Scale contrast, line breaks, measure, leading.
5. **Imagery.** Replace weak pictures, unify crops and grade.
6. **Colour.** Proportion and accent discipline.
7. **Detail.** States, focus, small alignments, mobile refinements.
8. **Motion.** Last.
When removing something leaves a gap, do not refill it with an ornament. Close the gap, enlarge what
remains, or let a picture or a colour field take the space.
## 6. When to stop
Run at most three rounds: critique → fix the top three → re-inspect → critique. Stop early when a round
finds no problem a stranger would notice. If round three still scores under 7.5, say so plainly in
your report, with the remaining problems listed; do not round up.
@@ -0,0 +1,791 @@
# Imagery
Pictures are where generated pages most obviously give up: a grey box, an icon in a square, a fake
dashboard, an abstract blob. This file is how to decide what the pictures are, where to find real ones,
how to crop and place them, and what to do when there are none.
## Contents
1. What the study measured
2. Decide what imagery exists
3. Real pictures from the open collections
4. The dealt photographs
5. Cropping and placing
6. Treatment
7. Showing software
8. Drawing the subject
9. Type and colour alone
10. Markup and credits
11. Video
12. Where pictures and footage go
## 1. What the study measured
- On 60% of the studied sites one piece of media covers at least half of the first screen; on half of
them it covers nearly all of it. Top-scored sites: 68% and 56%. Weak sites: 48% and 39%.
- Only 15% of heroes have no media at all, and those are deliberate typographic statements.
- Media has square corners on 73% of sites. Where corners are rounded the usual radius is about 12px,
applied to every image alike.
- Top-scored pages carry far more imagery (a median of 37 images against 5 on weak pages): the strong
sites show, the weak ones tell.
- What the imagery is, by the panel's classification: a mix of kinds 32%, photography 14%, generative
or WebGL scenes 13%, real product interface 12%, 3D renders 11%, illustration 9%, video 6%, type only
3%.
- Among the 54 sites the panel scored under 6.8, legibility (text lost over a busy image, or too
little contrast) was named as a weakness in 13.
- In the October 2026 study of 91 Awwwards Sites of the Day (section 12), the median page carries 7
pictures (top sites 8, the lowest 2) and footage appears on 42% of sites (half of the top ones, one
of the 16 lowest).
- Pictures come in whole screens: 42% of scrolled screens hold none and 19% are 90% picture or more.
- 94% of pictures have square corners, and on a typical site two ratios hold three-quarters of them.
## 2. Decide what imagery exists
In order of preference:
1. **The project's own images.** Product shots, the place, the people, the work. Even ordinary
photographs of the real thing beat beautiful photographs of something else. Ask what exists; look in
the repository.
2. **Images you can generate** with an image tool in this session. Write prompts as a photographer's
brief: subject, angle, light, background, crop ratio, and "no text". Generate the hero first and
make the rest match its light.
3. **Real pictures of the subject's world from the open collections** (section 3). Archives,
museums and photographers have published millions of pictures anyone may use with a credit: the
client's city and street in old postcards, maps of its neighbourhood, its materials, its trade in
engravings, museum objects of its kind. Whenever an honest one exists, it beats a drawing of the
same thing: it is real, it has detail no drawing in a page's budget reaches, and it carries history.
Honest means nobody could take it for the client's own product, room, dish, person or team.
4. **Pictures you draw in code from the subject itself** (section 8), for what no honest picture
exists of: the product's own objects as flat shapes, the place's tiles and windows, a map of its
streets, the document a tool produces, the interface of a piece of software. In testing, pages that
drew their subject with care beat pages that borrowed unrelated photographs every time, and beat
pages that showed nothing.
5. **The photographs dealt on the direction sheet.** Real photographs of places, light, texture and
weather. Use them only where an honestly generic picture fits (the open sea for a house on the sea,
fog for a quiet retreat). They cannot stand in for a specific product, dish, room, person or
screen: a foggy motorway on a restaurant page reads as stock at once, and jurors notice.
6. **Type and colour alone,** done on purpose (section 9).
Tell `draw.mjs` the truth with `--have`. If you have no product photographs and no image tool, do not
pass `product-photo`; the draw will avoid the directions that depend on showing the object. Pictures
found in the open collections are not the project's own: do not pass `photography` for them.
## 3. Real pictures from the open collections
`scripts/assets.mjs` searches four open sources without keys and prints, for each find, a direct image
URL, the page it comes from, its size, its creator, its licence and a credit line:
- **Openverse**: one index over Flickr, Wikimedia Commons, museums and libraries.
- **Wikimedia Commons**: archival photographs, old maps, scans of prints and books, places as they
look now.
- **The Art Institute of Chicago** and **The Metropolitan Museum of Art**: public-domain paintings,
prints, drawings, photographs, textiles and objects, in large scans.
```bash
node <skill>/scripts/assets.mjs "ghent postcard"
node <skill>/scripts/assets.mjs "fig" --source museums --orientation portrait
node <skill>/scripts/assets.mjs "basalt texture" --min-width 2400 --source openverse,commons
node <skill>/scripts/assets.mjs --get commons:36649959,met:707892 # download, record the credits
node <skill>/scripts/assets.mjs --credits # footer line and report list
```
By default it keeps only licences that allow commercial use (CC0, public domain, CC BY, CC BY-SA) and
pictures at least 1000 pixels wide, and checks that every image it prints actually loads.
`--license pd` keeps only pictures that need no credit; `--orientation`, `--min-width` and `--limit`
narrow the list; `--source` picks the sources; `--json` gives the raw results. Each result has a ref
(`commons:36649959`) that `--get` takes.
**What to search for.** The inventory (SKILL.md step 1), not the category: "bakery" finds other
people's bakeries. The subject's world is specific:
- **Its place:** the city, the street, the harbour, the valley, in old postcards and photographs
("ghent postcard", "rotterdam harbour 1920") and as it looks today ("gent korenmarkt").
- **Its maps:** the street plan, the chart of its bay, the geology of its valley ("ghent old map",
"nautical chart lisbon").
- **Its materials,** close enough to read as a surface: the stone of its quay, the grain of its oak,
its glaze, its tiles ("azulejo tiles", "basalt cobblestones", "oak grain").
- **Its trade's history:** the old tools, the craft in engravings and trade catalogues, patent
drawings ("bread oven engraving", "potter's wheel", "bicycle patent drawing").
- **Museum objects of its kind,** with `--source museums`: a coffee set from about 1810 for a studio
that makes coffee sets, a woodblock print of a wave for a surf school, a porcelain plate painted
with figs for a fig orchard.
- **Scientific imagery:** botanical and zoological plates ("botanical engraving fig"), star charts,
microscope photographs, satellite views of its coast, geological sections.
Search in the place's own words (azulejo, not "blue tile"; Korenmarkt, not "market square"), add a
medium (postcard, engraving, old map, lithograph, aerial photograph) or a decade, and try two or three
queries per idea. The museum sources match best on one or two words ("fig", "harbour", "teapot").
**What makes a good find.**
- It belongs to this subject's world and would not fit the nearest competitor's page.
- It still reads at the crop the slot needs. Look at it before deciding: `--get` downloads it, then
open the file. A title is not a picture.
- It is large enough: twice the width it is shown at for anything full-bleed (the script prints the
largest size available). A 1024-pixel Flickr photograph is a half-width picture at most.
- It belongs to one family with the others on the page: all engravings, or all photographs from one
era, or all close surfaces in one light. A 1900 postcard beside a modern stock photograph beside a
botanical plate reads as a scrapbook.
- No watermark, no burned-in text or logo, no frame or scanner edge you cannot crop away, and no
recognisable living person who could be taken for the client's staff or customers.
**Use it creatively.** Found pictures are material, not decoration:
- **One archival photograph as the hero,** full-bleed: the street as it was a century ago, behind the
name of the business that stands in it today, with the year in the caption.
- **A museum engraving enlarged until its line becomes texture,** cropped tight and returning at
different scales as the page's one motif (the whole fig plate in the hero, a single leaf of it
beside the menu).
- **An engraving or a map on white paper set with `mix-blend-mode: multiply` on the page's colour
field,** so the ink sits on the ground (section 6).
- **The real map of the street as the contact section,** the door marked, the north and the scale
taken from the original.
- **A material close-up as a whole field:** the quay's stone behind the opening hours.
- **A run of old postcards or plates as the gallery,** one size, one treatment.
Treatments stay within section 6: untouched, greyscale for the whole set, or multiply on a field.
**Stay honest.**
- **A found picture is never the client's own.** It may show the client's city, its materials, its
history, the kind of thing it makes; it may never stand in for the client's product, room, dish,
person, team or customers. If a visitor could take it for the client's own (a bakery counter, a
plate of pasta, a smiling barista, a guest room), do not use it.
- **Caption from the source** when a caption helps: what it is and when, as the archive describes it
("Gand, unloading a steamer, 1905"). Never write a caption that ties it to the client.
- **Record and credit every picture.** `--get` writes the source, creator, licence and page of each
download to `assets/credits.json`. CC BY and CC BY-SA require a credit with the title, the creator,
a link to the source and a link to the licence; `--credits` prints them as one line of HTML for
the footer (section 10). Public-domain pictures need no credit; give one anyway.
- **Respect the licence.** CC BY-SA: an altered version (recoloured, combined with other pictures)
must be shared under the same licence. Non-commercial (NC) and no-derivatives (ND) licences are out
for a client's site, which is why the script leaves them out unless asked. Read the `note` lines:
some pictures carry personality rights or a trademark, are public domain only in the United States,
or come with an archive's statement instead of a licence.
- **Download, do not hotlink.** Save what you use into the project: `--get` puts it in `assets/` at
1920 pixels wide (pass `--width 1280` for a picture shown at half the screen, `--width 3840` for a
very large hero where the source has it). The page then works offline, does not break when an
archive moves a file, and does not load from someone else's server. Every licence the script
allows by default permits copying.
- **List them in the report:** file, title, creator, licence, source page, and where the credit sits.
`--credits` prints that list too.
If two or three honest queries per idea find nothing that fits, draw (section 8). A drawing made for
the subject is better than a found picture that is merely nearby. If the script cannot reach any
source (no network), draw, and say so in the report. For moving pictures, the same script searches
real footage with `--video` (section 11).
## 4. The dealt photographs
- The sheet lists eight from one set so they share a subject and a light. Use three to six.
- Each line says where the calm area is. Put text there or keep text off the image.
- More from the same or another set: `node scripts/draw.mjs --photos list`, `--photos <set>`, or
`--photos "fog coast"` to search descriptions.
- URLs have the form `https://picsum.photos/id/<id>/<width>/<height>`. Request the exact pixel size you
render at twice the CSS size for sharpness, capped around 2400 wide. Add `?grayscale` for
black-and-white.
- These are photographs from Unsplash served by Lorem Picsum under the Unsplash licence. List the ids
you used in your report; they are meant to be replaced by the project's own pictures.
## 5. Cropping and placing
- **Choose a small set of ratios and stick to them:** for example 4:5 for portraits, 16:10 for
landscapes and 1:1 for details. Random ratios look like a stock dump.
- **Go large.** A photograph at full width, or at half the screen and full height, has presence. Six
small ones in a grid have none. If the image is good, give it the room; if it is not, cut it.
- **Crop with intent.** Use `object-fit: cover` with an `object-position` chosen per image so the
subject sits where the composition needs it (off-centre, on a third, against the text side).
- **Let images touch edges.** Full-bleed, or bleeding off one side, reads as designed. An image
floating with equal margins on all sides reads as placed by default.
- **Overlap sparingly and on purpose:** a headline whose last line runs over the top edge of the
picture, a small image overlapping a large one by a fixed 40–80px. One overlap per screen at most.
- **Square corners** unless the direction or the one-motif shape says otherwise. If rounded, one radius
for every image on the page.
- **No frames:** no borders, no shadows, no device mock-ups, no browser chrome around pictures.
- **Captions** are one short line in the small size and soft ink under the image, left-aligned to its
edge. Most images need none.
- Where each picture goes on the page, how large, and what the phone does with it: section 12.
## 6. Treatment
One treatment for the whole page:
- **Untouched.** The default. Pick images whose light already agrees.
- **Greyscale.** `filter: grayscale(1) contrast(1.05)`, or `?grayscale` on the URL. Unifies a mixed
set instantly and suits editorial and typographic directions. Apply to every image or none.
- **Multiply on a field.** On a light flat ground, `mix-blend-mode: multiply` makes a white studio
background vanish so a product sits directly on the colour.
- **Darkened for a dark page.** `filter: brightness(0.85) contrast(1.05)` so the image edges sink into
the ground.
Not treatments: colour washes over photographs, duotone overlays, blur, grain overlays, vignettes,
tilted "polaroid" frames with shadows.
For text on a photograph, a neutral scrim covering only the text area: a solid block at 40–55% black,
or a fade from 55% black to transparent over the lower third. Never a coloured overlay.
## 7. Showing software
- Build a real fragment of the interface in HTML at its true size, with believable content, instead of
a shrunken screenshot or a decorative mock-up. Crop to the part that makes the point: one control,
one row, one panel.
- Believable means real labels, real numbers that add up, one item hovered or selected, proper
alignment. Grey bars standing in for text are worse than no image.
- Put the fragment directly on the page or on one flat stage colour. No laptop, no phone frame unless
the product is a phone app and the frame is a plain rounded rectangle.
- Inside a fragment the product's own interface conventions apply (its small radii, its 1px control
borders, a shadow on its open menu). Put the attribute `data-ui` on the fragment's root element: the
inspector and the linter then leave what is inside alone, and hold the page around it to the house
rules. `data-ui` is for cropped fragments only; wrapping a heading or a whole section in it fails.
Write the fragment's CSS under a `[data-ui]` selector (`[data-ui] .row { … }`) so the linter can
tell it apart from page styles.
- If the user supplies real screenshots, show them large and uncropped at least once.
## 8. Drawing the subject
When nothing real can be photographed and the open collections hold no honest picture of it, draw it,
and draw it well. Inline SVG and CSS are enough. Drawings and found pictures can share a page: the
found ones show the world around the client, the drawings show the client's own things.
- **Draw the subject's own things,** not decoration: the stool's three legs and the grain of its seat,
the enamel sign over the bakery door, the hut's two windows and its stove pipe, the street plan, the
invoice the tool produces, the festival's stage. If you cannot name what a drawing depicts in the subject's world, it is decoration.
- **One graphic language for the whole page:** flat filled shapes, no outlines or one consistent
stroke weight, the same corner and curve vocabulary everywhere, drawn in the palette's real colours
(fields and accent at full strength, not greys). A set of drawings that obviously belong together
reads as an identity; a mix of styles reads as clip art.
- **Colour is the material.** A product sold on its finish is drawn in that finish's colours; a sea house in the
sea's blues and the sand; never a page of grey placeholders.
- **Draw with three levels of detail.** Jurors called simple drawings "basic" every time. Give each
drawing big shapes that read at a glance, middle shapes that describe the thing (the handle, the
window frame, the step), and small details that reward a second look (the maker's mark, the house
number, the chipped tile, the hinge, a bird on the roof). Make light with flat tone steps (a lit face
and a shaded face of each volume), not gradients. A drawing that would pass as a placeholder icon at
full size is not finished.
- **Draw large and let it work:** the drawing is the hero's dominant element, a full-width band, a set
of tiles, the footer mark. Small doodles scattered around read as filler.
- **Let it carry the idea through the page,** the way a motif does: the same drawn object returns at
different scales, the tile grid becomes the opening hours, the map becomes the booking section.
- **Honesty:** say plainly, in small text near the first drawing or in the footer, that the pictures
are drawn until photographs exist, if the client might otherwise be taken to be showing real
products. In your report, list which drawings are placeholders for photography.
- **Not this:** gradient-shaded 3D-looking blobs, isometric clip art, generic line illustrations of
people at laptops, abstract floating shapes, icons in squares. Those are the generated look.
## 9. Type and colour alone
This is a legitimate design, not a gap to disguise. Only 7 of the studied sites use no imagery at all,
and they hold their own: a median panel score of 7.5 against 7.2 for the whole set.
- Make the typography the image: one statement at 10–14vw, a list set at 5vw, numerals at display size.
- Use colour at scale: a full field, an inverted section, a two-field split.
- Use the subject's own shape as one flat motif if the direction calls for it (a circle, an arc, a
notch), drawn in CSS, cropped by the viewport.
- Do not add: icons in squares, abstract gradients, dot grids, wave dividers, floating shapes, emoji,
generic line illustrations, or an empty box where a picture "will go".
## 10. Markup and credits
A found picture, downloaded into the project with `--get`, used as the hero:
```html
<img src="assets/fig-engraving.jpg" width="1920" height="2807" fetchpriority="high" decoding="async"
alt="Hand-coloured engraving of a fig cut open, with its flowers and seeds, in black, pink and green">
```
A dealt photograph further down the page:
```html
<img src="https://picsum.photos/id/626/2400/1500" width="2400" height="1500"
alt="Red and white lighthouse in a turquoise sea below a chalk cliff" loading="lazy" decoding="async">
```
The credits, as `--credits` prints them, in the footer:
```html
<p class="credits">Pictures: <a href="https://commons.wikimedia.org/wiki/File:Fig_(Ficus_carica_L.);_57_segments_of_flowers_and_fruits_at_Wellcome_V0043000.jpg">“Fig (Ficus carica L.): 57 segments of flowers and fruits”</a>, <a href="https://creativecommons.org/publicdomain/mark/1.0/">public domain</a>, via Wikimedia Commons.</p>
```
- Always `width` and `height` (or `aspect-ratio` in CSS) so nothing jumps while loading.
- `loading="lazy"` on everything except the hero image, which gets `fetchpriority="high"`.
- `alt` describes the picture in plain words. Decorative repeats get `alt=""`. The script's suggested
alt is the archive's title; rewrite it to say what the picture shows.
- A coloured `background-color` on the image container (the ground or surface tone) so the layout is
whole before the image arrives.
- The credit line is part of the footer's design, at full contrast, with its links. Set it at the body
size; one step smaller only on a page with plenty of running text, because a long credit line in
small type can become the page's most common text size and fail the inspector's body-size check.
One line for all pictures; no credit captions laid over the pictures themselves. Credit only the
pictures the page still uses.
- A broken image fails the inspector. Check every picture in the screenshots.
## 11. Video
A moving picture of the subject's world can carry a section the way a photograph does: the river at
the end of the street, the roaster's drum turning, a timber frame going up. `assets.mjs --video`
searches real footage without keys: Wikimedia Commons (every video there comes with web-sized
transcodes; the best source for short clips), the Internet Archive's Prelinger Archives (industrial,
educational, city and home films from the 1920s to the 1970s, mostly public domain and mostly long)
and NASA (the Earth from orbit, launches, aircraft, laboratories). The rest of the Internet Archive is
left out: anyone can upload there, and the licence claims are unchecked.
```bash
node <skill>/scripts/assets.mjs "coffee roasting" --video
node <skill>/scripts/assets.mjs "river" --video --source commons,nasa --orientation landscape
node <skill>/scripts/assets.mjs --get commons:49769620 # the clip, a poster frame, the credit
```
It takes a rendition of 720p or smaller (`--res` changes that), lists short clips first, prints each
clip's length and file size, and flags anything over 25 MB or longer than a minute. `--get` refuses a
clip over 25 MB unless you pass `--max-mb`, and saves the source's still frame beside it as the
poster (Commons and NASA; the archive's own stills are too small). An archive film of ten minutes is
too heavy as it stands: trim a few seconds from it with ffmpeg if it is installed, or choose a
shorter clip. Licences, credits and honesty work exactly as for pictures (section 3).
**Choosing a clip.**
- **Short:** 5 to 20 seconds that loop without a visible jump: water, smoke, a turning drum, traffic,
leaves, a crowd from far away. Watch it to the end before choosing.
- **Light:** under about 5 MB for a section and 10 MB for a hero. In the study sources are 1920×1080
(the commonest) or 1280×720, median length 10 s; 720p is plenty behind type. For a full-bleed hero,
cut a tall 720×1280 version for the phone, or set `object-position` on the subject.
- **Clean:** no slate, title card, logo, watermark or burned-in caption. NASA and archive films often
open with a slate, and the source's poster frame may be that slate. No recognisable person who could
be taken for the client's staff or customers.
- **Never the client's own:** a found clip of a roastery is not this roastery.
- **Found clips: one per page, two at most.** A found clip shows the world around the client; a
third makes a theme of it. The project's own footage can carry more: in the study a site with
footage carries a median of three or four clips. Either way, **one moving picture per screen**;
two loops running side by side are noise.
**On the page.**
- Muted, looping, inline, with a poster and a fixed size, as `--get` prints it:
`<video autoplay muted loop playsinline preload="metadata" poster="assets/river-poster.jpg"
width="854" height="480" aria-hidden="true"><source src="assets/river.webm" type="video/webm"></video>`.
No sound and no controls on a decorative loop. `aria-hidden` because it is decoration: whatever the
clip says that matters belongs in the text beside it.
- The poster shows before the clip plays, on slow connections and under reduced motion, so it must
work as a picture on its own. If the source's still is a slate or a blur, take a frame from the clip
yourself or use a found picture.
- In the study only 31% of clips had a poster, and the panel recorded the blank and grey boxes
before they played as faults. Here every clip has one.
- Play a clip only while it is in view, and none under `prefers-reduced-motion: reduce`, where the
poster shows instead:
```html
<script>
const calm = matchMedia('(prefers-reduced-motion: reduce)');
const clips = [...document.querySelectorAll('video[autoplay]')];
const inView = new Set();
const sync = (v) => {
if (calm.matches) return;
if (inView.has(v)) v.play().catch(() => {}); else v.pause();
};
const settle = () => {
for (const v of clips) {
if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); }
else if (inView.has(v)) v.play().catch(() => {});
}
};
const watch = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) inView.add(e.target); else inView.delete(e.target);
sync(e.target);
}
}, { threshold: 0.2 });
clips.forEach((v) => watch.observe(v));
settle();
calm.addEventListener('change', settle);
</script>
```
- Crop and place it like a photograph (sections 5 and 6): `object-fit: cover`, one ratio, square
corners, no frame, a ground-coloured `background-color` while it loads.
- Never under body text. On a clip put a headline, one line and one action at most, and check the
contrast where they sit across the whole loop, not only on the poster: footage that is dark at
one second can be bright at the next. If any frame drops below 4.5:1 (3:1 for 24px and up), use the
scrim in section 6 or move the text off the clip. Nothing lies on top of a headline or a statement.
- It is motion: let it be the page's one moving thing, or sit in a section where nothing else moves.
- Credit it with the pictures: `--credits` prints one footer line for both ("Pictures and footage:")
and the list for the report, with each clip's length.
- Where footage goes (hero background, inline loop, the frame that grows, a docked card), and what
the study measured about it: section 12. Nothing that matters plays on hover only, and a full film
with sound sits behind a play button that opens a player with controls.
## 12. Where pictures and footage go
What strong pages do with their pictures, measured on the latest 100 Awwwards Sites of the Day
(10 July to 17 October 2026, captured at 1440×900 and 390×844). 91 were usable. A panel described where
every picture sat and scored each site the Awwwards way, and a script measured how much of each screen
is picture. "Top" means a panel score of 7.5 or more (36 sites), "lowest" under 7.3 (16 sites). The
scores sit close together (6.7 to 7.9) and a single score carries about half a point of noise, so read
the links between a placement and the score as leanings, not laws.
### How much of each screen is picture
- **All or nothing.** Counting photographs, footage and picture backgrounds (not canvases), the median
scrolled screen in the study is 16% picture, but few screens sit near it: 42% hold no picture and
19% are 90% picture or more. Only 19% fall between 20% and 60%. A picture either leads its screen or
stays out of it.
- **About a third, with rests.** On the 67 sites that put pictures in the page, the average screen is
36% picture (middle half 18–69%). Top sites sit at 30% and leave 28% of their screens with no picture
at all: screens of type are the rests between picture screens.
- **The first screen.** A picture covers at least half of it on 41% of the sites and nearly all of it
on 32%. A full-bleed photograph or film is the whole first screen on 29%; footage is the main hero
picture on about a quarter.
- **A scene is not a picture.** 66 of the 91 keep a WebGL canvas over at least 30% of the screen, so
counting canvases the median screen is entirely covered. On the lowest-scored sites that canvas is
often all there is: the five lowest usable sites (6.7 to 7.0) draw everything in it and put two
pictures at most in the page.
- **Text is small in area.** The median screen is 8% text. Type is set large and sparse around the
pictures, not packed beside them.
- **Off-centre.** Measured per section across the crawled sites, pictures sit to the left or right
939 times, centred 273 times, and full-bleed or behind text 269 times.
- **The phone carries more picture,** not less: 19% of a phone screen at the median, and 27% of the
first phone screen against 17% on desktop. Full-bleed pictures re-crop to portrait and fill the
screen; splits put the picture first at full width.
### How many
- **Seven pictures per page** at the median (middle half 1–25). Top sites 8 (middle half 4–27), lowest
2 (0–4). The floor in SKILL.md step 6, four and usually six to twelve, is the strong half of the
study.
- **Footage on 42% of sites:** on half of the top sites and half of the middle band, and on one of the
16 lowest. A site with footage carries a median of three or four clips.
- **Three to five placements.** Sites that use two or fewer of the placements below average 7.31;
sites that use three or more average about 7.5. The median top site uses four, the median lowest
site one. Where the pictures go changes from section to section on a designed page.
### The placements, ranked
| Placement | Sites (of 91) | Top (of 36) | Lowest (of 16) | Mean score with / without |
| --- | --- | --- | --- | --- |
| Split: picture in a half or two-thirds | 32 | 17 | 2 | 7.50 / 7.38 |
| Horizontal gallery | 32 | 13 | 4 | 7.44 / 7.41 |
| Full-bleed band between sections | 31 | 14 | 3 | 7.48 / 7.40 |
| Full-bleed picture or film as the first screen | 26 | 10 | 3 | 7.45 / 7.41 |
| Uneven mosaic | 25 | 11 | 1 | 7.49 / 7.40 |
| Picture held while text scrolls | 23 | 12 | 1 | 7.52 / 7.39 |
| Footage behind text | 22 | 8 | 2 | 7.46 / 7.41 |
| Even grid | 20 | 11 | 2 | 7.48 / 7.41 |
| Scattered collage | 19 | 6 | 3 | 7.47 / 7.41 |
| Panel sliding over a held picture | 19 | 8 | 2 | 7.48 / 7.41 |
| Inset frame that grows | 17 | 7 | 1 | 7.52 / 7.40 |
| One picture centred on a field | 14 | 5 | 3 | 7.38 / 7.43 |
| Cut-out object on a flat field | 13 | 7 | 1 | 7.57 / 7.40 |
| Index with hover preview | 12 | 6 | 1 | 7.49 / 7.41 |
| 3D gallery | 12 | 4 | 4 | 7.30 / 7.44 |
| Small card docked in a corner | 11 | 5 | 1 | 7.47 / 7.42 |
| Picture inside or in front of display type | 11 | 7 | 1 | 7.50 / 7.41 |
| One small picture beside a statement | 8 | 5 | 0 | 7.56 / 7.41 |
| Small pictures between words | 6 | 4 | 0 | 7.52 / 7.42 |
- Found on top pages more than their share and almost never on the lowest: the split, the picture held
while text scrolls, the uneven mosaic, the inset frame that grows, the cut-out on a flat field, and
pictures set with the type (the last three rows together: 24 sites, 16 of them top, 1 lowest).
- The horizontal gallery is common in every band and lifts nothing. Use it where browsing is the point.
- The 3D gallery is the one placement that goes with lower scores, and with the lowest usability in the
table (5.9 against 6.35 for all sites).
### Each placement
Each entry gives when to use it, the geometry at 1440×900, the phone version and how it fails.
**Split: picture in a half or two-thirds**
- *When.* A claim and the picture that proves it. The commonest way strong pages pair words and a
picture.
- *Geometry.* The picture takes 42% of the width at the median (middle half 33–49%), up to two-thirds,
at the full height of its section: 680×850 and 720×900 are typical. 38% of these pictures are
portrait, mostly 4:5 or 3:4. The text starts on the 50% line (x = 720) or on the line where the
picture ends, top-aligned or docked low, not centred in its half. Let the picture touch the viewport
on its outer side: only about 30% do in the study, and the ones with margin on every side read as
placed by default (section 5). Alternate sides only across three or more rows.
- *Phone.* Picture first at full width (4:5, or 4:3 for a landscape), text under it.
- *Fails.* The same split in three sections running; a picture floating in the middle of its half; a
text column narrower than about 30 characters.
**Full-bleed band**
- *When.* One picture carries a whole screen between type screens: the place, the work, the object at
full size. One every three or four screens at most.
- *Geometry.* 100% wide and one to one and a half screens tall (median 900px at 1440×900, middle half
890–1,170px; ratios 1.4 to 1.9). For a slow parallax the picture is oversized in its frame by 4–10%
(32–72px a side in the study) and travels no further than that. Text is a headline and one line, or
nothing, docked 32–64px in from a corner over the calm part of the picture.
- *Phone.* A portrait re-crop that fills the screen, with `object-position` set on the subject; the
text moves to the bottom quarter.
- *Fails.* 11–14px white text over a bright area; three bands with nothing between them; a band that
only decorates.
**Picture held while text scrolls**
- *When.* A list, steps or a long paragraph that all belong to one picture. If the picture changes for
each item, it is the index with a preview, below.
- *Geometry.* A 100vh sticky stage inside a section two to six screens long (runways of 1,800–5,400px
are typical in the study). The picture holds one half (685×880 is a typical portrait), two-thirds, or
the whole screen with the text passing over it on a solid panel. Text rows about 150px apart, so two
or three are in view at once.
- *Phone.* Release the stick: picture first, text under it. If the picture swaps per item, keep it as
a band in the top 40% of the screen.
- *Fails.* Long runways where the text has faded out and the held picture is all that is left (the
panel recorded several empty stages on phones); body copy set on the held picture.
**Inset frame that grows**
- *When.* Once per page, for the film or picture the page is about, as the move from type into
picture.
- *Geometry.* The frame starts centred at 35–50% of the width (549×366, 710×480 and 864×540 in the
study) or as a 16px inset round a full screen, and grows to the full width and height over
720–2,700px of scroll. Any corner radius goes to 0 as it reaches the edges. A headline sits above it
before it starts; nothing else on the screen moves while it grows.
- *Phone.* Start at full width inside the 16px margins and grow to a full-bleed portrait, or skip the
growth.
- *Fails.* The growth is the only thing on screen for three scrolls; a loading spinner or a blank box
in the first frame (give it a poster, start loading before it enters); used twice.
**Uneven mosaic**
- *When.* A body of work, or one place seen several ways: five to twelve pictures.
- *Geometry.* Two columns, or rows of three on a 24-column grid. Two or three picture widths (for
example 32%, 48% and 65% of the width, or 5, 7 and 12 columns), neighbours offset vertically by
120–190px, 130–450px between rows, one or two ratios, a caption under each.
- *Phone.* One column alternating full width and about 70% width, or two columns of 120–180px tiles
that keep the offsets.
- *Fails.* Random ratios with random offsets, which read as a stock dump. Captions in 9–12px mono with
index numbers, the award-site habit: here the caption is one line in the text face at body size, with
no number.
**Horizontal gallery**
- *When.* A set people browse in any order: products, rooms, events, a team.
- *Geometry.* Cards 25–35% of the width (435×326 with 20px gaps at 1440: three in full and a fourth
cut by the edge), or a strip at one fixed height with mixed widths (576px tall in the study), or a
slider whose centre slide is about 73% of the width with 170–180px of each neighbour showing. Always
cut by the right edge so it reads as more. A marquee of pictures moves slowly: 60–120s per loop.
- *Phone.* One card at 75–80% of the width (290–300px of 390) with the next one showing; swipe.
- *Fails.* Arrows and no drag or swipe; a marquee of things people need to read; a carousel holding the
page's one important picture.
**Panel sliding over a held picture**
- *When.* A change of chapter: the picture holds, and the next section rises over it.
- *Geometry.* The picture is sticky at 100vh; the next section's ground slides up over it at full width,
its top edge straight or shaped from the subject (the study has the arch of a building's windows).
Here it is a solid panel with square corners: no rounded sheet tops, no frosted glass.
- *Phone.* Unchanged.
- *Fails.* Decks of five or six cards whose earlier cards linger as slivers; stacking for its own sake.
**Even grid**
- *When.* An archive only (layout.md §5): a shop range, the team, a menu, the full list of work.
- *Geometry.* Two, three or four across, equal cells, 8–16px gaps, one ratio (4:5 is the commonest),
the caption under each picture.
- *Phone.* Two across for products, one for work and people.
- *Fails.* Three equal cards as "features". Cards with a dark gradient at the foot so the label can sit
on the picture, common in the study: here the label goes under the picture.
**Index with hover preview**
- *When.* A list of eight or more projects or clients where the names matter as much as the pictures.
- *Geometry.* Rows on a 45–80px pitch, names at 30–60px. One picture at a time, about a third of the
width at 16:9 or a portrait 300–450px tall, at a fixed place (the right half from the 50% line, or
the centre), swapped within 0.3s. A version that needs no mouse: the names scroll across a picture
fixed at the centre and the picture shows whichever name is crossing it. In the study the picture
sits on top of the names; here nothing lies on top of large type, so the names pass in front of it.
- *Phone.* No hover. Give each row a thumbnail about 100px wide, or use the scroll version.
- *Fails.* Pictures that never appear on a touch screen. Rows divided by hairlines, the award-site
habit: here rows are separated by space, and the active row by ink or a change of ground.
**One picture on a field, or one small picture beside a statement**
- *When.* A pause in the page: a single portrait or object. Or a small picture that gives a statement
a face.
- *Geometry.* A single centred picture at 3:4 and 75% of the viewport height, on a ground taken from
the picture. Or a small picture 7–26% of the width (108–374px) beside, above or between the lines
of a 48–136px statement, aligned to one of its lines.
- *Phone.* The picture above the statement at 60–100% of the width.
- *Fails.* Used as the default for every picture: a picture with equal margins on all sides reads as
placed by default (section 5).
**Small pictures between words**
- *When.* A statement about the things themselves (materials, people, products), on a screen of its
own.
- *Geometry.* Two to four chips in the statement, from the cap height to three-quarters of the type
size tall and one to one and a half times as wide (50×46px in a 60px line; 113×75px between the two
words of a 150px name), all from one shoot, square corners. Give them a width and height so the lines
do not jump while they load.
- *Phone.* Keep them inline at the height of the text.
- *Fails.* More than four; chips from different shoots; a chip that carries information the words do
not.
**Picture inside or behind display type**
- *When.* The object is the subject: a product, a building, a person.
- *Geometry.* A small picture window sits inside a block of 150–200px display type and later opens
into the next screen; or the object stands behind the type, showing between and around the letters;
or it touches the edge of the word without covering a letter. In the study the object more often
stands in front and covers one or two letters, or a line of type runs behind a square picture; here
nothing lies on top of a headline (the inspector fails large text a fifth or more hidden), so the
type stays in front.
- *Phone.* Keep it, with the type at about 15vw, and check the word still reads.
- *Fails.* An object over the letters; type displaced or glitched by the picture.
**Cut-out object on a flat field**
- *When.* Products and objects. The highest mean score in the table, on 7 of the 36 top sites.
- *Geometry.* The object at 35–60% of the width on a flat ground taken from its own colours, standing
on the bottom edge or running off one side, with the type behind or beside it.
- *Phone.* The object at 80–90% of the width under the headline.
- *Fails.* A render floating in a void with no ground; a drop shadow added to a cut-out (no resting
shadows here; keep a contact shadow only if it was photographed).
**Scattered collage**
- *When.* Archives, ephemera, a festival of many small things. Sparingly: it is about as common on the
lowest pages as on the top ones.
- *Geometry.* Six to sixteen pictures of 100–300px at a few sizes around a centred heading, never over
it.
- *Phone.* Re-stack as overlapping pairs in one column.
- *Fails.* Tilted prints and stickers with offset shadows, common in the study and not a treatment
here (section 6); pictures crossing the text.
**Small card docked in a corner**
- *When.* A standing pointer to the reel or to the next thing.
- *Geometry.* About 216×144 to 240×140, 30–43px from the corner; one per page.
- *Phone.* Drop it, or move it into the flow.
- *Fails.* Content scrolling under it; a second one.
**3D gallery**
- *What.* Pictures on a ring, sphere, helix or tunnel in a WebGL scene, turned by the wheel or by
dragging.
- *In the study.* 12 sites, 4 of them among the 16 lowest; mean score 7.30 against 7.44, usability 5.9
against 6.35.
- *Here.* Do not build one. Show the same pictures as an index with previews or a horizontal gallery.
### Footage
**What the study measured.**
- Footage on 38 of 91 sites (42%): 18 of the 36 top sites, 19 of the 39 in the middle, 1 of the 16
lowest. A site with footage carries a median of three or four clips (middle half 1–8).
- Footage covers at least half of the first screen on about a quarter of the sites, and is full-bleed
on nearly all of them.
- Clips are short: median 10s, middle half 5–14s; 61% run 10s or less and 88% 20s or less.
- Every decorative clip is muted, loops, plays inline and has no controls. The autoplay attribute is on
44% of clips; the rest are started by script, mostly as they come into view.
- Only 31% of clips have a poster (13 of the 38 sites). The rest show a blank or grey box until they
play, and the panel recorded those as blank first paints. Here a poster is required (section 11).
- Sources: 1920×1080 is the commonest, then 1280×720; tall clips at 450×800 or 540×960 for phones and
portrait tiles. Median source width 1,080px.
- On the page: 16:9 41%, square 19%, portrait (4:5 to 9:16) 25%, 4:3 or 3:2 12%. Corners square on 75%
(the rest 8px or less, or 20–32px). `object-fit: cover` on 84%, contain on 16% (product strips, letterboxed
films).
**Where it goes.**
- **Hero background:** full-bleed, cover, often oversized by 1–25% so a drift or zoom never shows an
edge. Either one slow take or a montage cutting every second or two.
- **Inline, as a picture:** in splits, tiles and cards, at about a third of the width (median 33%,
middle half 24–45%); a third of these clips are under a quarter of the width and fewer than one in
ten is full width. Slow, near-still subjects (water, skin, light, a turning object) read as living
photographs and sit well among stills.
- **The frame that grows** (above), for a reel.
- **A docked card** (above): one small clip in a corner.
- **On hover:** only two or three sites play clips on hover, all of them in an index or list. Nothing
that matters should play on hover only; phones have no hover.
- **Full films:** about ten sites offer a full film behind a play button that opens a player with
controls. None autoplays it.
- **Sound:** six sites offer sound behind a toggle; no clip starts with sound.
**How text sits on it.**
- Of about 30 sites with a full-bleed picture or film behind text on the first screen, about 14 set the
type straight on it and rely on the footage being graded dark where the type sits; about 10 dim it
(to half brightness, under a 30–40% dark layer, or with a dark fade along the bottom 180–590px);
about 6 keep the text off the picture, above it or on a solid card.
- What goes on footage in the study is the name or a headline at 47–136px and one short line, docked
bottom-left or centred. Where body text at 11–14px went on footage or a busy photograph, the panel
named it as a fault, on top-scored sites too.
- Here: one headline, one line and one action on footage at most, and never body text. Check contrast
where the type sits across the whole loop, not only on the poster; if any frame drops below 4.5:1
(3:1 for type of 24px and up), use the scrim in section 6 or a solid band.
**On the phone.** Footage re-crops to a full-screen portrait (a separate tall source, or
`object-position` on the subject); inset frames go full-bleed. One site in the study adds a 40% scrim
on the phone only, where the type covers more of the picture.
### Crops and treatment
- **Ratios.** Of 1,356 pictures and clips measured: 4:5 21%, 16:9 19%, 1:1 16%, 16:10 10%, 3:2 7%, 2:3
5%, 4:3 5%, 5:4 4%, 3:4 3%, wider than 2:1 6%, 9:16 or taller 3%. Landscape 52%, portrait 33%,
square 15%.
- **Two ratios do most of the work.** On sites with four pictures or more, the two commonest ratios
hold 76% of the pictures. The others (five ratios in all at the median) are exceptions with a reason:
one tall portrait in a run of landscapes, one wide band.
- **Square corners** on 94% of pictures; 62 of the 68 sites with pictures in the page keep at least 80%
of them square. Where corners are rounded, it is 5px or less on two-thirds of them, or 20–32px on
card interfaces, one value per site.
- **Cover.** `object-fit: cover` on 78%, contain on 10% (a product on a tile, a letterboxed film).
- **Untouched.** CSS filters touch pictures on only 9 sites, mostly for hover states. A handful use
black-and-white photography as a set; two multiply scanned paper onto the ground. Several keep the
interface black, white and grey and let the photographs carry all the colour.
- **Common in the study, not treatments here** (section 6): a fixed grain layer over the whole page
(about a third of the sites), duotone or tinted photographs, blurred copies behind a picture as a
glow, tilted prints and stickers with offset shadows.
- **Oversize only by the distance it moves.** 22 sites move pictures with the scroll, oversizing them by
1–25%. Oversize by the travel and no more, on one picture per screen.
- **Resolution.** The study serves pictures at a median 1.9 times their CSS width, but 17% are served
smaller than they are shown and full-width pictures at a median of only 1,440px. Here: twice the CSS
width, capped at about 2,400px (section 4).
### Text over pictures
- In the study, text sits on pictures often and is checked rarely: about 28 notes record text set
straight on a picture with no scrim, and the faults of 16 sites include text lost over pictures: 8–14px
labels and body over photographs, and navigation in a difference blend or with no backing passing
over them.
- Where it works, the text is display size and sits over the calm part (sky, a dark wall, the shadow
side), docked in a corner or along one edge. Two other forms in the study: a solid card in the
ground colour inset over the photograph and carrying the heading, and text on the white of a picture
that fades into the page.
- Here: on a picture put only display text, one short line and one action. Body text, captions and
labels go beside or under it. Measure contrast at the text's own position (4.5:1, or 3:1 for 24px and
up). If it fails, move the text to a calmer part, put it on a solid card in the ground colour, or use
the scrim in section 6. A fixed navigation that passes over pictures gets a solid band, or hides
while scrolling down.
### Rules for a generator
1. **About a third of the page is picture.** Across the scrolled screens, pictures fill 30–40% of the
area, and at least one screen in four carries no picture (top sites: 30% and 28%).
2. **A picture leads its screen or stays small.** A leading picture takes at least 60% of its screen;
an accent stays under 10% of it. Avoid screen after screen of pictures at 20–40%: in the study 42% of
screens are 0% and 19% are 90% or more.
3. **Three to five placements per page,** and no two neighbouring sections with the same one. Sites
with two or fewer average 7.31, sites with three or more about 7.5.
4. **Six to twelve pictures, never fewer than four.** Study median 7; top sites 4–27; lowest 2.
5. **Two ratios for at least 75% of the pictures** (76% in the study), a third only for a deliberate
exception. Defaults: 4:5 for portraits, 16:9 or 3:2 for landscapes.
6. **Square corners on every picture** (94% in the study). If the direction rounds them, one radius of
4px for every picture and clip.
7. **Split pictures take 42–50% of the width, or two-thirds,** at the full height of the section and
touching the viewport on their outer side; the text starts on the 50% line. On the phone the
picture goes first, full width.
8. **Full-bleed bands are 100% wide and 100–150vh tall,** one every three or four screens, oversized by
10% at most for parallax, with text only in one corner at least 32px in. On the phone, re-crop to a
full-screen portrait.
9. **One inset frame that grows per page,** from 35–50% of the width to the full screen over
720–2,700px of scroll, with nothing else moving on that screen.
10. **Horizontal galleries show three cards and a cut fourth:** cards 25–35% of the width with 16–20px
gaps; on the phone one card at 75–80% of the width with the next one showing.
11. **One moving picture per screen.** Clips of 5–15s (median 10s), muted, looped, inline, with a
poster, paused out of view; a hero source of 1920×1080 under 10MB, inline clips at 1280×720 or
smaller under 5MB.
12. **Only display text on a picture:** a headline, one line and one action at most, with contrast
measured at the text's position across the whole loop. Body text, 16px or more, goes beside or
under the picture, never on it.
@@ -0,0 +1,292 @@
# Layout
How the page is put together: margins, the grid, where text sits, how sections differ, navigation,
endings and the phone. The direction sheet gives the specific compositions; this file gives the habits
behind them, taken from the panel's notes on the 114 studied sites that scored 7.3 or higher (counts
below are hand tallies from those notes, so read them as "about").
## Contents
1. What strong pages do
2. Margins and the grid
3. Where text sits
4. Empty space
5. The frame: margins, balance and titles
6. Sections: carry the idea, vary the arrangement
7. Navigation
8. Endings
9. The phone is a second composition
10. How pages fail
## 1. What strong pages do
- **Tight margins, content to the edges.** About 45 of 114 use outer margins of 32px or less; only
about 8 use 80px or more. The centred 1100–1300px container is the minority among strong sites and
the usual frame of the ones the panel called stock.
- **Text hangs from an off-centre line.** About 22 anchor text to one fixed line that is neither the
centre nor the margin: a third across, 42%, the midpoint.
- **Two or three type sizes with nothing between** (about 30): one very large with one small, or
everything small with the scale given to the image.
- **One thing per screen** (about 20): one statement, one object, one project.
- **Sections told apart by their ground.** About 20 change the whole background colour at each
section; about 12 alternate light and dark; about 10 never change and separate by space alone.
- **Sections recomposed, not repeated.** About 14 rearrange every section; about 12 alternate a
full-bleed image screen with a near-empty type screen.
- **The system broken once, on purpose** (about 14): one centred line on a split page, one paper card
on a dark page, colour held back until the last screen.
- **Often no navigation bar** (about 45), and endings that are a designed scene rather than link
columns.
- **Few words.** Median 575 on the whole study; some of the best carry under 200.
## 2. Margins and the grid
```css
:root {
--margin: clamp(1rem, 2.2vw, 2rem); /* 16–32px: tight, so content reaches the edges */
--gutter: clamp(0.75rem, 1.4vw, 1.5rem);
}
.wrap { padding-inline: var(--margin); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
```
- One pair of margins and one grid for the whole page. Every left edge is a column line.
- Do not wrap the page in a `max-width: 1200px; margin: auto` container by reflex. Let large things
(type, images, colour fields) run the full width, and constrain only the measure of running text
(`max-width` in `ch` on the paragraph, not on the page).
- On very wide screens cap the layout at about 1760–1920px, or let type sized in `vw` stop growing with
a `clamp()`.
- Directions that call for wide margins (uneven photographs, the quiet instrument) say so on the
sheet; follow the sheet.
- Never draw the grid. Column lines, crosshairs and guides are banned ornaments here even though they
are fashionable; the grid is felt through alignment.
## 3. Where text sits
- **Pick the anchor line and reuse it.** If the hero headline starts at column 5, the section
headings, the pricing figure and the footer links start there too. Two or three anchor lines on a
whole page; six means nothing was decided.
- **Off-centre beats centred.** A heading in the left third with its paragraph in the far-right
column on the same baseline and nothing between; a headline in the bottom third under a deliberate
void; text docked in corners with the middle given to the picture.
- **Opposite ends.** Two short texts at opposite ends of one baseline (name left, one-line description
right) is the commonest alternative to the centred stack.
- **Centred is a decision.** When a strong page centres, it uses one axis with wide empty flanks and a
single element per screen, not a centred stack of heading, paragraph and two buttons.
- **Rows for long copy:** heading in the left third, body in the right two-thirds, the same split for
every row on the page.
- Stagger sparingly: a second line indented by a third so a two-line headline steps across the screen.
## 4. Empty space
Empty space is placed, not left over. In the study of 97 Sites of the Day captured in October 2026,
readers judged every large empty area either a rest, which something closes, or a hole, which nothing
closes. Strong sites have plenty of rests. About half of them still leave at least one hole, and the
holes are what visitors name ("big random blank spaces").
**A void is placed when something closes it.** In the readers' notes the closer is, most often:
- the picture itself (sky, ground, the object, the film) with the type docked at its edge;
- a picture across the void: the photograph at the far side of an empty band, the full-width row
rising under a statement, the opposite picture in a stagger;
- the type: a headline large enough to span the space, or two short texts at the two ends of one
baseline;
- on a one-screen page, the wordmark, the navigation and one fact in the corners, like a mount around
a print.
About one closer in five in the study is a hairline or grid line. That device is banned here; close the
void with the picture, the type, a step in ground tone or a field of flat colour.
**Where holes come from.** In order of frequency in the study:
1. A gap between sections stretched too far: bands of 250–760px of plain ground.
2. Text in one half of a section and nothing in the other.
3. Content that arrives late: a reveal not yet fired, so the visitor scrolls onto bare ground while the
words fade in.
4. A short block stretched to a full screen: a footer padded to the viewport height, a section title
alone on a whole screen.
5. A pinned scene that holds almost nothing for several screens.
6. Pictures of different heights top-aligned in a row, leaving a hole under the short one.
**Rules**
- Decide where the void is and name what closes it. If nothing does, it is a hole: move the section's
picture into it, make the main element bigger, or take the space out.
- Keep the void clean: no stray caption, no decorative shape, no faint watermark, no grid lines.
- Sections sit 120–240px apart on desktop, counted from the last line of one to the first line of the
next (the median in the study is about 230–250px), and 72–112px on a phone. A change of ground
colour lets the gap be a little smaller. More than about 40% of a screen of plain ground between two
sections is a hole.
- The gap between sections is clearly larger than any gap inside one. Related things sit close and
unrelated things far; a heading has at least twice as much space above it as below.
- No stretch of the page taller than about half a screen holds nothing at all. In the study, runs of a
screen or more with nothing in the page were almost always scroll distance for a pinned scene; the
pinned stage must then hold something at every scroll position.
- Content is in place on the first paint. An entrance moves what is already there (see `motion.md`);
it never leaves a screen of bare ground while the words arrive.
- Size a section to its content. `min-height: 100vh` belongs to a section that fills the screen, not
to a footer or a lone heading.
- In a row of pictures of different heights, align the bottoms or put something under the short one,
or crop the row to one ratio.
- A rest is one thing on a screen: a single line, one object, one picture or a field of colour. Once
per page at most. A screen of nothing is never a rest, and the inspector fails two near-empty screens.
- When a section looks empty, the answer is almost never "add something". Make the main element
bigger, or cut the section.
## 5. The frame: margins, balance and titles
The frame is what readers judge before any detail: "no good margin", "everything is shifted to one
side", "no clear title", "random unclear sections". The inspector measures each of these. The numbers
are from the study of 97 Sites of the Day (October 2026).
**One pair of equal margins.**
- The left and right margins are the same, at every width. On the studied sites with scrolling
sections the two sides match to a few pixels: 16 and 16, 20 and 21, 32 and 32, 140 and 137. No
strong page in the study was pushed to one side.
- The usual margin is tight: median about 28px at 1440, half the sites between about 18 and 45px, a
few at 80px or more. Here it is 16–32px (section 2), and never under 16px at any width, on the phone
included. About one award site in seven sets text 8–15px from the edge; that studio habit is
excluded here.
- The navigation sits on the same margins as the content. The commonest small fault in the study is a
header on one inset and the page on another (40px against 20px, or 34px on the left against 10px on
the right).
- A bleed is deliberate and starts from the margin: a row of pictures that runs off the right edge
begins on the left margin; a display line that bleeds starts or ends on one. A bleed never moves the
margin.
- Asymmetry belongs inside the frame: an off-centre anchor line (a third across, 42%, the centre line)
on symmetric margins.
**Use the width the page has set up.**
- In the study about one text section in a hundred leaves half the width empty. Text that sits in one
half is common, in roughly one site in three, but the other half then holds the section's own
picture (most often), a full-bleed picture behind the text, a second column, a list or a figure.
- When the text takes one side, put the picture, the second column, the list or the large number on
the other. Never leave the other half to plain ground.
- The middle counts too: a card at the left edge and a lone button at the right with the middle third
empty is the same hole.
- The first screen counts too. When the sentence and the button take the left third above a name
docked at the foot, the upper right holds something (the picture or film, one fact, the action) or
the name grows up to meet them; in testing, 60% of a first screen left as plain ground under the
navigation read as a hole, and the inspector now warns when one empty block takes 28% of the first
screen or more.
- A display line fitted to the width is fitted by measurement (a small script that sets its size from
the content box, or SVG text with `textLength`), never by a size guessed for one face: a name that
starts on the margin and runs a few pixels off the other edge reads as a mistake.
- A centred composition has one axis, equal flanks and one thing on it, not a stack of heading,
paragraph and two buttons.
**A title that says what this is, on the first screen.**
- Asked whether a stranger could say what the page is within five seconds, readers found that 40 of
the 97 first screens say it in words, 42 say it only through pictures, small print or a bare name,
and 15 do not say it at all. The last group lost usability and content points, not design points.
- Size alone does not do it: 66 of the 97 first screens carry a display-size line, but 26 of those are
a name, a slogan or a place with nothing saying what is offered.
- The first screen has one `h1`, at display size (32px or more on desktop; on the clear first
screens in the study mostly 50–150px), two lines at most, that says what this is: the name with
what it does, the product with what it is for, the event with its date and place. If the `h1` is
the name, the line under it, at 18px or more, says what the thing is.
- It is there on the first paint. About a third of the studied pages still showed a loader or nothing readable 4.5
seconds.
**A heading on every section.**
- 82% of the text sections on the studied sites open with a heading: median 57px (40–96px for the
middle half), about three and a half times the body size, one or two lines.
- Headings start on the outer margin or on the page's inner anchor line (in the study about a third
each) and in the same place for every section. About a quarter of the headings in the study are
centred; centre one moment per page here.
- Where a studied section has no heading, the usual stand-in is a small label: caps, mono or a number
in brackets. All three are banned here. Put the label's words in a heading in the heading face at the
section size ("The rooms", "How it works", "Prices"), set them on the anchor line, and let the first
sentence carry the rest.
- A statement can open a section as its heading only when it names what the section holds.
## 6. Sections: carry the idea, vary the arrangement
The most common failure in every score band is a distinctive first screen followed by stock blocks:
icon cards, a three-up row, an accordion, a link-column footer. About 25 of the studied sites lose
points this way, including several above 7.5.
- **The idea survives to the last pixel.** Ask of each section, pricing and FAQ included: what does
the page's idea do here? If the page is a menu, the prices are menu lines. If the name is a
monument, the footer is the name again. If the object's shape is the motif, the button and the image
masks carry it.
- **No two neighbours share a layout.** Use the shape of each dealt blueprint (list, split, statement,
full-bleed, mosaic, table, strip, pinned …). Count them in the screenshot; a shape used three times
is the template showing.
- **Alternate dense and empty, image and type, light and dark** so the scroll has rhythm.
- **A repeated module is for archives only** (a work index, a menu, a schedule). Anywhere else,
repetition reads as a kit.
- **Break the system once.** After the page has established its rule, one planned exception (a single
centred line, one inverted section, one image at a different scale) is what makes the rule visible.
- **Short pages are fine.** A quarter of the studied sites are one or two screens. Do not add sections
to seem complete; each one must carry a claim (see `copy.md` §1).
## 7. Navigation
- The plain form: the wordmark at one end, three to five text links, one action, all at text size on
the page ground with no bar, border or background. Median link count in the study is 3.
- It is also normal to have less: small links in the corners, a single action, or the navigation
folded into the content (a list of section titles that is itself the index).
- If it stays fixed, it must never sit on top of content: check every scroll position in the
screenshots, especially on the phone. Floating pill bars are where the panel most often recorded a
nav covering text.
- On a phone: wordmark and one action in view; the links go in a simple full-screen sheet opened by a
text button ("Menu"), or move to the footer when there are only three.
- Navigation is small furniture, however it is drawn. On desktop it takes no more than about a tenth
of the first screen; on a phone it is one row. A blueprint that builds the navigation as a column of
colour blocks or a stack of tabs gets that treatment on desktop only, and collapses to one row on
the phone so the headline, the supporting sentence and the action stay in the first screen. In
blind tests a nav that ate the hero cost a whole point of usability.
## 8. Endings
- The page should end on a designed scene. In the study: the name at full width (about 18 of 114), the
hero repeated in inverted colours (about 12), a closing scene of its own such as the one contact
fact set as a headline (about 14), or simply one quiet line (about 22). Link-column footers appear
in the panel's notes only as criticism.
- The full-width name is strong but already a studio habit; use it when the direction is name-led,
otherwise prefer the blueprint dealt for the footer.
- Put the practical facts (address, hours, email, legal line) in the text face at a readable size, on
the same anchor lines as the rest of the page.
## 9. The phone is a second composition
Design the 390px screen as its own page, not the desktop page squeezed. Strong sites that neglect this
lose usability points; weak ones are often just broken.
- Each blueprint on the sheet has its own phone paragraph. Build it.
- Keep one dominant thing on the first screen. A wordmark that was 20vw wide on desktop might stack on
two lines at 26vw on the phone and be even stronger.
- Re-author line breaks; re-crop images to portrait (`object-position` per breakpoint, or a different
crop size in the image URL); keep unevenness with alternating widths rather than making everything
full width.
- Margins 16–20px. Body text 16–18px. Touch targets at least 44px high.
- Check 390px in the inspector's screenshots for every round: sideways scroll and three-line headlines
usually appear here first.
## 10. How pages fail
From the panel's notes on the 54 sites scoring under 6.8 (counts out of 54):
- The whole page is the category's stock template; swap the logo and it could be any competitor
(about 20).
- Centred headline, pill badge, two buttons, screenshot or tile grid below (about 9).
- A distinctive hero, then stock modules in the usual order (about 8, and about 17 more in the middle
band).
- A default typeface with no character (about 13).
- Text too small or too low in contrast (about 13); legibility lost over busy photographs (about 9).
- An undersized headline and no focal point (about 9).
- The page never says what the thing is, or shows no work (about 12 and 7).
- Thin content stretched over many screens, or a single gag (about 11).
- Gates, preloaders and entry pop-ups before any content (about 9).
- A phone layout that is the desktop shrunk, or broken (about 8).
- Fixed elements sitting on content; careless details such as colliding lines (about 7 and 5).
And the faults of the best sites, which are not to be copied: body text at 9–13px (about 24 of the 90
sites at 7.5 or more), information hidden behind the concept (about 20), the idea running out
mid-page (about 15), text over footage with no scrim (about 12), gates and blank first paints (about
12), floating elements colliding with content (about 11), and a first screen with no statement of what
this is and no action (about 9). Say what the thing is, and offer the action, on the first screen.
@@ -0,0 +1,980 @@
# Motion
Read this before adding any animation. The static page must already be finished and passing
`inspect.mjs`; motion is the last pass, never a way to rescue a weak layout.
## Contents
1. The budget
2. What strong pages do with motion
3. Four questions before any animation
4. Tokens
5. Scroll behaviours, ranked
6. Recipes (copy, then adapt)
7. Signature moves
8. Reduced motion and performance
9. What gives generated motion away
10. Rules for motion
## 1. The budget
A page that does not move reads as a mock-up; the inspector counts what moves while the page loads and
scrolls, and fails it under eight elements. The motion layer has five parts:
- **Two or three scroll scenes:** the big, scroll-driven moments award sites are remembered for (a
camera flying through a field of pictures, a picture that grows from a window to the whole screen,
type that crosses a held stage, cards that deal out and stack). The direction sheet deals them from
`library/scenes.json` when the library has scenes for the brief; `references/scenes.md` says how to
fill and fit them. They are where the page is bold; everything else stays quiet so they land.
- **One signature moment:** the thing a visitor would describe an hour later: the headline that rises
out of a mask, the image that opens like a shutter, the wordmark that scales with the scroll. The
direction sheet from `draw.mjs` names one. Build that one well; it is the only move allowed to be
loud.
- **Section entrances, choreographed per section:** the parts of each section arrive in reading order
(heading, text, picture, details), staggered 60–90ms, rising 16–32px with opacity, once. Vary the
manner with the content instead of stamping one preset everywhere: a list arrives row by row, a
picture opens from a mask or settles from a slight scale, a figure counts up once, a statement rises
line by line.
- **Feedback under the hand:** buttons that give on press, links that answer the pointer, cards and
pictures that respond on hover, a menu that opens from where it was clicked.
- **One moving picture:** a found clip as a muted loop with a poster frame, a slow drift or scale on
a large photograph, or a scroll-linked move on the hero picture.
What is still banned: the same fade-and-slide-up with the same timing on every block, which is the most
cited motion tell of generated pages; motion that hides content until a script runs, with no fallback;
bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
anything; choreographed motion with one protected moment says somebody did.
## 2. What strong pages do with motion
Measured on the latest 99 Awwwards Sites of the Day (July to October 2026), at 1440 and 390, with
the transitions, tweens and scroll triggers recorded while each page loaded and was scrolled. Where
the gallery does something the house rules ban, the house version follows.
- **The stack.** Two in three use a smooth-scroll library and three in five ship Three.js; GSAP is
named on 56. The quiet layer below (reveals, line rises, the ink fill) needs none of it: CSS scroll
timelines and view transitions do that work, though only 3 and 6 of the sites use them. The big
scroll scenes are built on GSAP ScrollTrigger, as on the award sites, through the shared stage in
`assets/scenes/` (`references/scenes.md`).
- **Curves.** The curves that really ran are strong ease-outs: expo out (`.16,1,.3,1` and
`.19,1,.22,1`) ran on 14 sites, cubic out on 13, quint out on 7, and by number of transitions the
quint and expo outs lead every chosen curve. In-out curves are kept for wipes and things that
travel (cubic and quart in-out, about 15 sites each). Ease-in appears only for exits, short, before
a slow arrival. Colour changes use plain `ease` (46 sites). Overshoot sits in 19 stylesheets and
ran on 2.
- **Durations.** Hover colour 200-300 ms; small interface moves 300-450 ms; reveals and line rises
600-1,000 ms; picture openings and wipes 1,100-1,600 ms. The median of everything that ran on a
site is 420 ms.
- **Staggers.** 20-40 ms between characters, 75-120 ms between lines, 50-100 ms between list items.
- **Scroll-linked motion is locked to the scrollbar.** Of the sites that scrub, 14 of 18 lock it with
no smoothing; the other four smooth it by 0.1-3 s. Reveals fire when the element's top is 80-95% of the way
down the viewport, once.
- **How much moves.** A median of 130 elements are moved by script on a page, but they come from a
few gestures: one split heading is 20-60 elements. The inspector's floor (8, warning under 16) is a
floor, not a target.
- **Waiting is the most common cost.** 60 of 99 open on a loading screen, 26 of them with a counter.
Half the usable pages were still unreadable 2.5 s after the browser said the page had loaded, a
third at 4.5 s. The preloaders were not covering slow pages (their documents loaded faster than the
rest) and the readers' design marks were the same whatever the wait; only usability fell. House
version: no preloader, the message readable on the first frame, the load sequence over by 1.2 s.
- **Pins cost length.** Two in three scrolling pages hold something sticky or pinned. Those pages
are twice as long (a median 16.6 screens against 7.5) for fewer words per screen (27 against 46).
House version: at most two held stages, about one screen per step, three screens per stage.
- **Reduced motion is mostly missing.** 35 of 99 have any `prefers-reduced-motion` rule, 16 have
three or more; 5 of 26 scroll-scrubbed scenes have one. Every recipe here keeps the finished state
as the reduced state.
## 3. Four questions before any animation
1. **How often will someone see it?** Once per visit (hero load, a section reveal): it can take its
time. Many times per visit (hover, menu, accordion): fast and small. Hundreds of times (keyboard
actions, typing): no animation.
2. **What is it for?** Valid answers: it shows where something came from, it confirms an action, it
keeps a change from being jarring, it explains how the product works, it is the signature moment.
"It looks nice" is only valid for the signature moment.
3. **Which easing?** Entering or leaving: ease-out, so movement starts the instant the eye arrives.
Moving between two places on screen: ease-in-out. Colour and opacity on hover: `ease`. Constant
travel (marquee, progress): `linear`. `ease-in` makes interfaces feel slow because the first frames
barely move; do not use it. The CSS keyword curves are weak, so use the tokens below.
4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).
## 4. Tokens
```css
:root {
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* new: reveals of 800ms and longer, line rises, openings */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* new: something leaving for good, short */
--t-press: 140ms;
--t-hover: 180ms;
--t-ui: 240ms;
--t-reveal: 800ms;
--t-open: 1200ms; /* new: picture openings and clip wipes */
--stagger-line: 90ms; /* new */
--stagger-item: 70ms; /* new */
}
```
How the study bears on each:
- `--ease-out` (quint out) is in 28 stylesheets with its near twin and is the single most frequent
chosen curve among transitions that ran. Keep it.
- `--ease-expo` is new. Expo out (`.16,1,.3,1` and `.19,1,.22,1`) is in 28 stylesheets and ran on 14
sites, more than any other chosen family, and it is the curve the long reveals use: line rises,
picture openings, card entrances of 0.9-1.6 s. Use it for anything at `--t-reveal` or longer; it
replaces `--ease-out` in the masked headline and the reveal-on-scroll recipes.
- `--ease-in-out` (quart in-out) matches its family (14 stylesheets, 7 ran). Keep it for wipes,
shutters, menus and page transitions.
- `--ease-soft` (cubic out) matches the cubic-out family (21 stylesheets, 13 ran), but the study uses it
for interface moves of 0.3-0.8 s, where `--ease-out` already serves. Narrow it to slow scale and
drift on photographs.
- `--ease-exit` is new. Ease-in is right for exactly one job: something that leaves for good (an
outgoing caption, a replaced slide, a closing panel), short, at about 60-70% of the matching
entrance. Never for anything the visitor is waiting to see.
- Not adopted: `.4,0,.2,1` (38 stylesheets) is a framework default, and overshoot curves (19) stay
banned on interface elements.
- `--t-hover` stays at 180ms. The study's colour hovers run 200-300ms; a little quicker reads as
more exact. A hover that moves something (a label roll, an arrow) takes `--t-ui` to 400ms.
## 5. Scroll behaviours, ranked
| Behaviour | Pages of 99 | What it serves | House version |
| --- | --- | --- | --- |
| Smooth-scroll library | 67 | A feeling of weight; makes scrubbed scenes easier to build | Optional, through `Stage.smooth()`: Lenis at lerp 0.1-0.14, off for touch and reduced motion |
| Held (sticky or pinned) stage | 44 | One picture or object carries several claims; chapters | Yes: the scroll scenes (`references/scenes.md`), two or three per page, one to four screens each; other sticky stages three screens at most |
| Split text | 40 | Lines and words arriving as they are read; hover rolls | Lines for headings and statements; words only for the ink fill; characters only for a heading of one to three words |
| Colour by chapter | 34 | Tells chapters apart without padding bands | Yes, as hard cuts at section edges; the header ink follows in 300ms |
| Scroll-scrubbed scene (camera, object, image sequence) | 26 | Explains a product by moving it | Yes, from the scene library, built on the shared stage with a finished composition for reduced motion |
| Parallax depth | 24 | Depth in collages; pictures drifting inside oversized frames | Travel under 8% of the element, pictures held at 106-110% in their frames |
| Marquee | 21 | Life on a static band | Existing "Edge-to-edge marquee": one per page, pausable |
| Masked line or word rise | 18 | Headings that arrive like set type | Yes: "Masked line reveal" on load, "Lines that rise on scroll" below the fold |
| Wheel drives a scene, no page scroll | 16 | Browsing one object | Not used (see 2.6) |
| Blur-in | 15 | Soft arrival | At most 8px of blur, 600ms, gone once settled; never on body text |
| Picture from inset frame to full-bleed | 14 | The hand-over from a type screen to the big picture | Yes: "Opening window", about one screen of scroll |
| Grey-to-ink text fill | 14 | Paces one statement | Yes, once per page, starting at 45% ink: "Ink fill" |
| Sheet slides over a held layer | 11 | Page-turn rhythm between chapters | Yes: new move "Sheet over a held picture" |
| Scramble or decode | 11 | A "code" voice | Not used (see 2.6) |
| Header ink by chapter | 10 | The fixed header stays legible | Yes, with colour by chapter |
| Horizontal track pinned to vertical scroll | 8 | Walking through rooms or a collection | Native drag row first; pinned only when the row is under three screens long, with keyboard support: "Sideways gallery" |
| Curtain footer | 8 | The page ends as a scene | Existing move |
| Rolling digits | 6 | Figures that arrive | Yes, for pages whose case is a handful of numbers: "Rolling figures" |
| Index with hover preview | 5 | Navigating work by name | Existing move, upgraded recipe |
Text, split by unit: characters on about 20 pages, lines on about 14, words on about 11. Character
splits serve short headings and button rolls; on long statements they leave fragments mid-scroll.
## 6. Recipes
All recipes keep the content visible when JavaScript is off, put every hidden state inside
`prefers-reduced-motion: no-preference`, and use the tokens above. Two notes that apply to several:
- **Scroll timelines need a scroller to watch.** `animation-timeline: view()` takes the nearest scroll
container, and `overflow: hidden` makes one. A picture inside an `overflow: hidden` frame therefore
never animates. Use `overflow: clip` on frames, or name the timeline on the section
(`view-timeline-name: --hero`) and point the children at that name. This also applies to the
existing "Scroll-linked progress" recipe.
- **Scroll-linked means linear.** The scroll is the easing. 14 of the 18 scrubbing sites in the study
lock motion to the scrollbar, which is what CSS scroll timelines do.
All recipes keep content visible when JavaScript is off or slow: the hidden state only applies under
`html.js`, which a one-line inline script sets.
```html
<script>document.documentElement.classList.add('js')</script>
```
### Load sequence in 1.2 seconds
Everything is in the HTML and painted on the first frame; the sequence only adds arrival. Nothing
waits for the load event, and nothing in the navigation animates.
```html
<header class="hero">
<h1 class="display lines">
<span class="line"><span data-line style="--i:0">Stools for</span></span>
<span class="line"><span data-line style="--i:1">small kitchens</span></span>
</h1>
<p class="lead" data-load style="--d:420ms">…</p>
<a class="button" data-load style="--d:480ms" href="#stools">See the stools</a>
<figure class="hero__picture" data-open><img src="stool.jpg" alt="…" fetchpriority="high"></figure>
</header>
```
```css
.hero__picture { overflow: clip; margin: 0; }
@media (prefers-reduced-motion: no-preference) {
.js .hero [data-line] { transform: translateY(105%); animation: load-line 800ms var(--ease-expo) forwards; animation-delay: calc(100ms + var(--i) * var(--stagger-line)); }
.js .hero [data-load] { opacity: 0; transform: translateY(12px); animation: load-rise 500ms var(--ease-out) forwards; animation-delay: var(--d); }
.js .hero [data-open] { clip-path: inset(0 0 100% 0); animation: load-open 1000ms var(--ease-in-out) 150ms forwards; }
.js .hero [data-open] img { transform: scale(1.08); animation: load-settle 1100ms var(--ease-expo) 100ms forwards; }
}
@keyframes load-line { to { transform: none; } }
@keyframes load-rise { to { opacity: 1; transform: none; } }
@keyframes load-open { to { clip-path: inset(0); } }
@keyframes load-settle { to { transform: none; } }
```
The timeline: the headline's lines rise from 100 to 990 ms, the picture opens from 150 to 1,150 ms,
the sentence and the button arrive from 420 to 980 ms. All settled by 1.2 s, four things at most.
Preload the display font (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) so the lines
rise in the real face, and give the picture `fetchpriority="high"` and a ground colour sampled from it,
so a slow image opens onto its own colour rather than a blank box. Study: 22 of 91 pages were readable
at the first intro frame and 11 more by 1.2 s; the intros the readers called respectful finished in
about 1-1.2 s; a third of the pages were still unreadable at 4.5 s.
### Masked line reveal (the classic gallery headline)
Each line slides up from behind its own baseline. Because headlines are capped at two lines, you set
the line breaks yourself and wrap each line.
```html
<h1 class="lines">
<span class="line"><span data-line style="--i:0">Stools for</span></span>
<span class="line"><span data-line style="--i:1">small kitchens</span></span>
</h1>
```
```css
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.lines [data-line] { display: block; }
@media (prefers-reduced-motion: no-preference) {
.js .lines [data-line] {
transform: translateY(105%);
animation: line-up 900ms var(--ease-out) forwards;
animation-delay: calc(var(--i) * 110ms + 150ms);
}
}
@keyframes line-up { to { transform: none; } }
```
The `padding-bottom` / negative margin pair stops descenders (g, y, p) being clipped by the mask.
On a narrow phone the two authored lines may each wrap; check the mobile screenshot, and if they do,
author a shorter mobile break with a `<br class="only-wide">` pattern or reduce the size.
### Reveal on scroll (once)
For the two or three sections that deserve it, not for every block.
```css
@media (prefers-reduced-motion: no-preference) {
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
}
```
```js
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
```
Never attach reveals with a scroll listener, never replay them when the element re-enters, and never
leave anything at `opacity: 0` if the observer fails to fire (the `html.js` gate handles no-JS; the
`unobserve` handles replays).
### Image shutter
An image opens from one edge while scaling down slightly inside its frame. Gallery staple; use it for
the one hero or showcase image.
```css
.shutter { overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
.js .shutter { clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
.js .shutter img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
.js .shutter.is-in { clip-path: inset(0); }
.js .shutter.is-in img { transform: none; }
}
```
### Press and hover
```css
.button { transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
.button:active { transform: scale(0.97); }
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
@media (hover: hover) and (pointer: fine) {
.button:hover { background-color: var(--accent-strong); }
.link:hover { text-decoration-color: currentColor; }
}
```
Hover styles go behind `(hover: hover)` because touch devices fire hover on tap and leave it stuck.
Hover should raise contrast, never lower it. Do not change font weight on hover (the text reflows).
Do not lift or scale every card on hover; one consistent, small response is enough.
### Scroll-linked progress without JavaScript
For a wordmark that scales as the hero leaves, an image that drifts a few percent, or a progress bar.
Wrap in `@supports` so browsers without scroll timelines get the static state.
```css
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.drift { animation: drift linear both; animation-timeline: view(); animation-range: entry 0% exit 100%; }
@keyframes drift { from { transform: translateY(-4%); } to { transform: translateY(4%); } }
}
}
```
Keep the travel small (under 8% of the element). Large parallax makes text hard to read and looks
dated.
### Pinned story
A section whose heading stays put while items pass. Use `position: sticky`; do not hijack scrolling.
```css
.story { display: grid; grid-template-columns: 5fr 7fr; gap: var(--gutter); align-items: start; }
.story__title { position: sticky; top: 18vh; }
@media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
```
### Index with preview, anchored to the row
Rows of names; pointing at a row or tabbing to it opens one frame level with that row. The frame
follows the row, never the pointer.
```html
<section class="index">
<ul class="index__list">
<li><a class="index__row" href="/work/harbour-house" data-preview="/img/harbour-house.jpg">
<span class="index__name">Harbour House</span><span class="index__year">2025</span>
<img class="index__thumb" src="/img/harbour-house-small.jpg" alt="" loading="lazy" width="640" height="400">
</a></li>
<!-- … -->
</ul>
<div class="index__frame" aria-hidden="true"><img alt=""></div>
</section>
```
```css
.index { position: relative; }
.index__list { list-style: none; margin: 0; padding: 0; }
.index__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0 var(--gutter); padding-block: 0.3em; font-size: var(--step-head); color: inherit; text-decoration: none; transition: opacity var(--t-hover) ease; }
.index__thumb { order: -1; flex-basis: 100%; width: 100%; height: auto; margin-bottom: 0.4rem; }
.index__frame { display: none; }
@media (hover: hover) and (pointer: fine) {
.index__list { max-width: 62%; }
.index__thumb { display: none; }
.index__list:has(.index__row:hover, .index__row:focus-visible) .index__row:not(:hover, :focus-visible) { opacity: 0.4; }
.index__frame { display: block; position: absolute; top: 0; right: var(--margin); width: 30%; aspect-ratio: 4 / 3; z-index: 1; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path 320ms var(--ease-out), transform 360ms var(--ease-out); }
.index__frame.is-open { clip-path: inset(0); }
.index__frame img { width: 100%; height: 100%; object-fit: cover; }
}
@media (prefers-reduced-motion: reduce) { .index__frame { transition: none; } }
```
```js
(() => {
const root = document.querySelector('.index');
if (!root) return;
const frame = root.querySelector('.index__frame');
const picture = frame.querySelector('img');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
let warmed = false;
const show = (row) => {
if (!fine.matches) return;
if (!warmed) { warmed = true; root.querySelectorAll('[data-preview]').forEach((a) => { new Image().src = a.dataset.preview; }); }
picture.src = row.dataset.preview;
const y = row.offsetTop + row.offsetHeight / 2 - frame.offsetHeight / 2;
const max = root.offsetHeight - frame.offsetHeight;
const opening = !frame.classList.contains('is-open');
if (opening) frame.style.transitionProperty = 'clip-path'; // open in place, do not slide in from the top
frame.style.transform = `translateY(${Math.min(max, Math.max(0, y))}px)`;
if (opening) { frame.getBoundingClientRect(); frame.style.transitionProperty = ''; }
frame.classList.add('is-open');
};
const hide = () => frame.classList.remove('is-open');
root.querySelectorAll('.index__row').forEach((row) => {
row.addEventListener('pointerenter', () => show(row));
row.addEventListener('focus', () => show(row));
});
root.addEventListener('pointerleave', hide);
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) hide(); });
})();
```
On touch screens each row shows its own picture above the name. Study: 5 pages navigate work this way;
frames from about 470x270 to large plates behind the list; the other rows dimmed to about 0.3 (0.4 here, the floor for dimmed text). The
image trails and cursor-borne previews seen elsewhere in the study are the gimmick version.
### Lines that rise on scroll
For a section heading or a statement whose line breaks you do not control. Splits after the fonts
load, reveals once, then gives the plain text back so it rewraps normally. Plain text only (no links or
emphasis inside).
```html
<h2 class="head" data-lines>Turned by hand in one small workshop</h2>
```
```css
[data-lines] .line { display: block; overflow: clip; padding-bottom: 0.12em; margin-bottom: -0.12em; }
[data-lines] .line > span { display: block; }
@media (prefers-reduced-motion: no-preference) {
.is-split .line > span { transform: translateY(110%); }
.is-split.is-in .line > span { transform: none; transition: transform 900ms var(--ease-expo) calc(var(--i) * var(--stagger-line)); }
}
```
```js
(() => {
const els = [...document.querySelectorAll('[data-lines]')];
if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const restore = (el) => { el.textContent = el.dataset.text; el.classList.remove('is-split', 'is-in'); };
const split = (el) => {
const text = el.textContent.trim().replace(/\s+/g, ' ');
el.dataset.text = text;
el.textContent = '';
const words = text.split(' ').map((word) => {
const s = document.createElement('span');
s.style.display = 'inline-block';
s.textContent = word;
el.append(s, ' ');
return s;
});
const lines = []; // group the words by the line they landed on
for (const w of words) {
const last = lines.at(-1);
if (last && Math.abs(last.top - w.offsetTop) < 4) last.words.push(w.textContent);
else lines.push({ top: w.offsetTop, words: [w.textContent] });
}
el.textContent = '';
lines.forEach((line, i) => {
const mask = document.createElement('span');
const inner = document.createElement('span');
mask.className = 'line';
inner.style.setProperty('--i', i);
inner.textContent = line.words.join(' ');
mask.append(inner);
el.append(mask, ' ');
});
el.classList.add('is-split');
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target);
e.target.classList.add('is-in');
e.target.querySelector('.line:last-of-type > span')
.addEventListener('transitionend', () => restore(e.target), { once: true });
}
}, { rootMargin: '0px 0px -12% 0px' });
document.fonts.ready.then(() => els.forEach((el) => { split(el); io.observe(el); }));
let width = innerWidth; // a width change before the reveal: give the text back
addEventListener('resize', () => {
if (innerWidth === width) return;
width = innerWidth;
els.filter((el) => el.matches('.is-split:not(.is-in)')).forEach((el) => { io.unobserve(el); restore(el); });
});
})();
```
Study: masked rises from 100-120% below the line, 0.6-1.2 s, 75-120 ms between lines, triggered when
the block's top is 80-95% down the viewport. Reduced motion: the script does not run; the text is plain.
### Opening window
The hand-over from a type screen to the one big picture: the picture first shows as a window in the
middle of a held stage and opens to full-bleed over about one screen of scroll.
```html
<section class="opening">
<div class="opening__stage">
<img src="workshop-wide.jpg" alt="The workshop at first light, benches cleared" width="2400" height="1500">
</div>
</section>
```
```css
.opening__stage { height: 100svh; overflow: clip; }
.opening__stage img { display: block; width: 100%; height: 100%; object-fit: cover; }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.opening { height: 200svh; view-timeline-name: --opening; --inset-y: 20%; --inset-x: 28%; }
.opening__stage { position: sticky; top: 0; animation: opening-window linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
.opening__stage img { animation: opening-settle linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
}
}
@media (max-width: 700px) { .opening { --inset-y: 18%; --inset-x: 8%; } }
@keyframes opening-window { from { clip-path: inset(var(--inset-y) var(--inset-x)); } to { clip-path: inset(0); } }
@keyframes opening-settle { from { transform: scale(1.15); } to { transform: none; } }
```
The section is two screens tall; the stage is held for one, and the window is fully open after three
quarters of it, so the full-bleed picture rests for a moment before the page moves on. Study: 14 pages;
start windows 38-60% of the width; runways 600-1,800 px, and the readers asked for one to two screens.
Without scroll timelines, and under reduced motion, the picture is a full-bleed screen.
### Held picture, passing captions
The pinned story built for photographs: the picture holds on one side while short captions pass on
the other; the caption at the middle of the screen is lit and its picture is shown.
```html
<section class="steps">
<figure class="steps__media">
<div class="steps__pictures">
<img src="seasoning.jpg" alt="Ash boards stacked to season" class="is-on">
<img src="turning.jpg" alt="A leg being turned on the lathe">
<img src="finish.jpg" alt="Oil rubbed into a finished seat">
</div>
<figcaption class="steps__count" aria-hidden="true"></figcaption>
</figure>
<ol class="steps__list">
<li class="steps__item"><h3>Seasoned for a year</h3><p>…</p></li>
<li class="steps__item"><h3>Turned by hand</h3><p>…</p></li>
<li class="steps__item"><h3>Finished with oil</h3><p>…</p></li>
</ol>
</section>
```
```css
.steps { display: grid; grid-template-columns: 7fr 5fr; column-gap: var(--gutter); }
.steps__media { margin: 0; align-self: start; } /* a stretched grid item cannot stick */
.steps__pictures img { display: block; width: 100%; height: auto; }
.steps__list { list-style: none; margin: 0; padding: 0; }
.steps__item { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; }
.js .steps__media { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 0.75rem; }
.js .steps__pictures { position: relative; aspect-ratio: 4 / 5; max-height: 80svh; }
.js .steps__pictures img { position: absolute; inset: 0; height: 100%; object-fit: cover; opacity: 0; transition: opacity 500ms ease; }
.js .steps__pictures img.is-on { opacity: 1; }
.js .steps__item { opacity: 0.4; transition: opacity var(--t-ui) ease; }
.js .steps__item.is-active { opacity: 1; }
@media (max-width: 800px) {
.steps { display: block; } /* the section, not a grid area, contains the sticky picture */
.js .steps__media { height: auto; z-index: 1; background: var(--bg); padding-block: 0.5rem; }
.js .steps__pictures { aspect-ratio: 3 / 2; max-height: 42svh; }
.steps__item { min-height: 60svh; }
}
@media (prefers-reduced-motion: reduce) { .js .steps__pictures img, .js .steps__item { transition: none; } }
```
```js
(() => {
const root = document.querySelector('.steps');
if (!root || !('IntersectionObserver' in window)) return;
const items = [...root.querySelectorAll('.steps__item')];
const pictures = [...root.querySelectorAll('.steps__pictures img')];
const count = root.querySelector('.steps__count');
const show = (i) => {
items.forEach((el, k) => el.classList.toggle('is-active', k === i));
pictures.forEach((el, k) => el.classList.toggle('is-on', k === i));
if (count) count.textContent = `${i + 1} of ${items.length}`;
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) show(items.indexOf(e.target));
}, { rootMargin: '-45% 0px -45% 0px' }); // a band across the middle of the screen
items.forEach((el) => io.observe(el));
show(0);
})();
```
Without JavaScript the pictures and captions are two plain columns. Study: inactive items at 20-40%
(dims of 10-15% read as missing text, so the floor here is 40%), crossfades of 400-600 ms, about one
screen per step; the readers asked for a visible count on any held sequence of more than two steps.
The count is a plain sentence in the body face, not a zero-padded numeral.
### Sideways gallery
A row of pictures that the vertical scroll moves sideways while the stage is held, for a short
collection (eight items or fewer). The native drag row is the base; the pinned version is added only
on wide screens with motion allowed, and only when the row is under three screens long.
```html
<section class="gallery">
<div class="gallery__stage">
<h2 class="head">Eight kitchens, one stool</h2>
<ul class="gallery__track">
<li class="gallery__item"><a href="/kitchens/leith"><img src="leith.jpg" alt="A narrow galley kitchen in Leith" width="1200" height="1500"><span>Leith</span></a></li>
<!-- … -->
</ul>
</div>
</section>
```
```css
.gallery__track { display: flex; gap: var(--gutter); list-style: none; margin: 0; padding: 0 var(--margin); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--margin); }
.gallery__item { flex: 0 0 min(72vw, 28rem); scroll-snap-align: start; }
.gallery__item img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
.gallery.is-pinned .gallery__stage { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 2rem; overflow: clip; }
.gallery.is-pinned .gallery__track { overflow: visible; scroll-snap-type: none; }
.gallery.is-pinned .gallery__item { flex-basis: calc(62svh * 0.8); }
```
```js
(() => {
const root = document.querySelector('.gallery');
if (!root) return;
const track = root.querySelector('.gallery__track');
const wide = matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
let dx = 0, queued = false;
const place = () => {
queued = false;
if (!root.classList.contains('is-pinned')) return;
const p = Math.min(1, Math.max(0, -root.getBoundingClientRect().top / dx));
track.style.transform = `translate3d(${(-p * dx).toFixed(1)}px, 0, 0)`;
};
const measure = () => {
root.classList.add('is-pinned'); // measure the row as it would be when pinned
track.style.transform = '';
dx = track.scrollWidth - track.clientWidth;
const pin = wide.matches && dx > 0 && dx < innerHeight * 3;
root.classList.toggle('is-pinned', pin);
root.style.height = pin ? `${innerHeight + dx}px` : ''; // one pixel of scroll moves the row one pixel
place();
};
track.addEventListener('focusin', (e) => { // keyboard: bring the focused item into view
if (!root.classList.contains('is-pinned')) return;
const item = e.target.closest('.gallery__item');
const x = Math.min(dx, Math.max(0, item.offsetLeft - parseFloat(getComputedStyle(track).paddingLeft)));
scrollTo({ top: root.getBoundingClientRect().top + scrollY + x, behavior: 'instant' });
});
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(place); } }, { passive: true });
addEventListener('resize', measure);
addEventListener('load', measure);
wide.addEventListener('change', measure);
measure();
})();
```
Study: 8 pages pinned a horizontal track; their pages ran a median 25 screens, and the readers' worst
cases spent 9 screens on three cards and 10,000-23,000 px of wheel on one track. Here the speed is 1:1
and the hold ends when the last picture reaches the margin. Phones, narrow windows, reduced motion and
long rows all get the native row with snapping, which also works with touch, trackpads and the
keyboard. Never put the only copy of a call to action inside the moving row.
### Receding hero
As the first screen leaves, its picture sinks a little and darkens while the words lift away a little
faster, so the hero recedes instead of simply scrolling off.
```html
<header class="hero">
<img class="hero__picture" src="kitchen.jpg" alt="…" width="2400" height="1600" fetchpriority="high">
<div class="hero__copy"><h1 class="display">…</h1><p class="lead">…</p></div>
</header>
```
```css
.hero { position: relative; min-height: 100svh; overflow: clip; display: grid; align-items: end; view-timeline-name: --hero; }
.hero__picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hero::after { content: ''; position: absolute; inset: 0; background: var(--ink); opacity: 0; pointer-events: none; }
.hero__copy { position: relative; z-index: 1; padding: var(--margin); }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.hero__picture { animation: hero-sink linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
.hero::after { animation: hero-dim linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
.hero__copy { animation: hero-lift linear both; animation-timeline: --hero; animation-range: exit 0% exit 70%; }
}
}
@keyframes hero-sink { to { transform: translateY(8%) scale(1.06); } }
@keyframes hero-dim { to { opacity: 0.35; } }
@keyframes hero-lift { to { transform: translateY(-6%); opacity: 0; } }
```
The `exit` range runs from the moment the hero's top meets the top of the screen until its bottom
does, which for a first screen is the first screen of scroll. Study: hero pictures that sink and dim as
they leave, a heading scrubbed from 140 to 60 px, a title card that scales down on the phone; the
readers called it quiet and the page "one sheet". Travel stays under 8%; the overlay is a flat ink at
up to 35%, not a gradient. Static where unsupported or reduced.
### Page to page
Navigation between pages of the site with the browser's own view transitions: the old page fades
quickly, the new one rises in, and the picture the visitor clicked travels into place as the new
page's hero. No library, no full-screen curtain.
```css
/* on every page */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
::view-transition-old(root) { animation: vt-leave 200ms var(--ease-exit) both; }
::view-transition-new(root) { animation: vt-arrive 380ms var(--ease-out) 60ms both; }
::view-transition-group(*) { animation-duration: 480ms; animation-timing-function: var(--ease-in-out); }
::view-transition-image-pair(*.picture) { overflow: clip; }
::view-transition-old(*.picture), ::view-transition-new(*.picture) { height: 100%; object-fit: cover; }
@keyframes vt-leave { to { opacity: 0; } }
@keyframes vt-arrive { from { opacity: 0; transform: translateY(16px); } }
```
```html
<!-- on the index: the card picture of one project -->
<img src="/img/leith.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
<!-- on that project's page: its hero picture -->
<img src="/img/leith-wide.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
```
Each name may appear once per page. Browsers without cross-document view transitions simply navigate.
Study: 16 pages load a page-transition library and run full-screen curtains of 0.7-2.4 s; 6 use view
transitions. The new page should be readable within half a second.
### Ink fill
One statement that fills from part-ink to full ink as it rises through the screen, line by line. It is
readable before it fills; the fill only sets the pace.
```html
<p class="statement head" data-fill>Nine people, one workshop, no catalogue: every stool is made for the kitchen it goes to.</p>
```
```css
[data-fill] { --fill: var(--ink); color: var(--fill); }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.is-filling > span { display: inline-block; color: color-mix(in oklab, var(--fill) 45%, transparent); animation: ink-fill linear both; animation-timeline: view(); animation-range: cover 20% cover 45%; }
}
}
@keyframes ink-fill { to { color: var(--fill); } }
```
```js
(() => {
if (!CSS.supports('animation-timeline: view()') || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-fill]').forEach((el) => {
const words = el.textContent.trim().split(/\s+/);
el.textContent = '';
words.forEach((word, i) => {
const s = document.createElement('span');
s.textContent = word;
el.append(s, i < words.length - 1 ? ' ' : '');
});
el.classList.add('is-filling');
});
})();
```
Each word watches its own position, so the words of a line fill together as the line moves from three
quarters of the way down the screen to just past half way. Set `--fill` on a dark chapter to its ink.
Study: 14 pages scrub a statement from grey to ink; unread words started at about 25-30% and settled
frames still caught statements half pale, which reads as disabled text. Here: one statement per page,
three to five lines, 32px or larger, starting at 45% (about 3:1 against the ground, enough for large
text). Full ink without scroll timelines or under reduced motion.
### Rolling figures
For pages whose case is a handful of numbers: each digit rolls up to its value once, the columns a
beat apart. The real number stays in the text for screen readers and search.
```html
<p class="figure"><span class="display" data-roll>1987</span> the year the first stool was sold</p>
```
```css
.roll__digits { display: inline-flex; font-variant-numeric: tabular-nums; line-height: 1; }
.roll__col { display: inline-block; height: 1em; overflow: clip; }
.roll__strip { display: flex; flex-direction: column; transform: translateY(calc(var(--d) * -1em)); }
.roll__strip > span { height: 1em; }
@media (prefers-reduced-motion: no-preference) {
.roll:not(.is-in) .roll__strip { transform: none; }
.roll.is-in .roll__strip { transition: transform 1100ms var(--ease-expo) calc(var(--i) * var(--stagger-item)); }
}
```
```js
(() => {
const els = [...document.querySelectorAll('[data-roll]')];
if (!els.length) return;
els.forEach((el) => {
const value = el.textContent.trim();
const said = Object.assign(document.createElement('span'), { className: 'sr-only', textContent: value });
const shown = Object.assign(document.createElement('span'), { className: 'roll__digits' });
shown.setAttribute('aria-hidden', 'true');
let i = 0;
for (const ch of value) {
if (!/\d/.test(ch)) { shown.append(ch); continue; }
const col = Object.assign(document.createElement('span'), { className: 'roll__col' });
const strip = Object.assign(document.createElement('span'), { className: 'roll__strip' });
strip.style.setProperty('--d', ch);
strip.style.setProperty('--i', i++);
for (let n = 0; n < 10; n++) strip.append(Object.assign(document.createElement('span'), { textContent: n }));
col.append(strip);
shown.append(col);
}
el.classList.add('roll');
el.replaceChildren(said, shown);
});
if (!('IntersectionObserver' in window)) { els.forEach((el) => el.classList.add('is-in')); return; }
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}, { rootMargin: '0px 0px -15% 0px' });
els.forEach((el) => io.observe(el));
})();
```
Study: 6 pages roll digit columns (stats, a year, coordinates), about 0.9-1.1 s per column on an
expo-out, staggered by column. Use it for three or four figures at display size, never in running text
and never for a value that changes after load. Reduced motion: the figure is set at its value.
### Accordion
```css
.acc__panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 320ms var(--ease-out); }
.acc__panel > div { overflow: hidden; }
.acc[open] .acc__panel, .acc__item.is-open .acc__panel { grid-template-rows: 1fr; }
```
Animating `grid-template-rows` from `0fr` to `1fr` opens to the content's real height without measuring
it in JavaScript.
### Menu or dialog
```css
.menu { transform-origin: top right; transition: opacity var(--t-ui) var(--ease-out), transform var(--t-ui) var(--ease-out); }
.menu[hidden], .menu.is-closed { opacity: 0; transform: scale(0.97) translateY(-6px); pointer-events: none; }
```
Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
`scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.
## 7. Signature moves
The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.
| Move | What happens | Build with |
| --- | --- | --- |
| Masked headline | Lines rise from behind their baselines on load | Masked line reveal recipe |
| Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
| Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
| Pinned story | A heading holds while its items scroll past | Sticky recipe |
| Index with preview | Text rows; the hovered row shows its image | Index with preview, anchored to the row |
| Curtain footer | The last section is revealed from under the page as you reach the end | `position: sticky; bottom: 0` footer under a main with a background and `z-index` |
| Slow crossfade | Hero image crossfades through 3–4 photographs, 5–6s each | Stacked images, opacity keyframes, pause when the tab is hidden |
| Edge-to-edge marquee | One line of very large type travels sideways, slowly | Duplicate the content, `translateX(-50%)` linear loop, pause on hover and under reduced motion |
| Drag row | A horizontally scrollable row of large images with scroll snapping | `overflow-x: auto; scroll-snap-type: x mandatory`, no library |
| Colour by chapter | Each chapter is its own full-bleed colour field, so the ground changes at section edges; a fixed header takes the ink of the chapter beneath | Section backgrounds in CSS; `base.js` sets `--header-ink` from `data-ink`. Never recolour the whole page from an observer |
| Opening window | A type screen hands over to the big picture: a window in the middle of a held stage opens to full-bleed over about one screen | Opening window recipe |
| Ink-fill statement | One statement fills from 45% to full ink as it rises through the screen | Ink fill recipe |
| Sheet over a held picture | A full-bleed photograph holds for one screen; the next chapter slides up over it as an opaque sheet | A 100svh `position: sticky; top: 0` figure followed by a section with its own background and a higher `z-index`; no script |
| Sideways gallery | A short row of pictures moves sideways while the vertical scroll holds the stage | Sideways gallery recipe |
| Pushed line | One line of very large type is pushed sideways by the scroll, reading whole at the middle of its run | `animation-timeline: view()` on the line, `translateX` from 12% to -12%, linear; static where unsupported |
| Rolling figures | The few figures that make the case roll up to their values once | Rolling figures recipe |
| Carried picture | The picture clicked in a list travels into place as the next page's hero | Page to page recipe |
Pick by subject. A portfolio wants the index with preview; a restaurant wants the slow crossfade; a
product wants the shutter on its hero object. If the move says nothing about the subject, it is
decoration and the page is better without it.
Pick by subject, as before: the opening window for a product or a place with one great photograph; the
sheet for chapters that each own a picture; the sideways gallery for a short set of rooms, kitchens or
pieces; the pushed line for a brand whose claim is a word; rolling figures for a report, a fund or a
practice that leads with numbers; the carried picture for a portfolio whose index and case pages are
both built.
## 8. Reduced motion and performance
- `prefers-reduced-motion: reduce` means fewer and gentler, not none: keep opacity and colour changes,
drop travel, scale, parallax and autoplay. All recipes above are written inside
`no-preference` blocks so the reduced state is simply the finished state.
- Animate `transform`, `opacity` and `clip-path`. Animating `width`, `height`, `top`, `left`, `margin`
or `padding` forces layout on every frame. `grid-template-rows` for accordions is the accepted
exception because it is short and rare.
- Do not drive many children from one changing CSS variable on a parent; set the transform on the
element that moves.
- `will-change` only on an element that is about to animate, removed afterwards.
- Pause loops when off screen or when the tab is hidden.
- No scroll hijacking and no custom cursor. They cost usability, which is 30% of an Awwwards score and
the line most winners score lowest on. Smoothing only through `Stage.smooth()`, which stays off for
touch and reduced motion.
## 9. What gives generated motion away
- The same fade-up on every block.
- A dozen staggered entrances in the hero.
- Hover lift or zoom on every card and image.
- Bounce or elastic easing on interface elements.
- Infinite loops that mean nothing: pulsing dots, floating shapes, shimmering text, rotating badges.
- A reveal that never fires and leaves a blank band in the page. Run `inspect.mjs` after the motion
pass and look at every screenshot for missing content.
### Seen in the latest Sites of the Day
Seen in the study, with how often, and what the house does instead.
- **Preloaders and counters (60 of 99; 26 with a counter).** Half the usable pages were unreadable
2.5 s after load, a third at 4.5 s; the preloaders sat mostly on pages that had already loaded, and
25 of the 29 that loaded in under 3 s still showed nothing readable at 4.5 s. Counters stalled,
outlasted the load or were tweens with nothing behind them. House: no preloader. If a scene must
load, the HTML first screen (headline, picture, action) is there at once and the scene fades in
behind it when ready.
- **Gates (10 of 99): enter, sound, age or gesture before any content.** House: none. Sound is a
control on the page, off by default.
- **Scroll hijacking (16 wheel-driven scenes, 9 of them endless, plus 14 single screens where the wheel
does nothing).** The visitor cannot tell where they are, how much is left or how to get out, and the
keyboard and screen readers get nothing. Usability 6.01 against 6.50 for pages that scroll. House:
native scroll; any scene is driven by scroll position on a sticky stage that ends, with a count.
- **Smooth-scroll libraries (67 of 99).** They make the scrubbed scenes feel weighted, and they also
put a delay between the hand and the page; with a long ease the page keeps sliding after the wheel
stops.
- **Custom cursors (28 of 99).** Followers, image trails, a lens that smears the headline it sits on,
labels that only exist on hover; 10 of the 28 have any reduced-motion rule. House: the system cursor;
labels are on the page.
- **Scramble and typewriter text (11 and 5).** The headline unreadable for four seconds, body copy
garbled mid-scroll, actions held back until the typing ends. House: not used. Real words arrive whole.
- **Long held runways.** Pages with pins average twice the length for fewer words per screen; 17 pages
are 20 screens or longer, some with whole screens that hold only a slow rotation. House: about one
screen per step, three per stage, every held screen carries a line of text.
- **Content held at zero opacity.** Reveals that keep text invisible until the scroll settles, inactive
items dimmed to 10-15%, statements left half pale. House: reveals only below the fold, once, never
longer than 900 ms in view; dims no lower than 40%; fills start at 45%.
- **Motion over words.** Marquees and drifting objects that cross statements, a sliding line that crops
its own words, blur left on text mid-move. House: nothing moves across text the visitor is reading;
a moving line must read whole at some point of its run.
- **Overshoot and bounce (19 stylesheets).** House: still banned on interface elements.
- **Endless loops with no off switch.** Grain, pulsing dots, equalisers, spinning badges, running with no
reduced-motion rule on most pages. House: loops pause off screen and stop under reduced motion.
Where the study's work does something the house bans, the house version: hairline progress bars and
tick rails on held stages become a plain count in the body face ("2 of 4"); zero-padded counters
("01/08") become a plain count or nothing; crossfading gradient grounds become flat chapter colours with
hard cuts; monospace HUD labels become small text in the text face.
## 10. Rules for motion
1. **First frame.** The headline, the sentence and the picture are in the HTML and painted on the first
frame. The load sequence starts without waiting for the load event and is settled by 1.2 s, with
four elements at most.
2. **No preloader, no counter, no gate, no custom cursor, no wheel capture.** Smoothing only through
`Stage.smooth()` (Lenis at lerp 0.1-0.14, off for touch and reduced motion): weight, never a page
that keeps sliding after the hand stops.
3. **Curves.** `--ease-out` for anything under 600ms; `--ease-expo` for reveals of 800ms or more;
`--ease-in-out` for wipes and things that travel; `--ease-exit` only for things leaving for good;
`linear` for scroll-linked motion and loops; plain `ease` for colour. No overshoot.
4. **Durations.** Press 140ms; hover colour 180-200ms; hover that moves 300-400ms; menus 240ms; reveals
700-900ms; picture openings 1,100-1,300ms; exits 60-70% of the matching entrance.
5. **Staggers.** Lines 80-110ms; list items 50-90ms; characters 20-40ms and only in a heading of one to
three words; four steps at most in any one sequence.
6. **Reveals.** Fire once, when the element's top is 85-90% of the way down the viewport; rise 12-32px
or from a line mask; nothing in view stays hidden for more than 900ms; nothing above the fold waits
for scroll.
7. **Scroll scenes are where the page is bold.** The big moves come from the scene library
(`references/scenes.md`): the direction sheet deals two or three per page, each built on
`assets/scenes/stage.js`, each held for one to four screens, each with a phone version and a
finished composition. Inside a scene the scene's own numbers apply (a picture may grow from a
window to the whole screen, type may cross the stage, a camera may fly through a field of
pictures); rules 8 and 10 still hold for the text a visitor is reading.
8. **Outside the scenes, scroll-linked travel stays quiet.** Linear and locked to the scrollbar;
parallax under 8% of the element; scale between 0.94 and 1.15; built with scroll timelines inside
`@supports`, static without them. Other held stages: sticky, about one screen per step, three
screens at most, a line of text on every held screen.
9. **One of each.** One signature move (a dealt scene can be it); one moving picture; one marquee at
most. A quiet section between two scenes, so each one lands.
10. **Text stays readable.** Fills start at 45% ink; dims no lower than 40%; blur at most 8px and gone
once settled; nothing moves across text being read.
11. **Colour by chapter** as hard cuts at section edges; the fixed header's ink follows in 300ms.
12. **Horizontal motion.** The native drag row first; the pinned sideways gallery only for rows under
three screens long, with keyboard support.
13. **Page to page.** View transitions, 480ms at most, off under reduced motion.
14. **Reduced motion.** Every hidden state lives inside `prefers-reduced-motion: no-preference`; the
reduced page is the finished page; loops stop. Two in three award pages skip this, which is a
reason to do it, not a licence to skip it.
@@ -0,0 +1,356 @@
# The study
Everything numeric in this skill comes from here. Cite these figures; do not round them up or invent
others.
## Contents
1. Method and its limits
2. Scores
3. Where the idea comes from
4. Headlines
5. Type
6. Devices: what separates strong from weak, and what does not
7. Colour and shape
8. Imagery and page structure
9. What the inspector says about the gallery itself
10. The 25 highest-scored sites and their ideas
11. How the skill was tested
12. The October 2026 study: 100 Sites of the Day
13. The app-screen study
## 1. Method and its limits
- **Sample.** 248 sites collected on 5 October 2026: 156 from One Page Love (the latest gallery pages
and the category listings) and 92 Awwwards Sites of the Day (the three most recent listing pages).
247 loaded; 246 were re-measured with the final probe.
- **Capture.** Headless Chrome at 1440×900 and 390×844. For each site: the first viewport, five more
frames while scrolling with real wheel input, the mobile first viewport, and a measurement pass in
the page (computed styles, text geometry, colours, fonts actually loaded).
- **Panel.** 21 model reviewers each studied about 12 sites from the screenshots and the measurements,
described them (idea, hero, type, colour, imagery, layout, sections) and scored them the Awwwards
way: design 40%, usability 30%, creativity 20%, content 10%. These are model judgements from still
frames, not human jurors and not the real Awwwards scores. Batches mixed both sources so each
reviewer saw a spread.
- **Usable sets.** 235 sites were usable for the panel (12 showed only a preloader, an entry gate or a
bot wall). 202 were measurable for typography (a page with at least 25 words and a detectable
headline). "Top" below means panel score 7.5 or higher (82 measurable sites); "weak" means under 6.5
(23 measurable sites).
- **Limits.** One capture per site on one day. Some heroes were caught mid-animation or behind a cookie
notice. WebGL-heavy sites are under-described by text measurements. The eyebrow detector counts any
small short label directly above a large heading, so it over-counts against the panel's stricter
reading (60% measured against 35% observed). Motion was inferred from frames, not watched. The sample
is whatever the two galleries had recently featured; it is fashion as much as quality.
## 2. Scores
| | All 235 | One Page Love (151) | Awwwards (84) |
| --- | --- | --- | --- |
| Median overall | 7.2 | 7.1 | 7.5 |
| Middle half | 6.8–7.6 | | |
| 8.0 or higher | 25 sites (11%) | | |
| 7.5 or higher | 90 sites (38%) | | |
| Under 6.5 | 29 sites | | |
Median by criterion: design 8, usability 7, creativity 7, content 7. Among the top 25: design 9,
usability 7, creativity 8, content 8. Usability is the one line that does not rise with the rest: the
best sites are no easier to use than the average one. Do not copy their gates, preloaders, tiny text
or hidden navigation.
## 3. Where the idea comes from
The clearest pattern in the panel's notes: high scores go with a concept taken from the subject, and
low scores go with its absence. The weakest entries' ideas were recorded in words like "none beyond the
standard dark SaaS launch formula", "no visual idea beyond clarity", "no strong idea". The strongest
were sentences only that subject could own (section 10).
Recurring engines among the 7.3+ sites, each the basis of a direction in the library:
- **The product demonstrates itself.** A colour picker's loupe reads the page's own background; a list
tool is sold with tiny working lists; a photo app's page is a wall of its own output.
- **The page is an artefact from the subject's world.** A wedding site that is the invitation; an
archive as tabbed folders; a coupon with a cut line; a portfolio as a contact sheet.
- **The name is the hero.** Wordmarks as wide as the screen, a photograph set inside the name like a
letter, a name split to opposite corners with the page between.
- **One motif from the subject drives everything.** A tool's octagonal section as button, mask and
loader; two circles for two accounts; a café's stripes.
- **Dressed as another category.** A money app as a streetwear drop; an industrial roaster as a design
object; a parcel service as a fairground.
- **The place carries it.** One full-bleed view and a name along the bottom edge.
- **A fixed stage.** A title or object stays put while content swaps beneath it.
- **One section, one colour.** Flat fields as chapters.
- **Radical understatement.** A 27px headline and tiny live demonstrations, carried through completely.
Hero types, by the panel's classification (top sites against weak sites): full-bleed media with type
over it 16% / 10%; giant wordmark 14% / 0%; typographic poster 13% / 7%; text beside media 10% / 3%;
**centred statement with media below 7% / 24%**. The centred headline over a screenshot is the weak
sites' signature layout.
## 4. Headlines
| Hero headline | All | Top | Weak |
| --- | --- | --- | --- |
| Words, median | 5 | 5 | 5 |
| Words, middle half | 3–9 | 2–8 | 3–8 |
| Characters, median | 32 | | |
| One line at 1440 | 47% | | |
| Two lines or fewer at 1440 | 70% | 71% | 83% |
| Two lines or fewer at 390 | 57% | | |
| Three lines or fewer at 390 | 82% | | |
Awwwards heroes are shorter (median 4 words, 76% within two lines) than One Page Love's (6 words, 66%).
Section headings have a median of 3 words. Length does not separate strong from weak; nearly everyone
in the gallery writes short headlines. The two-line limit in this skill is a house rule that most of
the gallery already meets on desktop and that takes care to meet on a phone.
## 5. Type
| | All | Top | Weak |
| --- | --- | --- | --- |
| Hero size, median | 72px (5.0vw) | 6.0vw | 4.7vw |
| Hero size, middle half | 3.0–7.6vw | 2.5–9.7vw | 2.7–6.1vw |
| Largest text on the page, median | 6.4vw | 8.4vw | 5.0vw |
| Largest text, upper quarter starts at | 10.4vw | 12.5vw | 6.7vw |
| Hero on a phone, median | 40px | | |
| Hero tracking, median (middle half) | -0.01em (-0.03 to 0) | | |
| Hero leading, median (middle half) | 1.0 (0.95–1.1) | | |
| Body size, median (middle half) | 16px (14–18) | | |
| Headline ÷ body, median | 4.3 | 4.7 | 4.0 |
| Typefaces carrying 3%+ of text | one 31%, two 45%, three 22% | | |
| Distinct font sizes, median | 8 | | |
Hero weight: regular 48%, medium 18%, semibold 6%, bold or heavier 22%, light 6%. Capitals 24%.
Centred 39% overall, 52% on weak sites. Display classes by the panel: neo-grotesk 48%, transitional
serif 11%, geometric sans 10%, condensed 6%, extended 6%, old-style serif 4%, custom lettering 4%.
Strong sites push the extremes: their biggest type is bigger and their range is wider. Weak sites
cluster in the middle.
## 6. Devices: what separates strong from weak, and what does not
Measured in the page unless marked (panel).
**More common on weak sites (the data supports avoiding them):**
| Device | Top | Weak |
| --- | --- | --- |
| Icon-topped feature cards (panel) | 1% | 28% |
| Rows of identical cards with a small icon (measured) | 7% | 26% |
| Pill badges (panel) | 14% | 34% |
| Pill-shaped label above a heading (measured) | 5% | 26% |
| Tiny uppercase letter-spaced labels, any | 16% | 57% |
| Tiny uppercase letter-spaced labels, three or more | 6% | 43% |
| Gradient fills (not counting scrims) | 22% | 52% |
| Gradient text | 2% | 13% |
| Shadows on resting elements, any | 24% | 48% |
| Shadows, five or more | 10% | 30% |
| Emoji in the page | 10% | 26% |
| Eyebrow labels (panel) | 32% | 45% |
**No real difference:**
| Device | Top | Weak |
| --- | --- | --- |
| Backdrop blur anywhere | 32% | 39% |
| Italic-serif accent word (panel) | 4% | 7% |
| Thin rules, any | 61% | 52% |
**More common on strong sites (banned here anyway, as house style):**
| Device | Top | Weak |
| --- | --- | --- |
| Monospace labels (panel) | 44% | 24% |
| Monospace anywhere outside code (measured) | 45% | 26% |
| Index numbering (panel) | 38% | 21% |
Monospace captions, index numbers and ruled grids are the present fashion of award sites. They are
excluded from this skill by the owner's choice, not by the data: they are the devices generators copy
first, and a page that cannot lean on them has to find structure in space, tone and scale.
## 7. Colour and shape
- Dark page ground 41%; pure white 25%; pure black 12%; a warm off-white 13%.
- Scheme (panel): monochrome plus one accent 23%, light neutral 22%, dark neutral 14%, several flat
colours 13%, two-tone 12%, photographic 9%, one colour field 6%.
- Primary buttons are neutral (black, white, grey) on 52% of sites. One site in 246 has a terracotta
button; none has a violet one.
- Median number of distinct section grounds: 2 on top sites, 1 on weak sites.
- Button shape: top sites square (0–2px) 52%, fully round 23%, small radius 19%, medium (9–16px) 6%.
Weak sites fully round 59%, medium 24%, square 18%.
- Media corners are square on 73% of sites; where rounded, the median radius is 12px.
- Buttons in the first viewport: none on 55% of top sites and 39% of weak ones.
## 8. Imagery and page structure
- Hero media covers at least half the first viewport on 60% of sites (top 68%, weak 48%) and nearly
all of it on 50% (top 56%, weak 39%). No hero media: 15%.
- Video is present on 49% of sites and a canvas element on 49% (top 62%, weak 35%).
- Images per page, median: 24 overall, 37 on top sites, 5 on weak ones.
- Imagery kind (panel): mixed 32%, photography 14%, generative or WebGL 13%, product interface 12%,
3D render 11%, illustration 9%, video 6%, type only 3%. Median panel score is lowest for
illustration-led (6.9) and product-interface-led (7.1) pages and highest for video and type-only
(7.5).
- Page length, median: 8.7 screens and 575 words (middle half 1.6–14.4 screens, 250–956 words). A
quarter of the sites are a single screen or two.
- Navigation: a median of 3 links; fixed or sticky on 66%.
- Density (panel): airy 46%, balanced 45%, dense 9%. Alignment: centred 33%, mixed 31%, left-anchored
17%, modular grid 13%, free 6%.
- Asked to describe each site's most interesting sections, the panel picked a pricing, testimonial or
FAQ section only 7, 12 and 9 times across 235 sites. Where strong sites have them at all, they are
not where the design effort goes, and they avoid the stock form.
**From the panel's written notes on the 114 sites scoring 7.3 or more** (hand tallies, approximate;
`layout.md` turns these into practice):
- Outer margins of 32px or less on about 45; 80px or more on about 8. A centred 700–1300px container
on about 15, several of them the pages the panel called stock.
- Text anchored to a fixed off-centre line on about 22. Text docked in corners or at opposite ends of
a baseline on about 25 and 15.
- Two or three type sizes with nothing between on about 30; no note describes a graded five-step
scale.
- One thing per screen on about 20. A full background colour per section on about 20; alternating
light and dark on about 12.
- Every section recomposed on about 14; one planned exception to the page's own system on about 14.
- No conventional top navigation bar on about 45; small corner links on about 22; a single link or
button on about 10.
- Endings: the name at full width about 18, the hero repeated inverted about 12, a closing scene of
its own about 14, no footer or a single line about 22. Link-column footers are mentioned only as
criticism.
- Motion (inferred from frames): autoplaying video about 50; pinned or fixed stages about 40; text
reveals about 30 (blur-in 10, masked line rises 8, scroll-tied word colouring 7); horizontal strips
about 27; marquees about 26; media swapped behind a fixed element about 15; scroll-linked scaling
about 12. Rare: hover effects (4 mentions), parallax (6), accordions (5), bounce or spring motion
(none). About 10 strong sites show little or no motion at all.
- How weak sites fail (54 sites under 6.8): the category's stock template about 20; centred headline,
pill badge, two buttons and a screenshot about 9; stock modules after a distinctive hero about 8,
plus about 17 in the middle band; a characterless default typeface about 13; text too small or too
faint about 13; never saying what the thing is about 12.
- Faults of the best sites (90 at 7.5 or more), not to be copied: text at 9–13px about 24;
information hidden behind the concept about 20; the idea running out mid-page about 15; text over
footage with no scrim about 12; gates, preloaders and blank first paints about 12.
## 9. What the inspector says about the gallery itself
Running this skill's own `inspect.mjs` rules over the 202 measurable gallery sites: the median site
has 4 `FAIL`s and only 4% have none. The rules that fire most: headline over two lines somewhere on
the page 60%, eyebrow-like label 60%, thin rules 50%, monospace 37%, shadows 32%, gradients 29%, index
numbering 29%.
So the house style is deliberately stricter than the gallery. It is not a description of award sites;
it is a narrower target inside what they do: their scale, their ideas, their imagery and restraint,
without the ornaments that have since become a uniform.
## 10. The 25 highest-scored sites and their ideas
The panel's one-sentence reading of each. Notice that every one names something only that subject
could own. (Several use devices this skill bans; take the idea, not the ornament.)
| Site | Source | Score | Idea |
| --- | --- | --- | --- |
| PP Neue Montreal | Awwwards | 8.6 | A typeface specimen dressed as a vintage city travel guide: the font named after Montreal is sold through guidebook titling, postcards and old tourist footage. |
| Seasats | Awwwards | 8.4 | Calm, dusk-lit seascapes sell autonomous boats as serene instruments, and the navigation is a column of stacked colour swatches taken from that sea-and-sand palette. |
| AI in Design Report 2026 | Awwwards | 8.4 | A research report laid out like a colour-blocked print annual: a video collage on top, an oversized tight grotesk title, and flat orange, lilac, sage and black blocks carrying stats, quotes and chapters. |
| House of Honey | Awwwards | 8.4 | A block-heavy wordmark pierced by a fine script: the bold-against-delicate contrast is repeated at every scale, with giant pink script behind quiet serif statements. |
| White Desert | Awwwards | 8.4 | One word, the destination, set a third of the screen tall over footage like a film title card; sheer scale stands in for remoteness. |
| Applied Imagination | One Page Love | 8.3 | An essay conceived on a flight is read as a descent: an altimeter ticks from 30,000 ft to zero while you scroll down through clouds to mountains at ground level. |
| Cursor Compile | One Page Love | 8.3 | The event name drawn as seven looping one-line curves in seven colours, more signature than logotype, on an otherwise whisper-quiet off-white page. |
| The New Industrials | One Page Love | 8.3 | A long-read essay laid out inside a drafting grid, where colour fields shift as you read and machine parts drift through the margins and finally assemble into the publisher's initials. |
| Wild | One Page Love | 8.3 | A studio essay in which everything decorative is drawn on one square-pixel grid: the hero heatmap, the icons, the diagrams, the lettering and the footer. |
| Hildén & Kaira | Awwwards | 8.3 | The agency name is cast in liquid chrome across the full width, and the same chrome becomes the objects that float through the page, tying the pitch to one material. |
| PX Push | Awwwards | 8.2 | A design subscription presented as a retro broadcast: the entire site is seen through a CRT screen, with a chrome logo token as its mascot. |
| Illoca | Awwwards | 8.2 | Software for architects sold as a drafting table: a two-colour illustrated studio on graph paper that the scroll pushes into until the drawing fills the screen. |
| Aspen Search | Awwwards | 8.2 | The page is a tile system with no margins: the two-word name is split across cells that alternate type, dithered image, black and one mint green. |
| Dragonfly Redux | Awwwards | 8.2 | A crypto fund presented as a terminal artefact: every picture is rendered in characters, framed by four letters of the name pinned to the viewport corners. |
| Monolog | Awwwards | 8.2 | A studio pitch run as an asymmetric sheet: a narrow left column carries the sales talk while a wide right column stacks a very heavy headline and the proof. |
| The Queue | One Page Love | 8.1 | An essay on queueing where the queue is the ornament: a line of drawn figures stands along the bottom of the hero and returns between chapters in changing period dress. |
| Paul Kalkbrenner | Awwwards | 8.1 | Swiss poster typography for a techno artist: the name as one black line with a photo set inside it like a glyph, then every chapter gets its own flat colour. |
| Vero New York | Awwwards | 8.1 | A keepsake treated like couture: a slow close-up film opens, then a white sculpture of a dress stays pinned at centre while one sentence passes over it. |
| Depo Luxe | Awwwards | 8.1 | A film production roster shown as a full-screen reel in which every step is one film with only a title, a director and a client, set like credits at body size. |
| Longbow | Awwwards | 8.1 | The page is built on a visible four-column construction grid, and cars, cards and a giant condensed marquee are slotted into its cells like objects on an artboard. |
| Mosby's Files | Awwwards | 8.0 | An archive of architects presented as a physical stack of coloured index-tab file folders that you flip through. |
| Sarah & Matt Wedding | One Page Love | 8.0 | The site is the invitation: a letterpress-style card opens the page, and its ornaments and small typographic parts become the components for everything after it. |
| Zara Webb | One Page Love | 8.0 | A one-woman newborn-care practice presented like a hand-made keepsake: a mascot rabbit, scalloped and flower-shaped cut-outs, candid film photographs. |
| Noho | Awwwards | 8.0 | Furniture sold through people misusing it (wearing, balancing on, hiding behind chairs on flat colour backdrops) while the interface stays quiet. |
| Juggle | One Page Love | 8.0 | The headline literally drowns: a flat blue field rises to half height and the lines below the waterline change colour. |
Full notes on any studied site: `node scripts/draw.mjs --site <id>` (ids appear on direction sheets).
## 11. How the skill was tested
Six client briefs (an invoicing tool, a restaurant, a designer's portfolio, a ceramic product, a
guesthouse, a design conference), each with no photographs. For every brief a fresh agent built the
page three ways: with no skill (baseline), with another published design skill (atelier, three briefs)
and with this skill. Every build was photographed by `inspect.mjs` in the same way and judged blind
by a separate agent with the juror brief (Awwwards weights, anonymous A/B/C folders, screenshots
only). The skill was revised after each round from the jurors' reasons and the builders' notes.
| Round | Briefs | This skill | Baseline | Atelier | Wins |
| --- | --- | --- | --- | --- | --- |
| 1 | 4 | 6.6 | 7.7 | 6.8 | 1 of 4 |
| 2 | 3 | 7.1 | 8.0 | | 0 of 3 |
| 3 | 3 | 7.5 | 7.5 | | 1 of 3 |
| 4 (unseen briefs) | 3 | 6.9 | 7.9 | 7.5 | 0 of 3 |
| 5 (same three) | 3 | 7.7 | 7.8 | 7.2 | 2 of 3 |
Under the inspector, the final builds measured 0 FAIL each; the baselines measured 8 to 13 FAIL each
(eyebrows, monospace labels, 01/02/03 numbering, hairlines, gradients, shadows, three-line headlines,
text under 13px).
What the jurors rewarded, in the order they named it: an idea that belongs to this client and not to
the category (the competitor test); one live thing on the page; full content at a readable size; a
first screen with one dominant element and a real button; drawings of the subject with detail. What
they punished: a quiet page that read as empty, a small headline, text sliding over pictures or cut
at the top, a page recoloured mid-section, rows cut at the right edge, a large sentence over three or
more lines (read as a long headline whatever its markup), navigation that took over the first screen.
Each of these is now a rule in SKILL.md or a check in the inspector.
Limits: one juror per brief per round, so single scores carry about half a point of noise; the round-5
briefs had been seen in round 4; jurors are models, not people.
## 12. The October 2026 study: 100 Sites of the Day
A second study, made for v2.1, of the latest 100 Awwwards Sites of the Day (10 July to 17 October
2026), with a different aim from the first: where the pictures and footage sit, how pages move, and
the frame (margins, balance, empty space, titles). About three in four of these sites were also in
the first study.
- **Capture.** Headless Chrome at 1440×900 and 390×844 on 18 October 2026: four frames of the first
4.5 seconds after load, every screen while scrolling with real wheel input (one frame at rest per
step; the mid-step frames were cut from the top of the document instead of the viewport, so they
were not used), five phone screens, and a measurement pass in the page: every picture and
clip with its box, ratio, fit and corners; every section's content edges and pictures; blank runs;
the CSS that loaded (curves, durations, keyframes, breakpoints, feature counts); the animations,
transitions and script-driven moves that actually ran; GSAP tweens and scroll triggers where the
page exposed them. Nothing a site owns (code, copy, pictures) is reproduced in the skill; only
measurements and descriptions in our own words.
- **Panel.** One model reader per site studied its frames and measurements, described the idea, the
first screen, the frame, every section, where each picture sits, the motion and what not to copy,
and scored it the Awwwards way. 91 of 99 captures were usable (the rest showed a gate, a bot wall or
a firewall page). Scores ran 6.7 to 7.9 (median 7.45); "top" means 7.5 or more (36 sites), "lowest"
under 7.3 (16).
- **What it changed.** Where pictures and footage go: `imagery.md` §12. What strong pages do with
motion, new tokens, recipes and the gimmicks to avoid: `motion.md` §2, §4–6 and §9–10. Empty space
and the frame: `layout.md` §4–5. New compositions in `library/heroes.json` and `sections.json` and
notes on media and motion in `library/sites.json` (`draw.mjs --site <id>`).
- **Headline numbers.** Pictures fill a median 16% of a scrolled screen, but in whole screens: 42% of
screens hold none and 19% are 90% picture or more. The median page carries 7 pictures (top 8,
lowest 2) and 42% carry footage (half of the top sites, one of the 16 lowest). 94% of pictures have
square corners. Strong ease-outs are the curves that ran (expo and quint out); the median of all
that ran on a site is 420ms. 60 of 99 open on a loading screen, and a third of the usable pages were
still unreadable 4.5 seconds after load, with no gain in the design marks. About 1 text section in
100 leaves half the width empty; the outer margins of strong pages match to a few pixels; 82% of
text sections open with a heading; 15 of 97 first screens never say what the thing is, and those
lose usability points.
- **Limits.** One capture per site on one day; a WebGL scene can render blank in a headless browser;
the scroll-linked frames are partly an artefact of smooth-scroll libraries; one reader per site, so
single scores carry about half a point of noise.
## 13. The app-screen study
For dashboards and app screens (v2.1), a separate study of 23 real product screens chosen by the
owner (17 products, 17 light and 6 dark, at 1440–1512px): each screen measured from its pixels by a
reader with full-resolution crops, six cross-cutting reports (shell, data and charts, tables and
forms, visual language, taste, styles), and a synthesis into `references/app-screens.md` and
`library/app.json` (seven styles, five shells, screen patterns). The screenshots stay private; the
skill carries only the measurements and the rules. Blind test rounds of app screens built with and
without the skill then shaped the warnings at the top of that file.
@@ -0,0 +1,174 @@
# Scroll scenes
The big moments of a page: stretches of scroll where the page stops being a column of sections and
becomes a scene. A camera flies through a field of pictures, a picture grows from a window to the whole
screen, a word buckles under the scroll, a set of photographs orbits and lands in a grid. These are what
visitors remember from award sites, and what a page built from tidy sections alone never has.
The scene library is taken apart from recent Awwwards Sites of the Day (October 2026: 43 sites recorded
frame by frame while scrolling, 261 scenes described, 30 types), rebuilt from scratch in our own code,
tested frame by frame at desktop and phone width and under reduced motion, and reviewed against the
scenes it came from. Nothing a site owns is in them. Until `library/scenes.json` ships the draw deals
none; a page that needs a scene builds one on the stage (section 8), with every rule here.
## Contents
1. What a scene is
2. Putting a scene on a page
3. Filling it
4. Fitting it to the page
5. The phone, reduced motion, no script
6. Checking it
7. Rules for scenes
8. When no scene in the library fits
9. The catalogue
## 1. What a scene is
A tall section whose stage holds still while the page scrolls past it. The stage is
`position: sticky`; the section is (hold + 1) screens tall, so the stage is held for `hold` screens;
a GSAP timeline is scrubbed from 0 to 1 by that scroll. Nothing captures the wheel: the scrollbar always
tells the truth, the keyboard and the trackpad work as everywhere else, and the scene ends.
What a scene is not: a fade-up, a small parallax, a sticky footer, a marquee. Those are the quiet layer
(`references/motion.md`). A scene changes the whole screen.
The direction sheet deals two or three (section 9 of the sheet), from different families, each placed
on the section of the argument it can carry. Build them in step 6, with that section: a scene is
composition, not decoration added at the end.
## 2. Putting a scene on a page
Copy the shared stage and the dealt scene's files into the project (`assets/scenes/stage.css`,
`stage.js`, `<id>.css`, `<id>.js`). Each scene file's header comment lists the markup it expects and
its options; `assets/scenes/demo/<id>.html` is a complete working page to read beside it.
```html
<head>
<script>document.documentElement.classList.add('js')</script>
<link rel="stylesheet" href="scenes/stage.css">
<link rel="stylesheet" href="scenes/<id>.css">
</head>
<body>
…
<section class="scene" data-scene="<id>" data-options='{"hold": 3}' aria-label="…">
<div class="scene-stage">
<!-- the markup from the scene file's header, filled with this page's content -->
</div>
</section>
…
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script src="scenes/stage.js"></script>
<script src="scenes/<id>.js"></script>
<script>Stage.smooth(); Stage.init();</script>
</body>
```
- Load SplitText (`gsap@3.15.0/dist/SplitText.min.js`) only when a scene file says it needs it.
- `Stage.smooth()` adds weighted wheel scrolling (Lenis at lerp 0.12). It stays off for touch and for
reduced motion. Leave it out on a quiet page; the scenes work either way.
- `Stage.init()` starts every `[data-scene]` on the page. A page with two scenes loads both files.
- The versions are pinned. Do not change them to `@latest` or an unpinned major.
## 3. Filling it
A scene is only as strong as what goes in it. The scene file's header and the sheet's "Content" line
say how many pictures and words it takes; these hold for all of them:
- **Pictures of one family.** Same light, same grade, same kind of subject: a field of mismatched stock
photographs breaks the illusion of one space faster than any timing error. Real pictures of the
subject's world (`assets.mjs`), the project's own, or the dealt photographs, in that order
(`references/imagery.md`). Size each to its largest display size, set `width` and `height`.
- **Words that read in a breath.** A held statement is 4 to 12 words; captions are one to four words in
the text face, sentence case. One statement on screen at a time.
- **The scene says something about the subject.** A fly-through of the studio's work, the route a
shipment really takes, the four rooms of the house, the product's change of state. A scene filled
with unrelated pictures is a gimmick and reads as one.
- **Real content only.** If the scene needs eight pictures of the subject and there are three, take the
`--redraw scenes`, never pad it with stand-ins.
## 4. Fitting it to the page
- The scene files read the page's tokens: `--ground`, `--ink`, `--muted`, `--accent`,
`--font-display`, `--font-text` (with fallbacks). Set them on `:root` from the sheet and the scene
takes the page's colour and type. Check the demo against your page, not the other way round.
- Display type in a scene uses the dealt display face and the two-line headline rule; statements stay
at statement size.
- Leave a quiet section between two scenes (a text section, a list, a plain picture) so each one lands.
Two scenes back to back read as one long ride and the second loses.
- A scene on the first screen must be readable at progress 0: the title, the sentence and the action are
there on the first frame (motion rule 1), and the scene starts moving only when the visitor scrolls.
- Give the section a heading. If the design has no visible heading in the scene, the section still
carries one (`aria-label` on the section and a real heading in the finished composition).
## 5. The phone, reduced motion, no script
- **The phone.** Each scene has a phone plan in its file: the same scene with changed numbers, a
simpler version, or `phone: 'rest'`, where the phone shows the finished composition. Touch scrolling
is never smoothed. Look at the phone sheet as carefully as the desktop one.
- **Reduced motion and no script.** The section is an ordinary section showing the finished
composition: the same content, laid out to read without motion. Nothing is hidden waiting for a
script. The inspector's `reduced-hidden` check and the reduced sheet of `scenes.mjs` show it.
- **A failed CDN** falls back to the finished composition too (the stage adds `.is-rest`). The
inspector fails a scene that rests on desktop (`scene-not-live`), so a broken script cannot pass.
## 6. Checking it
```bash
node <skill>/scripts/scenes.mjs <page> # every scene at 0, 10 … 100%, desktop and phone, and reduced motion
node <skill>/scripts/inspect.mjs <page> # the gate; with scenes on the sheet it also films the scroll
```
`scenes.mjs` writes one contact sheet per scene and width to `.taste-skill/scenes/`. Read every one.
At every frame ask: is there one clear thing to look at; can the statement on screen be read; is
anything popping, blank, cut off or crossing the words; is it as bold as it should be? The inspector's
`desktop-film-NN.jpg` shows the whole page's scroll a third of a screen at a time: check that the
scenes and the quiet sections between them make a rhythm, and that nothing between them is empty.
## 7. Rules for scenes
1. **Two or three per page,** from different families, a quiet section between them. The inspector
warns over four (`many-scenes`) and when scenes hold more than 60% of the page (`scene-heavy`).
2. **Each holds one to four screens:** about one screen per beat of the story; a hold with nothing new
to show is cut, not padded.
3. **Native scroll only.** No wheel capture, no locking the page, no scenes that only run on a click.
Smoothing only through `Stage.smooth()`.
4. **Readable at its rest points.** The statement on screen reads whole; nothing passes over text while
it is being read; text on a picture has a flat scrim or a calm area under it.
5. **Inside a scene the scene's numbers apply** (motion rule 7): a picture may grow from a window to the
whole screen, type may cross the stage, a camera may fly through pictures. Outside scenes, the quiet
limits of `references/motion.md` hold.
6. **Transform, opacity, clip-path.** Nothing that forces layout on every frame. Pictures decoded before
they move.
7. **The house rules hold inside scenes:** no monospace captions, no 01/07 counters (a plain "3 of 7" if
a count is needed), no gradients (flat scrims), no shadows or glows, no hairline decoration, no
decorative particles, no violet.
8. **The finished composition is designed,** not an afterthought: it is what reduced motion, a failed
script and some phones see.
## 8. When no scene in the library fits
Redraw first (`--redraw scenes`). If the brief really needs a scene the library does not have, build it
on the same stage so it inherits the guards:
```js
Scenes['my-scene'] = (root, opts = {}) => Stage.scene(root, {
hold: opts.hold ?? 2, // screens the stage is held for
scrub: opts.scrub ?? true, // true when Lenis runs; 0.4-0.8 without it reads as weight
phone: 'same', // or 'rest', or { hold, build } for a phone plan
build(tl, { q, phone }) { // positions are fractions of the scene, 0-1
tl.fromTo(q('.picture'), { clipPath: 'inset(30% 30% 30% 30%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.6 }, 0)
.to(q('.title'), { yPercent: -40, autoAlpha: 0, duration: 0.25 }, 0.7);
},
});
```
and write its CSS finished composition first, then the live layout under `.scene.is-live`. Check it
with `scenes.mjs` like any other.
## 9. The catalogue
(Generated from `library/scenes.json`; the draw deals from it. `node <skill>/scripts/draw.mjs --list
scenes` prints it.)
@@ -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` §9.
## 8. Why removal is not enough
A page with every tell removed and nothing put in their place is an empty page with good manners, and
that is a tell too (the "avoidance aesthetic": anyone could guess it from the category plus the list of
things to avoid). The tells were filling a gap where an idea should have been. The order of work in
`SKILL.md` exists to fill that gap first: the idea from the subject, the dealt composition, real
pictures or committed type, and only then the checks.
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function text(v) {
if (v === undefined || v === null) return '';
return String(v).replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'")
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&amp;/g, '&')
.replace(/\s+/g, ' ').trim();
}
function keyOf(u) { let s = noQuery(u) || ''; try { s = decodeURIComponent(s); } catch {} return s.toLowerCase().replace(/^https?:\/\//, '').replace(/ /g, '_'); }
function slug(s) {
return String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
.split('-').slice(0, 7).join('-').slice(0, 60).replace(/-+$/, '');
}
// The words of a query that carry meaning, and whether a record's own description holds them
// (all of them, or all but one of three or more). Used to keep the museum sources on the subject.
function queryWords(q) { return String(q).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w && !['the', 'a', 'an', 'of', 'in', 'on', 'at', 'and', 'or', 'for', 'with', 'from', 'to', 'by'].includes(w)); }
function onSubject(wanted, description) {
if (!wanted.length) return true;
const loose = (s) => String(s).toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/our\b/g, 'or'); // harbour = harbor
const hay = loose(description);
const hits = wanted.filter((w) => { const x = loose(w); return hay.includes(x) || (x.length > 4 && hay.includes(x.replace(/(es|s)$/, ''))); }).length;
return hits >= (wanted.length >= 3 ? wanted.length - 1 : wanted.length);
}
async function mapLimit(items, n, fn) {
const out = new Array(items.length); let next = 0;
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) { const k = next++; out[k] = await fn(items[k], k); } }));
return out;
}
async function getJson(url, ms = 20000, headers = {}) {
for (let attempt = 1; ; attempt++) {
let r;
try { r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) }); }
catch (e) {
const timedOut = e.name === 'TimeoutError' || e.name === 'AbortError';
if (!timedOut && attempt < 2) { await sleep(800); continue; }
throw new Error(timedOut ? `no answer within ${Math.round(ms / 1000)} s` : `could not connect (${e.cause?.code || e.message})`);
}
if ([502, 503, 504].includes(r.status) && attempt < 2) { await sleep(1200); continue; }
if (r.status === 429) throw new Error('rate limited (HTTP 429): wait a minute, or search another --source');
if (r.status === 410) throw new Error('this API endpoint has been retired (HTTP 410); assets.mjs needs updating for it');
if (!r.ok) throw new Error(`HTTP ${r.status}${r.status >= 500 ? ', the service is having trouble' : ''}`);
try { return await r.json(); } catch { throw new Error('the answer was not JSON'); }
}
}
// Width and height from the first bytes of a JPEG, PNG, GIF or WebP file.
function dims(b) {
if (!b || b.length < 26) return {};
if (b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG') return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
if (b.toString('ascii', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
if (b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' && b.length >= 30) {
const f = b.toString('ascii', 12, 16);
if (f === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
if (f === 'VP8L') { const bits = b.readUInt32LE(21); return { w: 1 + (bits & 0x3fff), h: 1 + ((bits >>> 14) & 0x3fff) }; }
if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
}
if (b[0] === 0xff && b[1] === 0xd8) {
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) { i++; continue; }
const m = b[i + 1];
if (m === 0xff) { i++; continue; }
if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) { i += 2; continue; }
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
i += 2 + b.readUInt16BE(i + 2);
}
}
return {};
}
// Width, height and duration from the head of an MP4 whose index comes first (what web renditions use).
function mp4Info(b) {
const out = {}; let i = -1; let area = 0;
while ((i = b.indexOf('tkhd', i + 1)) > 3) { // one per track; the video track is the one with a size
const end = i - 4 + b.readUInt32BE(i - 4);
if (end <= b.length && end - (i - 4) >= 84) { const w = b.readUInt32BE(end - 8) >>> 16; const h = b.readUInt32BE(end - 4) >>> 16; if (w * h > area) { area = w * h; out.w = w; out.h = h; } }
}
const m = b.indexOf('mvhd');
if (m > 3 && m + 36 <= b.length) {
const v1 = b[m + 4] === 1; const scale = b.readUInt32BE(m + (v1 ? 24 : 16)); const d = v1 ? Number(b.readBigUInt64BE(m + 28)) : b.readUInt32BE(m + 20);
if (scale && d) out.duration = d / scale;
}
return out;
}
const clock = (s) => { if (!s && s !== 0) return null; const t = Math.round(s); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; };
// The Art Institute's image server turns away scripts that do not name themselves in this header.
const imageHeaders = (url) => ({ 'User-Agent': UA, Accept: 'image/*', ...(/^https?:\/\/[^/]*artic\.edu\//.test(url) ? AIC_HEADERS : {}) });
// Does the file load, is it something a browser shows, and how large is it? Reads only the first
// 128 KB of an image, or 512 KB of a video (enough for an MP4's size and duration).
async function probe(url, ms = 15000, video = false, again = true) {
const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms);
const want = video ? 524288 : 131072;
try {
const headers = video ? { 'User-Agent': UA, Accept: 'video/*' } : imageHeaders(url);
const r = await fetch(url, { headers: { ...headers, Range: `bytes=0-${want - 1}` }, signal: ac.signal, redirect: 'follow' });
if (!r.ok) {
r.body?.cancel().catch(() => {});
if (r.status >= 500 && again) { clearTimeout(timer); await sleep(1000); return probe(url, ms, video, false); } // file servers stumble now and then
return { ok: false, why: `HTTP ${r.status}` };
}
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!(video ? VIDEO_TYPES : WEB_TYPES)[type]) { r.body?.cancel().catch(() => {}); return { ok: false, why: type ? `not a web ${video ? 'video' : 'image'} (${type})` : 'no content type' }; }
const reader = r.body.getReader(); const chunks = []; let n = 0;
while (n < want) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); n += value.length; }
reader.cancel().catch(() => {});
const bytes = r.status === 206 ? Number((r.headers.get('content-range') || '').split('/')[1]) || null : Number(r.headers.get('content-length')) || null;
const head = Buffer.concat(chunks);
return { ok: true, type, bytes, ...(video ? (type === 'video/mp4' ? mp4Info(head) : {}) : dims(head)) };
} catch (e) {
return { ok: false, why: e.name === 'AbortError' ? 'timed out' : e.cause?.code || e.message };
} finally { clearTimeout(timer); }
}
// ---------- licences and credits ----------
function licenceLabel(it) {
if (it.licenseName) return it.licenseName; // set where the plain code would mislead: museums, informal statements, unusual licences
if (it.license === 'cc0') return 'CC0';
if (it.license === 'pd') return 'public domain';
if (it.license === 'other') return 'licence on the source page';
return `CC ${it.license.toUpperCase()}${it.licenseVersion ? ' ' + it.licenseVersion : ''}`;
}
function licenceUrl(it) {
if (it.licenseUrl) return String(it.licenseUrl).replace(/^http:/, 'https:');
if (it.license === 'cc0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
if (it.license === 'pd') return 'https://creativecommons.org/publicdomain/mark/1.0/';
if (it.license.startsWith('by')) return `https://creativecommons.org/licenses/${it.license}/${it.licenseVersion || '4.0'}/`;
return null;
}
const creditRequired = (it) => it.license !== 'cc0' && it.license !== 'pd';
function licenceNotes(it) {
const n = [];
if (/-sa$/.test(it.license)) n.push('Share-alike: an altered version (recoloured, combined with other pictures) must be shared under the same licence.');
if (/-nc/.test(it.license)) n.push('Non-commercial only: not for a business, a shop or a client site.');
if (/-nd$/.test(it.license)) n.push('No derivatives: show it whole and unaltered, no crops or treatments.');
if (it.license === 'other') n.push('A licence outside the usual set: read the source page before using it.');
return n;
}
function credit(it) {
const by = it.creator && !/^(unknown|anonymous)\b/i.test(it.creator) ? ` by ${short(it.creator, 60)}` : '';
const date = (it.source === 'aic' || it.source === 'met') && it.date ? `, ${short(it.date, 30)}` : '';
return `"${short(it.title, 90)}"${by}${date}, ${licenceLabel(it)}, via ${it.via}`;
}
function base(source, id) {
return { ref: `${source}:${id}`, source, media: 'image', via: SOURCES[source].name, title: '', creator: '', creatorUrl: null, date: null, kind: null, about: null, license: 'other', licenseVersion: null, licenseName: null, licenseUrl: null, page: null, image: null, original: null, smaller: null, width: null, height: null, imageWidth: null, imageHeight: null, bytes: null, estimated: false, duration: null, poster: null, alt: null, notes: [], hint: null };
}
// Licence code and version from a licence URL (Creative Commons licences and marks).
function licenceFromUrl(u) {
const s = String(u || '').toLowerCase();
if (/publicdomain\/zero/.test(s)) return { code: 'cc0', version: '1.0' };
if (/publicdomain\/mark|publicdomain\/?$|\/publicdomain\b/.test(s)) return { code: 'pd', version: null };
const m = s.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)\/(\d\.\d)/);
return m ? { code: m[1], version: m[2] } : { code: 'other', version: null };
}
// Of the renditions a source offers, the largest whose short side is at most `res` (720p by default),
// in a format every browser plays. If all are larger, the smallest one.
function pickRendition(list, res) {
const playable = list.filter((r) => r.url && VIDEO_TYPES[r.type]);
const side = (r) => (r.w && r.h ? Math.min(r.w, r.h) : Infinity);
const fit = playable.filter((r) => side(r) <= res).sort((a, b) => side(b) - side(a) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15));
return fit[0] || playable.sort((a, b) => side(a) - side(b) || (a.bytes ?? 1e15) - (b.bytes ?? 1e15))[0] || null;
}
function useRendition(it, pick, largest) {
Object.assign(it, { media: 'video', image: pick.url, mime: pick.type, imageWidth: pick.w || null, imageHeight: pick.h || null, bytes: pick.bytes || null, estimated: Boolean(pick.estimated), duration: it.duration || pick.duration || null });
if (largest) { it.width = largest.w || it.width; it.height = largest.h || it.height; }
return it;
}
// ---------- Openverse ----------
async function searchOpenverse(q, opts) {
const lic = opts.licences.filter((l) => l !== 'other').map((l) => (l === 'pd' ? 'pdm' : l));
if (!lic.length) return { total: 0, items: [], note: 'Openverse filters by Creative Commons licences and marks only.' };
const call = (large) => {
const p = new URLSearchParams({ q, license: lic.join(','), page_size: '20' });
if (opts.orientation) p.set('aspect_ratio', { landscape: 'wide', portrait: 'tall', square: 'square' }[opts.orientation]);
if (large) p.set('size', 'large');
return getJson(`https://api.openverse.org/v1/images/?${p}`, 30000);
};
// Its default order is led by 1024px Flickr files. Ask for the large ones first, and fill up from the rest only if needed.
const first = await call(true);
let rows = first.results || []; let total = first.result_count ?? null;
if (rows.filter((r) => !r.width || r.width >= opts.minWidth).length < opts.limit + 2) {
const rest = await call(false).catch(() => null);
if (rest) { total = rest.result_count ?? total; rows = [...rows, ...(rest.results || []).filter((r) => !rows.some((x) => x.id === r.id))]; }
}
const items = rows.map(fromOpenverse).filter((it) => it.image);
await wikimediaSizes(items, opts.width);
return { total, items };
}
function fromOpenverse(r) {
const it = base('openverse', r.id);
const via = r.source || r.provider;
Object.assign(it, {
via: PROVIDERS[via] || (String(via || '').startsWith('smithsonian') ? 'Smithsonian' : via ? String(via).replace(/_/g, ' ') : 'Openverse'),
title: text(r.title).replace(/^File:/, '').replace(/\.(jpe?g|png|tiff?|gif|webp)$/i, '') || 'Untitled', creator: text(r.creator), creatorUrl: r.creator_url || null,
license: r.license === 'pdm' ? 'pd' : String(r.license || 'other'), licenseVersion: r.license_version || null, licenseUrl: r.license_url || null,
page: r.foreign_landing_url || r.detail_url, image: r.url, original: r.url,
width: r.width || null, height: r.height || null, imageWidth: r.width || null, imageHeight: r.height || null,
});
if (!LICENCES.includes(it.license)) it.license = 'other';
return it;
}
// Openverse points at Wikimedia originals, which can be 10 MB or a TIFF. Swap in a web-sized rendition.
async function wikimediaSizes(items, width) {
const wm = items.filter((it) => /^https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/[0-9a-f]\/[0-9a-f]{2}\//.test(it.original || ''));
if (!wm.length) return;
const title = (it) => { let n = noQuery(it.original).split('/').pop(); try { n = decodeURIComponent(n); } catch {} return 'File:' + n.replace(/_/g, ' '); };
const j = await getJson(commonsApi({ titles: wm.map(title).join('|'), prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: String(width) })).catch(() => null);
if (!j?.query) return;
const normal = new Map((j.query.normalized || []).map((n) => [n.from, n.to]));
const info = new Map((j.query.pages || []).map((pg) => [pg.title, pg.imageinfo?.[0]]));
for (const it of wm) { const t = title(it); const ii = info.get(normal.get(t) || t); if (ii) applyThumb(it, ii); }
}
// ---------- Wikimedia Commons ----------
function commonsApi(params) { return `https://commons.wikimedia.org/w/api.php?${new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', origin: '*', ...params })}`; }
function commonsInfo(width) {
return { prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiurlwidth: String(width), iiextmetadatalanguage: 'en',
iiextmetadatafilter: 'ObjectName|Artist|LicenseShortName|License|LicenseUrl|Restrictions|DateTimeOriginal|ImageDescription' };
}
async function searchCommons(q, opts) {
let s = `${q} filetype:bitmap`;
if (opts.minWidth > 1) s += ` filew:>${opts.minWidth - 1}`;
const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: s, gsrlimit: '30', ...commonsInfo(opts.width) }));
if (j.error) throw new Error(j.error.info || 'the search failed');
const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
return { total: null, items: pages.map(fromCommons).filter(Boolean) };
}
function fromCommons(pg) {
const ii = pg?.imageinfo?.[0]; if (!ii) return null;
const m = (k) => text(ii.extmetadata?.[k]?.value);
const lic = commonsLicence(m('License'), m('LicenseShortName'));
const it = base('commons', pg.pageid);
Object.assign(it, {
title: m('ObjectName').replace(/\s*\b(?:title|label) QS:.*$/s, '') || pg.title.replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, ''), creator: m('Artist'),
date: m('DateTimeOriginal').replace(/\s*\bdate QS:.*$/s, '').replace(/^(\d{4}-\d{2}-\d{2})[ T]\d{2}:\d{2}.*$/, '$1') || null, about: m('ImageDescription') || null,
license: lic.code, licenseVersion: lic.version, licenseName: lic.name, licenseUrl: m('LicenseUrl') || null, page: ii.descriptionurl,
});
applyThumb(it, ii);
if (lic.informal) it.notes.push('Not a formal licence: the archive that published it states it knows of no copyright restrictions.');
if (it.about && it.about.toLowerCase().startsWith(it.title.toLowerCase()) && it.about.length < it.title.length + 12) it.about = null;
const restrictions = m('Restrictions');
if (restrictions) it.notes.push(`Restrictions named on the source page (${restrictions}): personality rights, a trademark or similar. Read it before use.`);
if (/^pd-us/i.test(m('License'))) it.notes.push('Public domain in the United States; check the source page before using it elsewhere.');
return it;
}
function commonsLicence(license, shortName) {
const l = String(license || '').toLowerCase(); const s = String(shortName || '').toLowerCase();
const version = (shortName.match(/\b(\d\.\d)\b/) || l.match(/(\d\.\d)/) || [])[1] || null;
if (/^cc0|^cc-zero/.test(l) || /^cc0/.test(s)) return { code: 'cc0', version: '1.0' };
if (/^pd|public domain/.test(l) || /public domain/.test(s)) return { code: 'pd', version: null };
// Flickr Commons and similar archives: no formal licence, the institution states it knows of no copyright
if (/^no (known copyright )?restrictions/.test(s)) return { code: 'pd', version: null, name: 'no known copyright restrictions', informal: true };
const cc = l.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)(?:-|$)/) || s.match(/^cc (by(?:-nc)?(?:-sa|-nd)?)\b/);
if (cc) return { code: cc[1], version };
return { code: 'other', version: null, name: shortName || license || null };
}
// Commons serves thumbnails only at standard widths (1280, 1920, 3840...); the API picks the step.
function applyThumb(it, ii) {
it.width = ii.width || it.width; it.height = ii.height || it.height; it.original = noQuery(ii.url) || it.original;
const tw = Number(String(ii.thumburl || '').split('?')[0].match(/(\d+)px-[^/]*$/)?.[1]) || 0;
if (ii.thumburl && tw && ii.width && (tw < ii.width || (ii.mime && !WEB_TYPES[ii.mime]))) {
it.image = noQuery(ii.thumburl); it.imageWidth = Math.min(tw, ii.width); it.imageHeight = ii.height ? Math.round((ii.height * it.imageWidth) / ii.width) : null;
} else { it.image = it.original; it.imageWidth = ii.width || null; it.imageHeight = ii.height || null; }
}
// ---------- Art Institute of Chicago ----------
async function searchAic(q, opts) {
const wanted = queryWords(q);
const run = (match) => getJson(`https://api.artic.edu/api/v1/artworks/search?params=${encodeURIComponent(JSON.stringify({
query: { bool: {
// the long description field is left out on purpose: it matches almost anything
must: [{ multi_match: { query: wanted.join(' ') || q, type: 'cross_fields', fields: ['title^3', 'subject_titles^2', 'term_titles^2', 'classification_titles', 'medium_display', 'artist_title', 'place_of_origin', 'date_display'], ...match } }],
filter: [{ term: { is_public_domain: true } }, { exists: { field: 'image_id' } }],
} },
fields: AIC_FIELDS, limit: 30,
}))}`, 20000, AIC_HEADERS);
// every word must match; with three words or more, one may be missing. Looser than that returns noise.
let j = await run({ operator: 'and' });
if (!(j.data || []).length && wanted.length >= 3) j = await run({ operator: 'or', minimum_should_match: wanted.length - 1 });
const iiif = j.config?.iiif_url || 'https://www.artic.edu/iiif/2';
return { total: j.pagination?.total ?? null, items: (j.data || []).map((d) => fromAic(d, iiif, opts.width)).filter(Boolean) };
}
function fromAic(d, iiif, width) {
if (!d?.image_id || d.is_public_domain === false) return null;
const it = base('aic', d.id);
const W = d.thumbnail?.width || null; const H = d.thumbnail?.height || null;
const cap = W ? Math.min(W, 3000) : 3000; const w = Math.min(width, cap);
Object.assign(it, {
title: text(d.title) || 'Untitled', creator: text(d.artist_title) || text(String(d.artist_display || '').split('\n')[0]),
date: text(d.date_display) || null, kind: [d.artwork_type_title, d.medium_display].filter(Boolean).map(text).join(', ') || null,
license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
page: `https://www.artic.edu/artworks/${d.id}`, image: `${iiif}/${d.image_id}/full/${w},/0/default.jpg`, original: `${iiif}/${d.image_id}/full/${cap},/0/default.jpg`,
width: W && H ? cap : null, height: W && H ? Math.round((H * cap) / W) : null, imageWidth: W && H ? w : null, imageHeight: W && H ? Math.round((H * w) / W) : null,
alt: text(d.thumbnail?.alt_text) || null,
});
it.hint = 'Download it with --get: the museum\'s image server answers browsers and this script, and refuses plain curl or wget.';
return it;
}
// ---------- The Met ----------
async function searchMet(q, opts) {
const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1.1/search?${new URLSearchParams({ q, hasImages: 'true', limit: String(Math.min(40, opts.limit * 4 + 8)), offset: '0' })}`);
const ids = (s.objectIDs || []).slice(0, opts.limit * 4 + 8);
const objects = await mapLimit(ids, 8, (id) => getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`, 15000).catch(() => null));
// The museum's search reads every field of a record, so much of what it returns is off the subject.
// Keep the objects whose own name, material, maker, place or tags carry the words of the query.
const wanted = queryWords(q);
const about = (x) => [x.title, x.objectName, x.medium, x.classification, x.culture, x.period, x.artistDisplayName, x.department, x.city, x.country, x.region, x.objectDate, ...(x.tags || []).map((t) => t.term)].filter(Boolean).join(' ');
return { total: s.total ?? null, items: objects.filter((x) => x && onSubject(wanted, about(x))).map(fromMet).filter(Boolean) };
}
function fromMet(x) {
if (!x || !x.isPublicDomain || !x.primaryImage) return null;
const it = base('met', x.objectID);
const fix = (u) => (u ? encodeURI(u).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
Object.assign(it, {
title: text(x.title) || text(x.objectName) || 'Untitled', creator: text(x.artistDisplayName) || (x.culture ? `${text(x.culture)} maker` : ''),
date: text(x.objectDate) || null, kind: [x.objectName || x.classification, x.medium].filter(Boolean).map(text).join(', ') || null,
license: 'cc0', licenseVersion: '1.0', licenseName: 'public domain (CC0)', licenseUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
page: x.objectURL || `https://www.metmuseum.org/art/collection/search/${x.objectID}`,
image: fix(x.primaryImage), original: fix(x.primaryImage), smaller: x.primaryImageSmall && x.primaryImageSmall !== x.primaryImage ? fix(x.primaryImageSmall) : null,
});
return it;
}
// ---------- video: Wikimedia Commons ----------
async function searchCommonsVideo(q, opts) {
const j = await getJson(commonsApi({ generator: 'search', gsrnamespace: '6', gsrsearch: `${q} filetype:video`, gsrlimit: '30', ...commonsInfo(1280), prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
if (j.error) throw new Error(j.error.info || 'the search failed');
const pages = (j.query?.pages || []).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
return { total: null, items: pages.map((pg) => fromCommonsVideo(pg, opts)).filter(Boolean) };
}
function fromCommonsVideo(pg, opts) {
const it = fromCommons(pg); const ii = pg?.imageinfo?.[0];
if (!it || !ii) return null;
it.poster = it.image !== it.original ? it.image : null; // for a video file, the thumbnail is a still frame
it.duration = Number(ii.duration) || null;
// the original plus the transcodes Commons makes of every video (240p to 1080p webm); sizes from their bit rates
const renditions = (pg.videoinfo?.[0]?.derivatives || []).map((d) => {
const url = noQuery(d.src); const original = !/\/transcoded\//.test(url);
return { url, type: String(d.type || '').split(';')[0].trim(), w: Number(d.width) || null, h: Number(d.height) || null,
bytes: original ? Number(ii.size) || null : d.bandwidth && it.duration ? Math.round((d.bandwidth * it.duration) / 8) : null, estimated: !original };
});
const pick = pickRendition(renditions, opts.res);
return pick ? useRendition(it, pick, { w: ii.width, h: ii.height }) : null;
}
// ---------- video: Internet Archive ----------
const first = (v) => (Array.isArray(v) ? v[0] : v);
const seconds = (s) => {
if (s === undefined || s === null || s === '') return null;
if (/^\d+(\.\d+)?$/.test(String(s))) return Number(s);
const p = String(s).split(':').map(Number); return p.length > 1 && p.every(Number.isFinite) ? p.reduce((a, x) => a * 60 + x, 0) : null;
};
// Only the Prelinger Archives: curated industrial, educational, advertising and home films, mostly in the
// public domain. The rest of the archive's films are uploads by anyone, and their licence claims go
// unchecked (an upload called "... all rights reserved" can carry a public-domain mark), so they are left out.
const ARCHIVE_LICENCE = { cc0: '*publicdomain\\/zero*', pd: '*publicdomain*', by: '*licenses\\/by\\/*', 'by-sa': '*licenses\\/by-sa\\/*', 'by-nc': '*licenses\\/by-nc\\/*', 'by-nd': '*licenses\\/by-nd\\/*', 'by-nc-sa': '*licenses\\/by-nc-sa\\/*', 'by-nc-nd': '*licenses\\/by-nc-nd\\/*' };
async function searchArchive(q, opts) {
const words = queryWords(q);
const licences = opts.licences.includes('other') ? '' : ` AND licenseurl:(${[...new Set(opts.licences.map((l) => ARCHIVE_LICENCE[l]))].join(' OR ')})`;
const p = new URLSearchParams({ q: `(${(words.length ? words : [q]).join(' AND ')}) AND mediatype:(movies) AND collection:(prelinger)${licences}`, rows: '40', output: 'json' });
for (const f of ['identifier', 'licenseurl']) p.append('fl[]', f);
const j = await getJson(`https://archive.org/advancedsearch.php?${p}`, 30000);
if (j.error) throw new Error(`its search is failing right now (${short(text(j.error), 80)}); try again later`);
const docs = j.response?.docs || [];
const allowed = docs.filter((d) => opts.licences.includes(licenceFromUrl(first(d.licenseurl)).code));
const metas = await mapLimit(allowed.slice(0, opts.limit * 2 + 4), 6, (d) => getJson(`https://archive.org/metadata/${encodeURIComponent(d.identifier)}`, 20000).catch(() => null));
return { total: j.response?.numFound ?? null, skippedLicence: docs.length - allowed.length, items: metas.map((m) => fromArchive(m, opts)).filter(Boolean) };
}
function fromArchive(m, opts) {
const md = m?.metadata; if (!md?.identifier || !Array.isArray(m.files)) return null;
const id = md.identifier;
const fileUrl = (name) => `https://archive.org/download/${encodeURIComponent(id)}/${String(name).split('/').map(encodeURIComponent).join('/')}`;
const isVideo = (f) => /\.(mp4|m4v|webm|mpe?g|avi|mov|ogv|mkv|wmv|flv)$/i.test(f.name || '');
// an item can hold several films: keep the first original and the web copies made from it
const originals = m.files.filter((f) => f.source === 'original' && isVideo(f));
const stem = originals[0] ? originals[0].name.replace(/\.[^.]+$/, '') : null;
const family = m.files.filter((f) => isVideo(f) && (!stem || f.name.startsWith(stem)));
const renditions = family.filter((f) => /\.(mp4|webm)$/i.test(f.name)).map((f) => ({ url: fileUrl(f.name), type: /\.webm$/i.test(f.name) ? 'video/webm' : 'video/mp4', w: Number(f.width) || null, h: Number(f.height) || null, bytes: Number(f.size) || null, duration: seconds(f.length) }));
const pick = pickRendition(renditions, opts.res); if (!pick) return null;
const largest = family.reduce((a, f) => ((Number(f.width) || 0) > (a.w || 0) ? { w: Number(f.width), h: Number(f.height) } : a), { w: pick.w, h: pick.h });
const lic = licenceFromUrl(first(md.licenseurl));
const it = base('archive', id);
Object.assign(it, {
title: text(first(md.title)) || id, creator: text(first(md.creator)), date: text(first(md.date) || first(md.year)) || null, about: text(first(md.description)) || null,
license: lic.code, licenseVersion: lic.version, licenseUrl: first(md.licenseurl) || null, page: `https://archive.org/details/${id}`,
original: fileUrl((originals[0] || family[0]).name), duration: pick.duration || seconds(first(md.runtime)),
poster: null, // the archive's own stills are about 160 pixels wide: too small to stand in for a clip
});
// the same film is often stored in several formats; different names mean different films
const films = new Set(originals.map((f) => f.name.split('/').pop().split('.')[0])).size;
if (films > 1) it.notes.push(`This item holds ${films} films; the first is shown. The page lists the others.`);
return useRendition(it, pick, largest);
}
// ---------- video: NASA Image and Video Library ----------
const nasaUrl = (u) => (u ? encodeURI(String(u).replace(/^http:/, 'https:')).replace(/%25([0-9a-f]{2})/gi, '%$1') : null);
async function searchNasa(q, opts) {
const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ q, media_type: 'video', page_size: String(Math.min(100, opts.limit * 3 + 6)) })}`);
// its search matches any word anywhere; keep the clips whose own title, description or keywords carry the query
const wanted = queryWords(q);
const rows = (j.collection?.items || []).filter((x) => { const d = x.data?.[0] || {}; return onSubject(wanted, [d.title, d.description, ...(d.keywords || [])].join(' ')); }).slice(0, opts.limit * 2 + 4);
const items = await mapLimit(rows, 6, async (x) => fromNasa(x, await getJson(nasaUrl(x.href), 15000).catch(() => null), opts));
return { total: j.collection?.metadata?.total_hits ?? null, items: items.filter(Boolean) };
}
function fromNasa(x, files, opts) {
const d = x?.data?.[0]; if (!d?.nasa_id || !Array.isArray(files)) return null;
const find = (suffix) => files.find((f) => String(f).endsWith(suffix));
// NASA makes the same renditions of every video; these are their 16:9 sizes, and the check reads the real ones
const renditions = [['~large.mp4', 1920, 1080], ['~medium.mp4', 1280, 720], ['~small.mp4', 640, 360], ['~mobile.mp4', 320, 180]]
.filter(([s]) => find(s)).map(([s, w, h]) => ({ url: nasaUrl(find(s)), type: 'video/mp4', w, h }));
const pick = pickRendition(renditions, opts.res); if (!pick) return null;
const it = base('nasa', encodeURIComponent(d.nasa_id));
Object.assign(it, {
title: text(d.title) || d.nasa_id, creator: text(d.photographer || d.secondary_creator) || `NASA${d.center ? ' ' + d.center : ''}`,
date: String(d.date_created || '').slice(0, 10) || null, about: text(d.description) || null,
license: 'pd', licenseName: 'public domain (NASA)', licenseUrl: 'https://www.nasa.gov/nasa-brand-center/images-and-media/',
page: `https://images.nasa.gov/details/${encodeURIComponent(d.nasa_id)}`, original: nasaUrl(find('~orig.mp4')) || pick.url,
poster: nasaUrl(find('~large.jpg') || find('~medium.jpg') || find('~thumb.jpg')),
});
it.notes.push('NASA footage is generally free of copyright in the US. Never use NASA logos or suggest NASA endorses the client; check the page for third-party material.');
return useRendition(it, pick, renditions[0]);
}
function videoNotes(it, opts) {
const n = [];
const smaller = it.imageWidth && it.imageHeight && Math.min(it.imageWidth, it.imageHeight) > 480;
if (it.bytes && it.bytes > opts.maxMb * 1048576) n.push(`Over ${opts.maxMb} MB (${it.estimated ? 'about ' : ''}${size(it.bytes)}): too heavy for a page, and --get refuses it unless you pass --max-mb. ${smaller ? 'Try --res 480, or a shorter clip.' : 'Choose a shorter clip.'}`);
if (it.duration && it.duration > 60) n.push(`Long (${clock(it.duration)}): a loop on a page wants 5 to 20 seconds. Prefer a shorter clip, or trim one with ffmpeg if it is installed.`);
return n;
}
// ---------- search ----------
function fits(it, opts, skipped) {
const W = it.width || it.imageWidth; const H = it.height || it.imageHeight;
if (W && W < opts.minWidth) { skipped.small++; return false; }
if (opts.orientation && W && H && shape(W, H) !== opts.orientation) { skipped.shape++; return false; }
return true;
}
async function search(query, opts) {
const runners = opts.video ? { commons: searchCommonsVideo, archive: searchArchive, nasa: searchNasa } : { openverse: searchOpenverse, commons: searchCommons, aic: searchAic, met: searchMet };
// a slow service never holds the whole search: each source gets 45 s, the check 30 s
const deadline = (ms, message) => new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref());
const results = await Promise.all(opts.sources.map(async (key) => {
try { return { key, ...(await Promise.race([runners[key](query, opts), deadline(45000, 'no complete answer within 45 s; try again or search another --source')])) }; }
catch (e) { return { key, error: e.message, items: [] }; }
}));
// short clips first, then longer ones, then anything over the size cap; the source's own order within each
const cap = opts.maxMb * 1048576;
const rank = (it) => (it.media !== 'video' ? 0 : it.bytes && it.bytes > cap ? 4 : !it.duration ? 3 : it.duration <= 30 ? 0 : it.duration <= 90 ? 1 : it.duration <= 300 ? 2 : 3);
const byRank = (a, b) => rank(a) - rank(b);
const seen = new Set();
for (const r of results) {
r.skipped = { licence: r.skippedLicence || 0, small: 0, shape: 0, dead: 0, alike: 0 };
const titles = new Map();
r.items = r.items.filter((it) => {
if (!opts.licences.includes(it.license)) { r.skipped.licence++; return false; }
const k = keyOf(it.original || it.image); if (seen.has(k)) return false; seen.add(k);
if (!fits(it, opts, r.skipped)) return false;
// one scanned book can fill a whole page of results: keep two pictures per title
const t = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 32); const n = (titles.get(t) || 0) + 1; titles.set(t, n);
if (t && n > 2) { r.skipped.alike++; return false; }
return true;
}).sort(byRank).slice(0, opts.limit + 4); // a few spares for files that turn out not to load
}
if (opts.check) {
await Promise.race([mapLimit(results.flatMap((r) => r.items), 8, async (it) => {
const video = it.media === 'video';
const p = await probe(it.image, video ? 15000 : 12000, video); it.check = p;
if (p.ok && p.w && p.h) {
it.imageWidth = p.w; it.imageHeight = p.h;
if (!it.width || it.image === it.original) { it.width = p.w; it.height = p.h; }
}
if (p.ok && p.bytes) { it.bytes = p.bytes; it.estimated = false; }
if (p.ok && p.duration) it.duration = p.duration;
}), new Promise((r) => setTimeout(r, 30000).unref())]);
for (const r of results) r.items = r.items.filter((it) => (it.check?.ok ? fits(it, opts, r.skipped) : (r.skipped.dead++, (r.skipped.why = it.check?.why || 'not checked within 30 s'), false))).sort(byRank);
}
for (const r of results) r.items = r.items.slice(0, opts.limit);
const shown = results.reduce((n, r) => n + r.items.length, 0);
if (args.json) {
console.log(JSON.stringify({ query, options: { media: opts.video ? 'video' : 'image', sources: opts.sources, licences: opts.licences, orientation: opts.orientation, minWidth: opts.minWidth, width: opts.width, res: opts.video ? opts.res : undefined, maxMb: opts.video ? opts.maxMb : undefined, limit: opts.limit, checked: opts.check },
sources: results.map((r) => ({ source: r.key, name: SOURCES[r.key].name, total: r.total ?? null, error: r.error || null, skipped: r.skipped,
items: r.items.map(({ check, ...it }) => ({ ...it, licenseLabel: licenceLabel(it), licenseUrl: licenceUrl(it), creditRequired: creditRequired(it), credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...(it.media === 'video' ? videoNotes(it, opts) : [])] })) })) }, null, 1));
return shown ? 0 : 1;
}
const filters = opts.video
? [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `renditions up to ${opts.res}p`, `flagged over ${opts.maxMb} MB`, opts.check ? 'every file checked' : 'files not checked']
: [`licences ${opts.licences.join(', ')}`, `at least ${opts.minWidth}px wide`, opts.orientation, `image URLs ${opts.width}px wide where the source can size them`, opts.check ? 'every image checked' : 'images not checked'];
console.log(`ASSETS${opts.video ? ' · VIDEO' : ''} · "${query}" · ${filters.filter(Boolean).join(' · ')}`);
for (const r of results) {
const src = SOURCES[r.key];
console.log('');
if (r.error) { console.log(`## ${src.name}: could not search it (${r.error})`); continue; }
const dropped = [r.skipped.small && `${r.skipped.small} narrower than ${opts.minWidth}px`, r.skipped.shape && `${r.skipped.shape} not ${opts.orientation}`, r.skipped.licence && `${r.skipped.licence} under other licences`, r.skipped.alike && `${r.skipped.alike} more with the same title`, r.skipped.dead && `${r.skipped.dead} did not load (${r.skipped.why})`].filter(Boolean);
const count = r.total != null ? `${r.total} matches, ${r.items.length} shown` : `${r.items.length} shown`;
console.log(`## ${src.name}: ${r.items.length ? count : 'nothing usable'}${dropped.length ? ` (dropped: ${dropped.join(', ')})` : ''}`);
if (r.note) console.log(r.note);
if (!r.items.length) continue;
console.log(`${(opts.video && src.videoNote) || src.note}`);
for (const it of r.items) {
console.log('');
console.log(`${it.ref} ${short(it.title, 100)}${it.date ? ` (${short(it.date, 30)})` : ''}`);
if (it.kind) console.log(` what ${short(it.kind, 110)}`);
if (it.about) console.log(` about ${short(it.about, 150)}`);
const pixels = it.imageWidth && it.imageHeight ? `${it.imageWidth} × ${it.imageHeight}` : 'size unknown';
const largest = it.width && it.height && it.width !== it.imageWidth ? `; largest ${it.width} × ${it.height}` : '';
const sh = shape(it.width || it.imageWidth, it.height || it.imageHeight);
if (it.media === 'video') {
const facts = [pixels, it.duration ? clock(it.duration) : 'length unknown', it.bytes ? `${it.estimated ? 'about ' : ''}${size(it.bytes)}` : null, VIDEO_TYPES[it.mime]];
console.log(` file ${it.image}`);
console.log(` size ${facts.filter(Boolean).join(', ')}${largest}${sh ? `; ${sh}` : ''}`);
if (it.poster) console.log(` poster ${it.poster}`);
} else {
console.log(` image ${it.image}`);
console.log(` size ${pixels}${it.bytes ? `, ${size(it.bytes)}` : ''}${largest}${sh ? `; ${sh}` : ''}`);
if (it.smaller) console.log(` smaller ${it.smaller}`);
}
console.log(` page ${it.page}`);
const lu = licenceUrl(it);
console.log(` licence ${licenceLabel(it)}${lu ? ` (${lu})` : ''}; ${creditRequired(it) ? 'credit required' : 'no credit required, credit anyway'}`);
console.log(` credit ${credit(it)}`);
for (const n of [...(it.media === 'video' ? videoNotes(it, opts) : []), ...it.notes, ...licenceNotes(it), ...(it.hint ? [it.hint] : [])]) console.log(` note ${n}`);
}
}
console.log('');
if (results.every((r) => r.error)) {
console.log('No source could be reached. Check the network and try again; if this machine has no internet access, draw the');
console.log('pictures instead (references/imagery.md) and say in your report that the open collections could not be searched.');
return 1;
}
if (!shown && opts.video) {
console.log('Nothing usable came back. Name the action or the process ("coffee roasting", "timber framing", "river"); use fewer');
console.log('words; try a smaller --min-width or another --source. A page does not need video: a still picture or a drawing is fine.');
return 1;
}
if (!shown) {
console.log('Nothing usable came back. Try fewer words; the place\'s own name for things ("azulejo", not "blue tile"); the place and a');
console.log('medium ("ghent postcard", "ghent old map", "ghent engraving"); a decade ("rotterdam harbour 1920"); a smaller --min-width;');
console.log('or another --source. The museum sources match one or two words best ("fig", "harbour"). If nothing honest exists, draw it.');
return 1;
}
if (opts.video) console.log('Next. Watch before you choose: --get downloads the clip and a poster frame into the project and records the credit.');
else console.log('Next. Look before you choose: --get downloads into the project and records the credit; open the file to see it.');
console.log(` ${CMD} --get <ref>[,<ref>...]${o.toArg}`);
console.log(` ${CMD} --credits${o.toArg} the footer credit line and the list for your report`);
console.log(`These ${opts.video ? 'clips' : 'pictures'} show the subject's world. Never present one as the client's own product, room, dish, person or team.`);
return 0;
}
// ---------- get ----------
function creditsPath(opts) { return join(opts.to, 'credits.json'); }
function readCredits(opts) { try { const j = JSON.parse(readFileSync(creditsPath(opts), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }
// A file name in the target folder that is free, or already belongs to this same picture.
function targetFile(opts, stem, ext, ref, credits) {
let file = join(opts.to, `${stem}.${ext}`);
for (let k = 2; existsSync(file) && credits.find((c) => c.file === rel(file))?.ref !== ref; k++) file = join(opts.to, `${stem}-${k}.${ext}`);
return file;
}
function recordCredit(opts, credits, entry) { writeFileSync(creditsPath(opts), JSON.stringify([...credits.filter((c) => c.file !== entry.file), entry], null, 1)); }
async function getOne(ref, opts) {
const m = String(ref).match(/^(openverse|commons|aic|met|archive|nasa):(.+)$/);
if (!m) throw new Error('a ref looks like openverse:<id>, commons:<id>, aic:<id>, met:<id>, archive:<id> or nasa:<id>, as a search prints it.');
const [, src, id] = m;
let it;
if (src === 'openverse') { it = fromOpenverse(await getJson(`https://api.openverse.org/v1/images/${encodeURIComponent(id)}/`, 30000)); await wikimediaSizes([it], opts.width); }
else if (src === 'commons') {
// the same ref form serves pictures and videos; the record says which
const j = await getJson(commonsApi({ pageids: id, ...commonsInfo(opts.width), iiprop: 'url|size|mime|mediatype|extmetadata', prop: 'imageinfo|videoinfo', viprop: 'derivatives' }));
const pg = j.query?.pages?.[0]; const ii = pg?.imageinfo?.[0];
it = ii && (ii.mediatype === 'VIDEO' || /^video\//.test(ii.mime || '')) ? fromCommonsVideo(pg, opts) : fromCommons(pg);
}
else if (src === 'aic') { const j = await getJson(`https://api.artic.edu/api/v1/artworks/${encodeURIComponent(id)}?fields=${AIC_FIELDS.join(',')}`, 20000, AIC_HEADERS); it = fromAic(j.data, j.config?.iiif_url || 'https://www.artic.edu/iiif/2', opts.width); }
else if (src === 'archive') it = fromArchive(await getJson(`https://archive.org/metadata/${encodeURIComponent(id)}`, 30000), opts);
else if (src === 'nasa') {
const j = await getJson(`https://images-api.nasa.gov/search?${new URLSearchParams({ nasa_id: decodeURIComponent(id), media_type: 'video' })}`);
const x = j.collection?.items?.[0];
it = x ? fromNasa(x, await getJson(nasaUrl(x.href), 15000), opts) : null;
}
else {
it = fromMet(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${encodeURIComponent(id)}`));
if (it?.smaller) { const s = await probe(it.smaller); if (s.ok && s.w >= opts.width * 0.9) it.image = it.smaller; }
}
if (!it || !it.image) throw new Error(`no usable ${['archive', 'nasa'].includes(src) ? 'video (no rendition a browser plays, or the record is gone)' : 'image (not public domain, or the record has no picture)'}.`);
if (!opts.licences.includes(it.license)) throw new Error(`its licence is ${licenceLabel(it)}, outside the ones allowed (${opts.licences.join(', ')}). Pass --license all to take it anyway, and say why in your report.`);
if (it.media === 'video') return getVideo(it, src, id, opts);
let r;
try { r = await fetch(it.image, { headers: imageHeaders(it.image), signal: AbortSignal.timeout(90000) }); }
catch (e) { throw new Error(`the image did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
if (!r.ok) throw new Error(`the image did not load (HTTP ${r.status}).`);
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
const ext = WEB_TYPES[type];
if (!ext) throw new Error(`the file is ${type || 'of no stated type'}, not a web image.`);
const buf = Buffer.from(await r.arrayBuffer());
if (!buf.length) throw new Error('the image was empty.');
const d = dims(buf);
mkdirSync(opts.to, { recursive: true });
const credits = readCredits(opts);
const stem = slug(args.name || it.title) || `${src}-${slug(id).slice(0, 12)}`;
const file = targetFile(opts, stem, ext, it.ref, credits);
writeFileSync(file, buf);
const entry = {
file: rel(file), media: 'image', ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
page: it.page, via: it.via, image: it.image, width: d.w || it.imageWidth, height: d.h || it.imageHeight, bytes: buf.length,
credit: credit(it), notes: [...it.notes, ...licenceNotes(it)], saved: new Date().toISOString(),
};
recordCredit(opts, credits, entry);
const alt = it.alt || it.title;
console.log(`Saved ${entry.file}${entry.width ? ` · ${entry.width} × ${entry.height}` : ''} · ${size(buf.length)}`);
console.log(` page ${it.page}`);
console.log(` credit ${entry.credit}`);
console.log(` markup <img src="${esc(entry.file)}"${entry.width ? ` width="${entry.width}" height="${entry.height}"` : ''} alt="${esc(short(alt, 120))}" loading="lazy" decoding="async">`);
console.log(' (rewrite the alt to say what the picture shows; drop loading="lazy" on the hero image)');
for (const n of entry.notes) console.log(` note ${n}`);
}
// A clip: check its size against the cap first, stream it to disk (stopping at the cap), then save a still
// frame from the source as its poster.
async function getVideo(it, src, id, opts) {
const cap = opts.maxMb * 1048576;
const head = await probe(it.image, 20000, true);
if (!head.ok) throw new Error(`the video did not load (${head.why}).`);
const side = it.imageWidth && it.imageHeight ? Math.min(it.imageWidth, it.imageHeight) : 0;
if (head.bytes && head.bytes > cap) throw new Error(`the clip is ${size(head.bytes)}, over the ${opts.maxMb} MB cap. ${side > 480 ? 'Try --res 480 for a smaller rendition, or a shorter clip' : 'Choose a shorter clip'}; pass --max-mb ${Math.ceil(head.bytes / 1048576) + 1} only if the page really needs this one.`);
mkdirSync(opts.to, { recursive: true });
const credits = readCredits(opts);
const stem = slug(args.name || it.title) || `${src}-${slug(decodeURIComponent(id)).slice(0, 12)}`;
const file = targetFile(opts, stem, VIDEO_TYPES[head.type], it.ref, credits);
let r;
try { r = await fetch(it.image, { headers: { 'User-Agent': UA, Accept: 'video/*' }, signal: AbortSignal.timeout(600000) }); }
catch (e) { throw new Error(`the video did not download (${e.name === 'TimeoutError' ? 'timed out' : e.cause?.code || e.message}).`); }
if (!r.ok) throw new Error(`the video did not load (HTTP ${r.status}).`);
const out = createWriteStream(file); const first512 = []; let kept = 0; let n = 0;
try {
const reader = r.body.getReader();
for (;;) {
const { done, value } = await reader.read(); if (done) break;
n += value.length;
if (n > cap) { reader.cancel().catch(() => {}); throw new Error(`the clip passed the ${opts.maxMb} MB cap while downloading; choose a shorter clip or pass a larger --max-mb.`); }
if (kept < 524288) { first512.push(value); kept += value.length; }
if (!out.write(value)) await new Promise((res) => out.once('drain', res));
}
await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
} catch (e) { out.destroy(); try { unlinkSync(file); } catch {} throw e; }
if (!n) { try { unlinkSync(file); } catch {} throw new Error('the video was empty.'); }
const info = head.type === 'video/mp4' ? mp4Info(Buffer.concat(first512)) : {};
const w = info.w || it.imageWidth; const h = info.h || it.imageHeight; const duration = info.duration || it.duration;
let poster = null;
if (it.poster) {
try {
const pr = await fetch(it.poster, { headers: imageHeaders(it.poster), signal: AbortSignal.timeout(60000) });
const pt = (pr.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (pr.ok && WEB_TYPES[pt]) { poster = file.replace(/\.[^.]+$/, `-poster.${WEB_TYPES[pt]}`); writeFileSync(poster, Buffer.from(await pr.arrayBuffer())); }
} catch { poster = null; }
}
const entry = {
file: rel(file), media: 'video', poster: poster ? rel(poster) : null, ref: it.ref, title: it.title, creator: it.creator || null, creatorUrl: it.creatorUrl, date: it.date,
license: licenceLabel(it), licenseCode: it.license, licenseUrl: licenceUrl(it), creditRequired: creditRequired(it),
page: it.page, via: it.via, video: it.image, width: w || null, height: h || null, duration: duration ? Math.round(duration * 10) / 10 : null, bytes: n,
credit: credit(it), notes: [...it.notes, ...licenceNotes(it), ...videoNotes({ ...it, bytes: n, duration }, opts)], saved: new Date().toISOString(),
};
recordCredit(opts, credits, entry);
console.log(`Saved ${entry.file}${w ? ` · ${w} × ${h}` : ''}${duration ? ` · ${clock(duration)}` : ''} · ${size(n)}`);
console.log(` poster ${entry.poster || 'none from the source: export a still frame yourself, or use a found picture'}`);
console.log(` page ${it.page}`);
console.log(` credit ${entry.credit}`);
console.log(` markup <video autoplay muted loop playsinline preload="metadata"${entry.poster ? ` poster="${esc(entry.poster)}"` : ''}${w ? ` width="${w}" height="${h}"` : ''} aria-hidden="true"><source src="${esc(entry.file)}" type="${head.type}"></video>`);
console.log(' (a silent loop is decoration: pause it or show the poster under prefers-reduced-motion; references/imagery.md "Video")');
for (const x of entry.notes) console.log(` note ${x}`);
}
async function getAll(refs, opts) {
let failed = 0;
for (const ref of refs) {
try { await getOne(ref, opts); } catch (e) { failed++; console.error(`Could not get ${ref}: ${e.message}`); }
console.log('');
}
if (failed < refs.length) {
console.log(`Credits are recorded in ${rel(creditsPath(opts))}. Look at each picture and watch each clip before using it.`);
console.log(`Print the footer credits and the report list with: ${CMD} --credits${opts.toArg}`);
}
return failed ? 1 : 0;
}
// ---------- credits ----------
function printCredits(opts) {
const list = readCredits(opts);
const uniq = list.filter((c, i) => list.findIndex((x) => x.ref === c.ref) === i && existsSync(resolve(c.file)));
if (!uniq.length) { console.log(`No pictures recorded in ${rel(creditsPath(opts))} yet. Download them with --get <ref> first.`); return 1; }
const html = (c) => {
const by = c.creator && !/^(unknown|anonymous)\b/i.test(c.creator) ? ` by ${esc(short(c.creator, 60))}` : '';
const date = /^(aic|met):/.test(c.ref) && c.date ? `, ${esc(short(c.date, 30))}` : '';
const lic = c.licenseUrl ? `<a href="${esc(c.licenseUrl)}">${esc(c.license)}</a>` : esc(c.license);
return `<a href="${esc(c.page)}">“${esc(short(c.title, 90))}”</a>${by}${date}, ${lic}, via ${esc(c.via)}`;
};
const media = new Set(uniq.map((c) => c.media || 'image'));
const label = media.size > 1 ? 'Pictures and footage' : media.has('video') ? 'Footage' : 'Pictures';
console.log('Footer credits (HTML). Full contrast, keep the links, body size (one step smaller only on a page with plenty of');
console.log('running text). Remove anything the page no longer uses, here and in credits.json.\n');
console.log(`<p class="credits">${label}: ${uniq.map(html).join('; ')}.</p>`);
console.log('\nFor your report:\n');
for (const c of uniq) console.log(`- ${c.file}${c.media === 'video' && c.duration ? ` (video, ${clock(c.duration)})` : ''}: ${c.credit}. ${c.page}${c.creditRequired ? '' : ' (no credit required)'}`);
return 0;
}
// ---------- run ----------
if (args.help) { console.log(USAGE); process.exit(0); }
if (args.credits) process.exit(printCredits(o));
if (args.get) process.exit(await getAll(String(args.get).split(',').map((s) => s.trim()).filter(Boolean), o));
const query = words.join(' ').trim();
if (!query) fail('Give a search query, for example: node assets.mjs "ghent postcard"');
process.exit(await search(query, o));
@@ -0,0 +1,584 @@
#!/usr/bin/env node
// tasteskill / draw — deal an art direction from the research library.
//
// The script chooses, not the model: a model asked to pick a direction returns the category
// default every time. The draw is random within what fits the brief, and it remembers recent
// draws so consecutive projects do not look alike.
//
// node draw.mjs --sector <sector> --brief "one line" [--sections hero,showcase,pricing,footer]
// [--tone quiet|bold|warm|technical|playful|luxury|editorial]
// [--have photography,product-photo,product-ui,illustration,video] assets you really have
// [--keep palette,type] the brand already fixes these; draw the rest
// [--avoid id,id] [--seed N] [--out DIR]
// [--options 3] show three dealt directions to choose from, write nothing
// [--direction <id>] deal the full sheet for the chosen direction
// node draw.mjs --show reprint the saved direction sheet
// node draw.mjs --redraw hero|type|palette|photos|scenes|section:<role>|direction re-deal one slot
// node draw.mjs --list sectors|directions|roles|tones|scenes
// node draw.mjs --site <id> print one studied reference site in full
// node draw.mjs --app --kind <kind> ... deal a style for an app screen instead (see lib/app-draw.mjs)
//
// Zero dependencies. Node 18+.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { homedir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const LIB = join(HERE, '..', 'library');
const ASSETS_PATH = join(HERE, 'assets.mjs').split('\\').join('/');
const ASSETS_CMD = `node ${/\s/.test(ASSETS_PATH) ? `"${ASSETS_PATH}"` : ASSETS_PATH}`;
const load = (name) => JSON.parse(readFileSync(join(LIB, name + '.json'), 'utf8'));
const argv = process.argv.slice(2);
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
const outDir = resolve(String(opt('out', '.taste-skill')));
const sheetPath = join(outDir, 'direction.json');
const SECTORS = ['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance', 'agency', 'studio-portfolio', 'personal-portfolio', 'ecommerce', 'physical-product', 'fashion', 'hospitality', 'restaurant', 'real-estate', 'architecture', 'event', 'health', 'education', 'nonprofit', 'culture', 'music', 'other'];
const TONES = ['quiet', 'bold', 'warm', 'technical', 'playful', 'luxury', 'editorial'];
const DEFAULT_SECTIONS = {
saas: ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
'dev-tool': ['hero', 'showcase', 'features', 'how-it-works', 'pricing', 'faq', 'cta', 'footer'],
'ai-product': ['hero', 'showcase', 'features', 'how-it-works', 'testimonial', 'pricing', 'cta', 'footer'],
'consumer-app': ['hero', 'showcase', 'features', 'testimonial', 'cta', 'footer'],
finance: ['hero', 'showcase', 'features', 'stats', 'testimonial', 'faq', 'cta', 'footer'],
agency: ['hero', 'intro', 'work', 'services', 'about', 'cta', 'footer'],
'studio-portfolio': ['hero', 'work', 'intro', 'services', 'about', 'cta', 'footer'],
'personal-portfolio': ['hero', 'work', 'about', 'cta', 'footer'],
ecommerce: ['hero', 'gallery', 'features', 'intro', 'testimonial', 'cta', 'footer'],
'physical-product': ['hero', 'showcase', 'features', 'gallery', 'intro', 'pricing', 'cta', 'footer'],
fashion: ['hero', 'gallery', 'intro', 'work', 'cta', 'footer'],
hospitality: ['hero', 'intro', 'gallery', 'features', 'list', 'contact', 'footer'],
restaurant: ['hero', 'intro', 'list', 'gallery', 'contact', 'footer'],
'real-estate': ['hero', 'intro', 'gallery', 'features', 'stats', 'contact', 'footer'],
architecture: ['hero', 'work', 'intro', 'about', 'contact', 'footer'],
event: ['hero', 'intro', 'list', 'features', 'pricing', 'faq', 'cta', 'footer'],
health: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'faq', 'cta', 'footer'],
education: ['hero', 'intro', 'features', 'how-it-works', 'testimonial', 'pricing', 'faq', 'cta', 'footer'],
nonprofit: ['hero', 'intro', 'stats', 'features', 'about', 'cta', 'footer'],
culture: ['hero', 'intro', 'gallery', 'list', 'about', 'cta', 'footer'],
music: ['hero', 'list', 'gallery', 'about', 'cta', 'footer'],
other: ['hero', 'intro', 'features', 'showcase', 'cta', 'footer'],
};
// requested role names → the role vocabulary used by library/sections.json
const ROLE_ALIAS = { features: 'features', feature: 'features', benefits: 'features', services: 'features', showcase: 'showcase', product: 'showcase', demo: 'showcase', 'how-it-works': 'how-it-works', how: 'how-it-works', process: 'how-it-works', steps: 'how-it-works', testimonial: 'testimonial', testimonials: 'testimonial', quotes: 'testimonial', proof: 'proof', logos: 'proof', press: 'proof', stats: 'stats', numbers: 'stats', pricing: 'pricing', plans: 'pricing', faq: 'faq', cta: 'cta', footer: 'footer', intro: 'intro', manifesto: 'intro', about: 'about', team: 'about', work: 'work', projects: 'work', index: 'work', 'case-studies': 'work', gallery: 'gallery', images: 'gallery', list: 'list', menu: 'list', schedule: 'list', lineup: 'list', 'line-up': 'list', speakers: 'list', agenda: 'list', programme: 'list', program: 'list', timetable: 'list', rooms: 'list', tickets: 'pricing', price: 'pricing', contact: 'contact', booking: 'contact', newsletter: 'contact', signup: 'contact' };
// App screens (dashboards, admin, settings) have their own library and sheet: scripts/lib/app-draw.mjs.
const savedKind = (() => { try { return JSON.parse(readFileSync(sheetPath, 'utf8')).kind; } catch { return null; } })();
if (opt('app') || ((opt('show') || opt('redraw')) && savedKind === 'app')) await import('./lib/app-draw.mjs');
if (opt('list')) {
const what = opt('list');
if (what === 'sectors') console.log(SECTORS.join('\n'));
else if (what === 'tones') console.log(TONES.join('\n'));
else if (what === 'roles') console.log([...new Set(Object.values(ROLE_ALIAS))].join('\n'));
else if (what === 'scenes') { const all = existsSync(join(LIB, 'scenes.json')) ? load('scenes') : []; for (const sc of all) console.log(`${sc.id.padEnd(34)} ${sc.name} — ${sc.family}; ${sc.summary.split(/(?<=\.)\s/)[0]}`); if (!all.length) console.log('No scene library yet.'); }
else for (const d of load('directions')) console.log(`${d.id.padEnd(30)} ${d.name} — ${d.sectors.join(', ')}`);
process.exit(0);
}
if (opt('photos')) {
const lib = load('photos'); const q = String(opt('photos'));
const line = (ph) => `- https://picsum.photos/id/${ph.id}/1600/1000 — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · photo: ${ph.by}`;
if (q === 'list' || q === 'true') { for (const [id, set] of Object.entries(lib.sets)) console.log(`${id.padEnd(22)} ${String(set.photos.length).padStart(3)} photos — ${set.note}`); }
else if (lib.sets[q]) { console.log(`# ${lib.sets[q].title} — ${lib.sets[q].note}`); for (const ph of lib.sets[q].photos) console.log(line(ph)); }
else { // free-text search across all descriptions
const words = q.toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 3);
const hits = Object.values(lib.sets).flatMap((s) => s.photos).filter((ph) => words.some((w) => (ph.alt + ' ' + (ph.colours || []).join(' ')).toLowerCase().includes(w)));
if (!hits.length) console.log('No photograph matches. `--photos list` shows the sets.'); else for (const ph of hits.slice(0, 24)) console.log(line(ph));
}
console.log('\nPath numbers are width/height; add ?grayscale to desaturate. Photographs are atmosphere, never a stand-in for a specific product or person.');
process.exit(0);
}
if (opt('site')) {
const s = load('sites').find((x) => x.id === opt('site') || x.name.toLowerCase() === String(opt('site')).toLowerCase());
if (!s) { console.error('No studied site with that id. Ids appear in the direction sheet under "Real references".'); process.exit(2); }
console.log(siteCard(s, true)); process.exit(0);
}
// ---------- randomness (seedable) ----------
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
const startSeed = seed;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
// prefer items not used recently, without ever emptying the pool
const fresh = (pool, recent) => { const f = pool.filter((x) => !recent.includes(x.id)); return f.length ? f : pool; };
// ---------- history (project + user level) ----------
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
const readHist = () => { for (const p of [userHist]) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch {} } return []; };
const writeHist = (entry) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(entry); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
// ---------- show / redraw ----------
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
if (opt('show')) {
if (!saved) { console.error('No saved direction. Run a draw first.'); process.exit(2); }
console.log(render(saved)); process.exit(0);
}
const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions'), scenes: existsSync(join(LIB, 'scenes.json')) ? load('scenes') : [] };
const hist = readHist();
const recent = (key, n) => hist.slice(0, n).map((h) => h[key]).filter(Boolean);
let sheet;
if (opt('alternatives')) {
// The other blueprints of one role that suit the saved direction: for when the dealt one cannot hold
// the real content (it assumes three plans and there is one; it needs five quotes and there are none).
if (!saved) { console.error('Run a draw first.'); process.exit(2); }
const role = ROLE_ALIAS[String(opt('alternatives'))] || String(opt('alternatives'));
const dir = L.directions.find((d) => d.id === saved.direction.id);
const current = saved.sections.find((s) => s.role === role)?.id;
const pool = sectionPool(dir, role, saved.input);
if (!pool.length) { console.error('No blueprints for role ' + role + '. Roles: --list roles'); process.exit(2); }
const line = (s) => `- \`${s.id}\`${s.id === current ? ' (dealt)' : ''} — **${s.name}** (${s.shape}). ${s.blueprint.split(/(?<=\.)\s/).slice(0, 2).join(' ')}\n`;
console.log(`# ${role}: blueprints that suit "${dir.name}"\n`);
for (const s of pool) console.log(line(s));
const software = imageryMode(saved.input) === 'interface';
const others = L.sections.filter((s) => s.role === role && !pool.includes(s) && (software || !softwareOnly(s)) && (s.needs || []).every((n) => saved.input.have.includes(n) || (n === 'product-ui' && software)));
if (others.length) {
console.log(`# Other ${role} blueprints (written for other directions; if one fits the content better, take its geometry and set it in this direction's type, colour and manner)\n`);
for (const s of others) console.log(line(s));
}
console.log(`Choose by what the content really is (how many plans, quotes, items exist), then: node draw.mjs --redraw section:${role}=<id> and say why in your report.`);
process.exit(0);
}
if (opt('redraw')) {
if (!saved) { console.error('Nothing to redraw. Run a draw first.'); process.exit(2); }
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1; // the sheet keeps its original seed; slot redraws are counted
const what = String(opt('redraw'));
const dir = L.directions.find((d) => d.id === sheet.direction.id);
if (what === 'direction') sheet = deal({ ...sheet.input, direction: null, avoid: [...(sheet.input.avoid || []), sheet.direction.id] }, sheet.redraws);
else if (what === 'hero') sheet.hero = dealHero(dir, sheet.input, [sheet.hero?.id]);
else if (what === 'type') sheet.type = dealType(dir, [sheet.type?.id]);
else if (what === 'palette') sheet.palette = dealPalette(dir, sheet.input, [sheet.palette?.id]);
else if (what === 'photos') sheet.photos = dealPhotos(dir, sheet.input);
else if (what === 'scenes') sheet.scenes = dealScenes(dir, sheet.input, (sheet.scenes || []).map((x) => x.id));
else if (what.startsWith('section:')) {
// section:<role> re-deals at random; section:<role>=<id> takes a named alternative (see --alternatives)
const [roleRaw, wanted] = what.slice(8).split('=');
const role = ROLE_ALIAS[roleRaw] || roleRaw;
const i = sheet.sections.findIndex((s) => s.role === role);
if (i === -1) { console.error('No section with role ' + role + ' in this sheet.'); process.exit(2); }
const used = sheet.sections.map((s) => s.id);
if (wanted) {
// a blueprint written for another role may be borrowed when its geometry fits the content better
const chosen = L.sections.find((s) => s.id === wanted);
if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives <role>.`); process.exit(2); }
sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` };
} else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i];
} else { console.error('Unknown slot. Use hero, type, palette, photos, scenes, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
} else {
const sector = String(opt('sector', ''));
if (!SECTORS.includes(sector)) {
console.error(`--sector is required. One of:\n ${SECTORS.join(', ')}\nPick the closest; use "other" only when nothing fits.`);
process.exit(2);
}
const requested = list(opt('sections'));
const input = {
brief: typeof opt('brief') === 'string' ? opt('brief') : '', sector,
tone: TONES.includes(opt('tone')) ? opt('tone') : null,
have: list(opt('have')), keep: list(opt('keep')), avoid: list(opt('avoid')),
sections: (requested.length ? requested : DEFAULT_SECTIONS[sector]).map((r) => ROLE_ALIAS[r] || r).filter((r) => r !== 'hero'),
direction: typeof opt('direction') === 'string' ? opt('direction') : null,
};
if (input.direction && !L.directions.some((d) => d.id === input.direction)) { console.error(`No direction with id ${input.direction}. List them with --list directions.`); process.exit(2); }
if (opt('options')) {
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3));
const options = dealOptions(input, n);
console.log(`# ${n} directions dealt for this brief\n`);
console.log('Choose the one whose way of composing can best carry an idea from your inventory (SKILL.md steps 1 and 4), not the');
console.log('most familiar one. Then deal it in full with the same command plus `--direction <id>` (and without --options).\n');
for (const d of options) {
console.log(`## \`${d.id}\` — ${d.name}`);
console.log(`Idea engine: ${d.idea.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
console.log(`Composition: ${d.composition.split(/(?<=\.)\s/).slice(0, 2).join(' ')}`);
if (d.needsPictures) console.log('Needs: many pictures. Without photographs of its own, each is a real picture of the subject\'s world found with scripts/assets.mjs, or a drawing you make.');
console.log('');
}
process.exit(0);
}
sheet = deal(input, 0);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector, scenes: (sheet.scenes || []).map((x) => x.id) });
console.log(render(sheet));
// ---------- dealing ----------
// What kind of pictures this build can honestly have. It decides which blueprints are dealt and how the
// sheet tells the agent to fill picture slots.
function imageryMode(input) {
if (input.have.includes('product-photo') || input.have.includes('photography')) return 'own';
if (['saas', 'dev-tool', 'ai-product', 'consumer-app', 'finance'].includes(input.sector)) return 'interface';
if (['physical-product', 'ecommerce'].includes(input.sector)) return 'no-product-photo';
return 'atmosphere';
}
function pictureLed(x) { return (x.tags || []).includes('photo-led') || (x.tags || []).includes('object-led'); }
function worksWithoutPictures(x) { return !pictureLed(x) || (x.tags || []).some((t) => ['ui-led', 'type-led', 'colour-field'].includes(t)); }
// The directions that suit this brief and the assets it really has.
function directionPool(input) {
const has = (need) => input.have.includes(need) || need === 'photography'; // the photo library covers photography
let pool = L.directions.filter((d) => (d.sectors.includes(input.sector) || d.sectors.includes('*')) && !input.avoid.includes(d.id));
const feasible = pool.filter((d) => (d.needs || []).every(has));
if (feasible.length) pool = feasible;
// directions built on photographs of the subject are not dealt when the subject cannot be photographed honestly
const mode = imageryMode(input);
if (mode === 'interface' || mode === 'no-product-photo') { const plain = pool.filter((d) => !d.needsPictures); if (plain.length >= 3) pool = plain; }
if (input.tone) { const toned = pool.filter((d) => (d.tones || []).includes(input.tone)); if (toned.length >= 2) pool = toned; }
if (!pool.length) pool = L.directions.filter((d) => !input.avoid.includes(d.id));
return pool;
}
// Three directions dealt at random from the pool, for the builder to choose the one that carries its idea.
function dealOptions(input, n) {
const pool = directionPool(input); const out = [];
let rest = fresh(pool, recent('direction', Math.min(5, pool.length - n)));
while (out.length < n && pool.length > out.length) {
if (!rest.length) rest = pool.filter((d) => !out.includes(d));
const d = pick(rest); out.push(d); rest = rest.filter((x) => x !== d);
}
return out;
}
function deal(input, redraws) {
const pool = directionPool(input);
const forced = input.direction ? L.directions.find((d) => d.id === input.direction) : null;
const dir = forced || pick(fresh(pool, recent('direction', Math.min(5, pool.length - 1))));
const s = { version: 1, at: new Date().toISOString(), seed: startSeed, redraws, input, imagery: imageryMode(input), allow: dir.allow || [], direction: dir };
s.hero = dealHero(dir, input, []);
s.type = input.keep.includes('type') ? null : dealType(dir, []);
s.palette = input.keep.includes('palette') ? null : dealPalette(dir, input, []);
s.photos = dealPhotos(dir, input);
s.motion = L.motions.find((m) => m.id === (s.hero?.motion || dir.motion)) || L.motions.find((m) => m.id === dir.motion) || null;
s.sections = [];
for (const role of input.sections) {
const sec = dealSection(dir, role, s.sections.map((x) => x.id), s.sections.at(-1)?.shape, null, input);
if (sec) s.sections.push(sec); else s.sections.push({ id: null, role, shape: null, name: role, blueprint: null });
}
s.references = dealReferences(dir, input);
s.scenes = dealScenes(dir, input, []);
return s;
}
// The page's big scroll moments (references/scenes.md): two or three tested scenes from different
// families, each placed on a section of the argument it can carry. Expressive sectors and tones get three.
function dealScenes(dir, input, exclude) {
if (!L.scenes.length) return [];
const has = (need) => input.have.includes(need) || need === 'photography';
const fits = (sc) => !exclude.includes(sc.id) && (sc.sectors || ['*']).some((x) => x === '*' || x === input.sector) && (sc.needs || []).every(has);
let pool = L.scenes.filter(fits);
if (pool.length < 2) pool = L.scenes.filter((sc) => !exclude.includes(sc.id) && (sc.needs || []).every(has));
if (!pool.length) return [];
const loud = ['agency', 'studio-portfolio', 'personal-portfolio', 'fashion', 'culture', 'music', 'event', 'architecture', 'physical-product', 'real-estate', 'hospitality'].includes(input.sector) || ['bold', 'playful', 'luxury', 'editorial'].includes(input.tone);
const quiet = ['quiet', 'technical'].includes(input.tone);
const n = Math.min(pool.length, quiet ? 2 : loud ? 3 : 2);
const recentIds = hist.slice(0, 4).flatMap((h) => h.scenes || []);
let rest = fresh(pool, recentIds);
const out = []; const families = new Set(); const usedRoles = new Set();
while (out.length < n) {
let cands = rest.filter((sc) => !families.has(sc.family) && !(sc.position === 'opening' && out.some((o) => o.position === 'opening')) && !(sc.position === 'closing' && out.some((o) => o.position === 'closing')));
if (!cands.length) cands = pool.filter((sc) => !out.includes(sc) && !families.has(sc.family));
if (!cands.length) break;
const sc = pick(cands); out.push(sc); families.add(sc.family); rest = rest.filter((x) => x !== sc);
}
// the section each scene carries: the first role of the argument it suits that no other scene has taken
const roles = ['hero', ...input.sections];
return out.map((sc) => {
const role = sc.position === 'opening' ? 'hero' : roles.find((r) => (sc.roles || []).includes(r) && !usedRoles.has(r) && r !== 'hero') || null;
if (role) usedRoles.add(role);
return { ...sc, carries: role };
}).sort((a, b) => (a.carries ? roles.indexOf(a.carries) : 99) - (b.carries ? roles.indexOf(b.carries) : 99));
}
function dealHero(dir, input, exclude) {
// the direction's own list, and any hero written for this direction since (the October 2026 study added some)
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id) || (h.directions || []).includes(dir.id));
if (!pool.length) pool = L.heroes;
if (imageryMode(input) !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((h) => !softwareOnly(h)); if (plain.length) pool = plain; }
const ok = pool.filter((h) => !exclude.includes(h.id) && (h.needs || []).every((n) => n === 'photography' || input.have.includes(n)));
return pick(fresh(ok.length ? ok : pool.filter((h) => !exclude.includes(h.id)).length ? pool.filter((h) => !exclude.includes(h.id)) : pool, recent('hero', 3)));
}
function dealType(dir, exclude) {
let pool = L.type.filter((t) => (dir.type || []).includes(t.id) || (dir.typeClasses || []).includes(t.class));
if (!pool.length) pool = L.type;
const p = pool.filter((t) => !exclude.includes(t.id));
return pick(fresh(p.length ? p : pool, recent('type', Math.min(6, pool.length - 1))));
}
function dealPalette(dir, input, exclude) {
let pool = L.palettes.filter((p) => (dir.palettes || []).includes(p.id) || (p.tags || []).some((t) => (dir.paletteTags || []).includes(t)));
if (!pool.length) pool = L.palettes;
const p = pool.filter((x) => !exclude.includes(x.id));
return pick(fresh(p.length ? p : pool, recent('palette', Math.min(6, pool.length - 1))));
}
function dealPhotos(dir, input) {
if (dir.photos === false) return null; // image-free directions
const words = briefWords(input.brief);
const mode = imageryMode(input);
const SOFT_SETS = ['architecture', 'fog-and-quiet', 'sky-and-space', 'texture', 'water'];
// subject first (sector + words of the brief), then the direction's preferred tone
const scored = Object.entries(L.photos.sets).map(([id, set]) => {
let score = (set.sectors || []).includes(input.sector) ? 3 : 0;
if ((set.primary || []).includes(input.sector)) score += 5;
// rooms and tables would be taken for this very house: for places to stay, deal the surroundings instead
if (mode === 'atmosphere' && ['hospitality', 'real-estate'].includes(input.sector) && id === 'interior-and-table') score -= 6;
score += words.filter((w) => (set.keywords || []).some((k) => k === w || (k.length >= 5 && w.startsWith(k)) || (w.length >= 5 && k.startsWith(w)))).length * 4;
score += set.photos.filter((ph) => words.some((w) => ph.alt.toLowerCase().includes(w))).length;
if (dir.photoTone) score += set.photos.filter((ph) => ph.tone === dir.photoTone).length / set.photos.length;
return { id, set, score };
}).filter((x) => x.score > 0 && x.set.photos.length >= 5 && (mode !== 'interface' || SOFT_SETS.includes(x.id))).sort((a, b) => b.score - a.score);
if (!scored.length) return null;
// one set, so the photographs share a subject and a light; choose among the best-fitting few
const top = scored.filter((x) => x.score >= scored[0].score * 0.6).slice(0, 4);
const chosen = pick(top);
let pool = chosen.set.photos;
if (dir.photoTone) { const toned = pool.filter((ph) => ph.tone === dir.photoTone || ph.tone === 'mid'); if (toned.length >= 6) pool = toned; }
// Without product photographs, a picture of the same kind of thing would pass for the product: deal
// those last and mark them. Otherwise a picture that matches the brief is the better atmosphere.
const things = words.filter((w) => w.length >= 5);
const matches = (ph) => things.some((w) => ph.alt.toLowerCase().includes(w));
const bonus = mode === 'no-product-photo' ? -2 : 2;
const ranked = shuffle(pool).sort((a, b) => (b.quality + (matches(b) ? bonus : 0)) - (a.quality + (matches(a) ? bonus : 0)))
.map((ph) => (mode === 'no-product-photo' && matches(ph) ? { ...ph, lookalike: true } : ph));
return { set: chosen.id, title: chosen.set.title, note: chosen.set.note, treatment: dir.photoTreatment || null, others: scored.filter((x) => x.id !== chosen.id).slice(0, 4).map((x) => x.id), photos: ranked.slice(0, mode === 'interface' ? 4 : 8) };
}
function briefStop() {
return new Set(['with', 'that', 'this', 'from', 'page', 'site', 'website', 'landing', 'their', 'they', 'have', 'will', 'your', 'about', 'into', 'make', 'build', 'create', 'design', 'small', 'based', 'called', 'named', 'which', 'where', 'what', 'need', 'want', 'like', 'also', 'some', 'very', 'more', 'most', 'just', 'than', 'then', 'them', 'and', 'the', 'for', 'one', 'hand', 'handmade', 'made', 'batch', 'ship', 'ships', 'week', 'within', 'across', 'euro', 'euros', 'cost', 'price', 'open', 'only', 'each', 'every', 'there', 'images', 'photos', 'photograph', 'static', 'html']);
}
function briefWords(brief) {
const stop = briefStop();
return [...new Set(String(brief || '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4 && !stop.has(w)).map((w) => w.replace(/(ies|es|s)$/, '')))].filter((w) => w.length >= 4);
}
// First queries for scripts/assets.mjs, built from the brief: the place in old postcards and maps, then the
// subject itself. In testing "<place> postcard" and "<place> old map" found archival material far more
// reliably than a place and a year. A start only: the better queries come from the builder's inventory.
function assetQueries(input) {
const brief = String(input.brief || '');
const notPlace = /^(?:january|february|march|april|may|june|july|august|september|october|november|december|monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?$/i;
const placeRe = /(?:^|[\s,(])(?:in|at|near|from|on|above|below|beside|outside|off|over)\s+(\p{Lu}[\p{L}'’-]*(?:\s+(?:\p{Lu}[\p{L}'’-]*|de|da|do|dos|das|di|del|van|von|der|den))*)/gu;
const place = [...brief.matchAll(placeRe)].map((m) => m[1].trim()).find((p) => !notPlace.test(p.split(/\s+/)[0]));
const stop = briefStop();
const generic = new Set(['seat', 'seats', 'room', 'rooms', 'minute', 'minutes', 'hour', 'hours', 'night', 'nights', 'year', 'years', 'people', 'family', 'local', 'short', 'best', 'making', 'selling', 'serving', 'offering', 'business', 'company', 'brand', 'shop', 'store', 'studio', 'service', 'services', 'dinner', 'lunch', 'breakfast', 'included', 'online', 'modern']);
// the subject: the last plain words of the first noun phrase ("a 38-seat Italian restaurant in ..." gives "restaurant")
const words = brief.split(/\s+/).filter(Boolean); const phrase = [];
for (let k = words.findIndex((w) => /^an?$/i.test(w)) + 1; k < words.length; k++) {
if (/^(?:in|at|on|for|from|with|near|above|below|by|that|which|who|of|and|to)$/i.test(words[k])) break;
phrase.push(words[k].replace(/[,.;:()!?]+$/, ''));
if (/[,.;:()!?]$/.test(words[k])) break;
}
const plain = phrase.filter((w) => /^\p{Ll}{3,}$/u.test(w) && !stop.has(w) && !generic.has(w));
const subject = plain.slice(-2).join(' ') || (input.sector !== 'other' ? input.sector.replace(/-/g, ' ') : null);
const out = [];
if (place) out.push(`${place.toLowerCase()} postcard`, `${place.toLowerCase()} old map`);
if (subject) out.push(subject.toLowerCase());
return [...new Set(out)];
}
// Blueprints written for showing software (an interface fragment is their picture).
function softwareOnly(x) { return (x.directions || [])[0] === 'product-performs' || (x.tags || []).includes('ui-led') || /data-ui|interface fragment/i.test(x.blueprint || ''); }
// Blueprints were measured on real sites and carry that site's sizes, greys, faces, colours and corners.
// This lists, for one blueprint, exactly where it disagrees with the house rules and the dealt system,
// so the builder does not have to work it out section by section.
function adjustments(bp, s) {
const text = [bp.blueprint, bp.mobile, bp.headline].filter(Boolean).join(' ');
const notes = [];
const sizes = (re) => [...new Set([...text.matchAll(re)].map((m) => m[1] + 'px'))];
const notSpacing = String.raw`(?!\s*(?:gaps?|gutters?|margins?|padding|radius|corners?|apart|from|inset|offset|high|tall|wide|thick|square|borders?|stroke|rules?|line\b|dots?|circles?|bars?|of|between|beneath|below|above|under|down|up|across|deep|left|right|in\b|to\b|and\b|each))`;
const tiny = sizes(new RegExp(String.raw`(?<!(?:radius|radii|gap|margin|padding|inset|offset)[:\s]{0,3}(?:of\s)?)\b(9|10|11|12)(?:\.\d)?px\b` + notSpacing, 'g'));
if (tiny.length) notes.push(`It sets text at ${tiny.join(', ')}: use your small step (13px or more) instead.`);
const running = sizes(/\b(14|15)px\s*(?:paragraphs?|body|text|descriptions?|copy|sentences?|answers?)/gi);
if (running.length) notes.push(`It sets running text at ${running.join(', ')}: paragraphs use your body step (16px or more).`);
if (/\d{1,2}% (?:opacity|black|ink|grey|gray|white)|opacity:? ?0?\.\d|\b(?:rest|others?|remaining)\b[^.;]{0,25}\b(?:at|to) \d{1,2}%(?! (?:of|width|wide))|full (?:ink|white|strength)[^.;]{0,60}\bat \d{1,2}%(?! (?:of|width|wide))/i.test(text)) notes.push('It fades text: anything a visitor reads, inactive items of a list included, is `--ink` or `--muted` at full strength (contrast 4.5:1 or more). The active item is told apart by `--ink` against `--muted`, weight or the accent, not by fading the rest.');
if (/\d+px (?:corner )?radius|radius (?:of )?\d+px|\brounded\b|\bpill/i.test(text)) notes.push('It names its own corners: use the one radius of this page (square unless the direction says otherwise) and one button shape.');
if (/\b(?:serif|sans-serif|condensed|grotesk|italic|monospace|mono|all caps|uppercase|capitals|script)\b/i.test(text)) notes.push(s.type ? `It names a kind of typeface: use the dealt faces (display ${s.type.display.family}, text ${s.type.text.family}) and their settings; capitals only where the type system sets them.` : 'It names a kind of typeface: use the brand faces.');
const colours = [...new Set([...text.matchAll(/\b(green|red|blue|yellow|orange|pink|purple|teal|navy|cream|beige|brown|grey|gray|white|black)\b(?![- ](?:space|studio|background disappears))/gi)].map((m) => m[1].toLowerCase()))];
if (colours.length) notes.push(`It names colours (${colours.join(', ')}): map them onto this palette's roles (ground \`--bg\`, panels \`--surface\` and the fields, text \`--ink\`/\`--muted\`, the accent \`--accent\` on the primary action only).`);
if (/(?<!\b(?:no|without|never|not|nor) (?:a |any )?)\b(?:rule|rules|hairline|border|borders|divider|underline|thin line|1px line)\b/i.test(text)) notes.push('It draws lines or borders: keep them only inside a table, a form or a `data-ui` fragment; elsewhere separate with space or a change of ground.');
if (/shadow|glow/i.test(text)) notes.push('It mentions a shadow: only on something open above the page (a menu, a dialog), never on a resting element.');
const bigSentence = [...text.matchAll(/\b(?:sentence|statement|paragraph|quotation|quote)\b[^.;]{0,40}?\b(?:at|to)\s+(?:clamp\(\s*\d+(?:\.\d+)?px,\s*)?(\d+(?:\.\d+)?)(px|vw)/gi)].some((m) => (m[2] === 'px' && +m[1] > 36) || (m[2] === 'vw' && +m[1] > 2.6));
if (bigSentence) notes.push('It sets a sentence large over several lines: jurors read that as a long headline. Either cut it to a two-line headline at display size, or keep it a statement at 36px or less on three lines (24px, four lines on a phone).');
if (/(?:sticky|pinned|pins?\b|stays? (?:fixed|put|in place))/i.test(text) && /\b(?:three|four|five|six) (?:to (?:four|five|six) )?(?:viewports|screens)\b|\b[3-6]00svh\b/i.test(text)) notes.push('It pins something for three screens or more: hold a pin for two or three screens at most, give every screen under it its own content, and let nothing pass over the pinned words (the inspector checks all three).');
if (/mix-blend-mode:\s*(?:difference|exclusion)/i.test(text)) notes.push('It blends text with `difference`: over anything but black, white or grey that gives random hues. Use a solid ink chosen for each ground instead.');
if (/tops? of the (?:letters|word|name|wordmark)|(?:letters|wordmark|name|word)\b[^.;]{0,30}\b(?:sliced|cut|cropped|clipped)[^.;]{0,20}\btop/i.test(text)) notes.push('It crops letters along their top: the inspector fails that (jurors read it as broken). Crop at the bottom edge or a side instead.');
if (bp.archetype && /single action[^.;]{0,40}?\b(?:text link|underlined (?:text )?link)/i.test(text)) notes.push('Its action is a text link: the primary action on the first screen is a real button (filled or solid-bordered, 44px or taller), placed where the blueprint puts the link. A second action may stay a link.');
if (s.type?.class === 'condensed-heavy' && /\d+(?:\.\d+)?vw/.test(text)) notes.push('Its vw sizes assume a face of normal width: with this condensed display face, size the words to fill the measure the blueprint gives them instead of copying the vw value.');
if (/\b0[1-9]\b(?![:.,]\d)|numbered|index number/i.test(text)) notes.push('It numbers things 01, 02: drop the numbers unless they are real data (a price, a date, a count).');
return notes;
}
// Every blueprint of a role that suits this direction and this build's real assets.
function sectionPool(dir, role, input) {
let pool = L.sections.filter((s) => s.role === role);
if (!pool.length) return [];
const mode = imageryMode(input);
const feasible = pool.filter((s) => (s.needs || []).every((n) => n === 'photography' || input.have.includes(n) || (n === 'product-ui' && mode === 'interface')));
if (feasible.length) pool = feasible;
if (mode !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((s) => !softwareOnly(s)); if (plain.length) pool = plain; }
if (mode === 'interface' || mode === 'no-product-photo') {
// prefer compositions that do not hang on a photograph of the thing
const honest = pool.filter((s) => (mode === 'interface' ? worksWithoutPictures(s) : !(s.tags || []).includes('object-led') || worksWithoutPictures(s)));
if (honest.length >= 2) pool = honest;
}
const byDirection = pool.filter((s) => (s.directions || []).includes(dir.id));
if (byDirection.length) return byDirection;
const byTag = pool.filter((s) => (s.tags || []).some((t) => (dir.sectionTags || []).includes(t)));
return byTag.length ? byTag : pool;
}
function dealSection(dir, role, usedIds, prevShape, nextShape, input) {
let pool = sectionPool(dir, role, input);
if (!pool.length) return null;
if (imageryMode(input) === 'interface') { const ui = pool.filter((s) => (s.tags || []).includes('ui-led')); if (ui.length >= 2 && rnd() < 0.6) pool = ui; }
const unused = pool.filter((s) => !usedIds.includes(s.id)); if (unused.length) pool = unused;
const varied = pool.filter((s) => s.shape !== prevShape && s.shape !== nextShape); if (varied.length) pool = varied;
return pick(pool);
}
function dealReferences(dir, input) {
const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean);
// the October 2026 panel scored on a lower scale (its top band starts at 7.5, the first panel's at about 7.6)
const good = (s, a, b) => s.score >= (s.panel === '2026-10' ? b : a);
const sameKind = L.sites.filter((s) => s.kind === input.sector && good(s, 7.2, 7.3) && !named.includes(s));
const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)];
return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2);
}
// ---------- rendering ----------
function siteCard(s, full) {
const lines = [`- **${s.name}** (${s.source}, studied score ${s.score}) \`${s.id}\``, ` Idea: ${s.idea}`, ` Hero: ${s.hero}`];
if (full) {
lines.push(` Type: ${s.type}`, ` Colour: ${s.colour}`, ` Imagery: ${s.imagery}`, ` Layout: ${s.layout}`);
if (s.media) lines.push(` Pictures and footage: ${s.media}`);
if (s.motion) lines.push(` Motion: ${s.motion}`);
for (const sec of s.sections || []) lines.push(` Section (${sec.role}): ${sec.description}`);
for (const t of s.steal || []) lines.push(` Worth taking: ${t}`);
lines.push(` URL: ${s.url}`);
} else if (s.steal?.[0]) lines.push(` Worth taking: ${s.steal[0]}`);
return lines.join('\n');
}
function render(s) {
const d = s.direction; const o = [];
const p = (x = '') => o.push(x);
p(`# DIRECTION SHEET — ${d.name}`);
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · sector ${s.input.sector}${s.input.tone ? ' · tone ' + s.input.tone : ''}`);
if (s.input.brief) p(`Brief: ${s.input.brief}`);
p();
p('| | |'); p('| --- | --- |');
p(`| Direction | ${d.name} |`);
p(`| Hero | ${s.hero.name} |`);
p(`| Type | ${s.type ? s.type.name : 'kept from the brand'} |`);
p(`| Colour | ${s.palette ? s.palette.name : 'kept from the brand'} |`);
p(`| Sections | ${s.sections.map((x) => x.role + (x.name && x.name !== x.role ? ' (' + x.name + ')' : '')).join(' → ')} |`);
p(`| Motion | ${s.motion ? s.motion.name : '—'} |`);
p(`| Photographs | ${s.photos ? 'the "' + (s.photos.title || s.photos.set) + '" set, as atmosphere' : 'none'} |`);
p();
p('This is the direction for this build. Adopt it. Do not blend it with the layout you would have made anyway,');
p('and do not redraw because it is unfamiliar: unfamiliar is the point. Redraw a slot only when it conflicts');
p('with a stated brand constraint or needs an asset that does not exist, and say why in your report.');
p();
p('The parts of this sheet were written separately and will disagree in small ways. Settle every disagreement in this order:');
p('**house rules** (including type no smaller than 13px and body text of 16px or more) → **the direction** (the idea, the composition habits, where the');
p('one accent goes) → **the type system and the palette** (which face does which job, the five size steps, which colour is the ground and where the');
p('accent goes) → **the hero blueprint** → **the section blueprints**. Under each blueprint below, "Adjusted for this page" lists where it disagrees.');
p('In practice: take geometry from blueprints (columns, proportions, what sits where) and take everything else from above them. Map every size a');
p('blueprint names onto one of your five type steps instead of adding a sixth. Use one button shape, one corner radius and one pair of page margins on');
p('the whole page, whatever individual blueprints say. If a blueprint names a kind of typeface (a light serif, a heavy grotesk), use the dealt face.');
p();
p('## 1. The idea'); p(d.idea); p();
p('Before anything else, write one sentence in your notes: "On this page, <the subject\'s own material> becomes <the device>."');
p('Fill the first blank from your inventory of what only this client has (its name and what it means, its street, its numbers, its');
p('constraint), then put the nearest competitor in the sentence: if it still works, the idea is the category\'s, not the client\'s.');
p('Then name the one live thing the visitor can do on the page (SKILL.md step 4).'); p();
p('## 2. Composition'); p(d.composition); p();
if (s.type) {
const t = s.type;
p(`## 3. Type — ${t.name}`); p(t.note);
p('```html'); for (const l of [...new Set([t.display.css, t.text.css].filter(Boolean))]) p(l); p('```');
p(`- Display: **${t.display.family}** — ${t.display.settings}`);
p(`- Text: **${t.text.family}** — ${t.text.settings}`);
if (t.scale) { p('```css'); p(':root {'); for (const [k, v] of Object.entries(t.scale)) p(` --${k}: ${v};`); p('}'); p('```'); }
if (d.typography) p(d.typography);
p('The direction decides sizes and how many of them; the type system decides how this face is set. Where the two give different tracking or leading, use the values of the type system: they are tuned to this face.');
p('The display size itself comes from the hero blueprint below.');
p();
} else { p('## 3. Type'); p('Kept from the brand. ' + (d.typography || '')); p(); }
if (s.palette) {
const c = s.palette;
p(`## 4. Colour — ${c.name}`); p('```css'); p(':root {');
for (const k of ['bg', 'surface', 'ink', 'muted', 'accent', 'accentInk']) if (c[k]) p(` --${k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())}: ${c[k]};`);
(c.fields || []).forEach((f, i) => p(` --field-${i + 1}: ${f.bg};${f.ink ? ` --field-${i + 1}-ink: ${f.ink};` : ''}`));
p('}'); p('```'); p(c.note); if (d.colour) p(d.colour); p();
} else { p('## 4. Colour'); p('Kept from the brand. ' + (d.colour || '')); p(); }
p('## 5. Imagery');
// where to look for real pictures of the subject's world, with first queries built from the brief
const qs = assetQueries(s.input);
const find = qs.length
? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)`
: `\`${ASSETS_CMD} "<a place, material or object from your inventory>"\``;
const MODE = {
interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected) by the app-screen rules (references/app-screens.md: deal its style once with `node <skill>/scripts/draw.mjs --app --kind <kind> --keep palette,type --out .taste-skill/app`, and build it with assets/app.css and assets/charts.js), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
'no-product-photo': '**There are no photographs of the product and none can be faked.** Wherever this sheet calls for a picture of the object, draw the object: its pieces as flat shapes in inline SVG, large, in one graphic language and in the colours that make it worth buying (a glaze, a finish, a material), never grey placeholders (references/imagery.md §8). **Around the object, use real pictures of its world** from the open collections: its material up close, its town in old postcards, its craft in engravings, a museum piece of the same kind, never one a visitor could take for the product itself (references/imagery.md §3). Start with ' + find + '. Download what you use with `--get` and credit every one on the page (`--credits` prints the footer line). Type at scale and full colour fields carry the rest; the dealt photographs only where they plainly do not pretend to be the product. In your report, say that product photography is needed and which drawings it will replace, and list every found picture with its source and licence.',
atmosphere: '**The project has no photographs of its own yet.** First choice: **real pictures of the subject\'s world from the open collections**: its city and street in old postcards and photographs, an old map of the neighbourhood, its materials up close, its trade in engravings, museum objects of its kind (references/imagery.md §3). Start with ' + find + '. Download what you use into the project with `--get` and credit every one on the page (`--credits` prints the footer line). They show the world around the client, never the client\'s own room, dish, product, person or team. Second, for what no honest picture exists of: draw the subject\'s own things in code, large, in one flat graphic language and the palette\'s real colours (its tiles, its windows, its objects; references/imagery.md §8). Third: the dealt photographs, only where an honestly generic picture fits (the sea, the weather, a material), never one a visitor could take for the thing itself and never an unrelated scene. One treatment for all of them. If this direction is built on photographs, plan the set before building (four to six pictures of one family) and reuse them at different crops and scales instead of finding or drawing a new picture for every slot; keep drawings as `<symbol>` + `<use>` or generate them with a small script so the page stays light. List every found picture (with its source and licence), every dealt photograph and every drawing that stands in for photography in your report.',
own: '**Use the images that belong to the project in every picture slot.** For slots they cannot fill, real pictures of the subject\'s world from the open collections come next (start with ' + find + '; credit them on the page), then the dealt photographs.',
};
p(MODE[s.imagery || 'atmosphere']); p(); p(d.imagery);
p(); p('**Floor (the inspector checks it):** at least four real pictures on the page, usually six to twelve; SVG drawings do not count. One moving picture where an honest clip exists: `node <skill>/scripts/assets.mjs --video "<query>"`, used as a muted loop with a poster frame. Whatever this direction says about pictures, it is carried out with real ones.');
if (s.photos) {
p();
p(s.imagery === 'interface' ? `Optional atmosphere, two at most, from the "${s.photos.title || s.photos.set}" set (${s.photos.note}). The page does not need them.` : `Photographs dealt for this build, from the "${s.photos.title || s.photos.set}" set (${s.photos.note})`);
p('Order of preference for imagery: (1) the project\'s own photographs and product shots; (2) images you generate, if an image tool is');
p('available; (3) real pictures of the subject\'s world found in the open collections (assets.mjs, downloaded and credited on the page);');
p('(4) drawings of the subject in code; (5) these. They are real photographs (Unsplash licence, served by picsum.photos) of places, light and');
p('texture. They are atmosphere, never a stand-in for a specific product, dish, person or interface. List the ones you use in your report so');
p('they can be replaced.');
if (s.photos.treatment) p(`Treatment: ${s.photos.treatment}`);
for (const ph of s.photos.photos) p(`- \`https://picsum.photos/id/${ph.id}/1600/1000\` — ${ph.alt} · ${ph.tone} tone · ${(ph.colours || []).join(', ')}${ph.mono ? ' · black-and-white' : ''}${ph.lookalike ? ' · **could be taken for the product: never use it as the product**' : ''}${ph.calm && ph.calm !== 'none' ? ` · calm area: ${ph.calm}` : ''} · native ${ph.ratio >= 1.15 ? 'landscape' : ph.ratio <= 0.87 ? 'portrait' : 'square'} · photo: ${ph.by}`);
p('The last two path numbers are width and height: request the exact crop you render (`/1200/1500` for a 4:5 portrait, `/2400/1350` for a hero) and');
p('add `?grayscale` to desaturate. ' + (s.imagery === 'interface' ? 'Use at most two.' : 'Use three to six of them, not all eight.') + ' Always set `width`, `height`, `alt` and `loading="lazy"` (not on the hero image).');
if (s.photos.others?.length) p(`Other sets that fit this brief: ${s.photos.others.join(', ')} — \`node draw.mjs --photos <set>\` prints one in full; \`--photos list\` shows all sets. Taking photographs from another set is your call and is not a redraw; keep them to one light and one treatment.`);
} else if (d.photos === false) {
p(); p('This direction does not lead with photographs; it still carries the picture floor below, with real pictures placed where the direction allows them.');
}
p();
const adjust = (bp) => { const n = adjustments(bp, s); if (n.length) { p(); p('Adjusted for this page:'); for (const x of n) p(`- ${x}`); } };
p(`## 6. Hero — ${s.hero.name}`); p(s.hero.blueprint); p(); p(`On a phone: ${s.hero.mobile}`); if (s.hero.headline) p(`Headline: ${s.hero.headline}`); adjust(s.hero); p();
p('## 7. Sections, in order');
p('Each is a composition to rebuild with this project\'s content. Keep the geometry; change the subject. Re-order or drop a');
p('section when the argument of the page needs it (see references/copy.md), never to fall back to a card grid.');
s.sections.forEach((sec, i) => {
p();
if (!sec.blueprint) { p(`### ${i + 1}. ${sec.role}`); p('No blueprint in the library for this role. Compose it from the direction above, using a layout shape not used by its neighbours.'); return; }
p(`### ${i + 1}. ${sec.role} — ${sec.name} (${sec.shape})`); p(sec.blueprint); if (sec.mobile) p(`On a phone: ${sec.mobile}`); adjust(sec);
});
p();
if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); }
if (s.scenes?.length) {
p('## 9. Scroll scenes');
p(`The page's big moments: ${s.scenes.length} scroll scenes, tested and ready to fill. They are part of the composition, so build them in step 6 with the section they carry, not as a motion afterthought. Each runs on the shared stage (assets/scenes/stage.js and stage.css, with GSAP and Lenis from the pinned CDN lines in stage.js) and ships its own finished composition for reduced motion and for phones that rest. How to put one on a page, fill it and check it: references/scenes.md. Check them with \`node <skill>/scripts/scenes.mjs <page>\` and look at every sheet.`);
for (const sc of s.scenes) {
p();
p(`### ${sc.name} (\`${sc.id}\`)${sc.carries ? ` — carries the ${sc.carries === 'hero' ? 'first screen' : sc.carries + ' section'}` : ' — a transition between two sections'}`);
p(sc.summary);
if (sc.place) p(`Where: ${sc.place}`);
if (sc.content) p(`Content: ${sc.content}`);
if (sc.options) p(`Options (data-options): ${sc.options}`);
p(`Files: \`<skill>/assets/scenes/${sc.id}.js\` and \`${sc.id}.css\`; a working example: \`<skill>/assets/scenes/demo/${sc.id}.html\`.${sc.phone ? ' On a phone: ' + sc.phone : ''}`);
}
p();
p('Leave a quiet section between two scenes so each one lands. A scene whose content you cannot fill honestly is redrawn (`--redraw scenes`), never faked.');
p();
}
if (d.richness) { p('## 10. Where the richness comes from'); p(d.richness); p(); }
if (d.pitfalls) { p('## 11. How this direction goes wrong'); p(d.pitfalls); p(); }
if (s.references?.length) {
p('## 12. Real references');
p('Studied sites that work this way. Take the principle, never the specifics: no names, copy, images or signature assets of theirs.');
for (const r of s.references) p(siteCard(r, false));
p('Full notes on any of them: `node draw.mjs --site <id>`.'); p();
}
p('## Always');
p('Headlines two lines or fewer at every width. No eyebrow labels, no monospace, no 01/02/03, no thin rules, no gradients, no shadows at rest,');
p('no cream-and-terracotta, no violet. The frame: one h1 on the first screen that says what this is, equal outer margins, no');
p('section huddled on one side with the other half empty, no blank stretch over half a screen, a heading on every section.');
p('`inspect.mjs` measures all of it.');
if (s.allow?.length) p(`This direction is allowed: ${s.allow.join(', ')} (the inspector reads this from the saved sheet).`);
p();
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
return o.join('\n');
}
@@ -0,0 +1,303 @@
#!/usr/bin/env node
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
//
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]
//
// --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel
// input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction
// that dealt scroll scenes switches it on by itself.
//
// --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a
// direction dealt with draw.mjs --app switches it on by itself.
//
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';
const args = process.argv.slice(2);
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
const has = (name) => { const i = args.indexOf('--' + name); if (i === -1) return false; args.splice(i, 1); return true; };
const outDir = resolve(flag('out', '.taste-skill/inspect'));
const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean);
let allow = flag('allow', '').split(',').filter(Boolean);
const asJson = has('json');
let appMode = has('app');
let film = has('film');
let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default
const target = args[0];
if (!target || target === '-h' || target.startsWith('--')) {
console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]');
process.exit(2);
}
// Allowances granted by the drawn direction (written by draw.mjs) apply automatically.
for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) {
if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; }
}
// A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to
// open a file path (some sandboxes show Node and the browser different views of the disk) and relative
// assets, fetch() and module scripts behave as they will when the page is hosted.
let url = target; let server = null;
if (!/^https?:\/\//.test(target)) {
const file = resolve(target);
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
const cwd = resolve('.');
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
server = createServer((req, res) => {
try {
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
if (statSync(f).isDirectory()) f = join(f, 'index.html');
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'content-length': statSync(f).size, 'cache-control': 'no-store' });
createReadStream(f).pipe(res);
} catch { res.writeHead(404); res.end('not found'); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
mkdirSync(outDir, { recursive: true });
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
const MOTION_MIN = 8, MOTION_GOOD = 16; // distinct moving elements over load + one scroll through the page
// Runs in the page before any of its own scripts: counts the distinct elements that actually move while
// the page loads and is scrolled (CSS animations, CSS transitions, Web Animations, and script-driven
// transform/opacity changes such as GSAP), so a page that never moves can be told from one that does.
const MOTION_RECORDER = `(() => {
const seen = new WeakSet(); const c = { elements: 0, css: 0, transitions: 0, scripted: 0 };
const note = (t, k) => { if (!(t instanceof Element) || seen.has(t)) return; seen.add(t); c.elements++; c[k]++; };
addEventListener('animationstart', (e) => note(e.target, 'css'), true);
addEventListener('transitionrun', (e) => note(e.target, 'transitions'), true);
const watch = () => new MutationObserver((list) => { for (const m of list) { const s = m.target.getAttribute && m.target.getAttribute('style') || ''; if (/transform|translate|opacity|scale|rotate|clip-path/.test(s)) note(m.target, 'scripted'); } })
.observe(document.documentElement, { attributes: true, attributeFilter: ['style'], subtree: true });
if (document.documentElement) watch(); else addEventListener('DOMContentLoaded', watch);
window.__motionSample = () => { for (const a of document.getAnimations ? document.getAnimations() : []) { const t = a.effect && a.effect.target; if (t) note(t, a.constructor && a.constructor.name === 'CSSTransition' ? 'transitions' : 'css'); } };
window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; };
})();`;
// Is the page's title on screen and readable: in the viewport, not faded, not masked away?
const TITLE_SEEN = `(() => {
const h = document.querySelector('h1'); if (!h) return { found: false };
const r = h.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' };
if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business
let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; }
if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' };
// text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre
const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect();
const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2));
const hit = document.elementFromPoint(x, y);
if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; }
return { found: true, visible: true };
})()`;
async function sheet(frames, cols, cellW, cellH, path) {
const rows = Math.ceil(frames.length / cols);
await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 });
await helper.eval((a) => {
document.body.style.cssText = `margin:0;background:#888;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px)`;
document.body.innerHTML = a.frames.map((s) => `<img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #888">`).join('');
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
}, { frames, cols, cellW, cellH });
await helper.screenshot({ path, quality: 76 });
}
try {
for (const w of widths) {
const h = HEIGHT[w] || Math.round(w * 0.625);
const label = name(w);
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
await sleep(600); // about 1.2s after the load event: is the title there to read yet?
const early = await page.eval(TITLE_SEEN).catch(() => null);
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(800); // let the load choreography finish before judging the first screen
const fold = join(outDir, `${label}-fold.jpg`);
await page.screenshot({ path: fold, quality: 82 });
const metrics = await page.eval(probe);
// walk the page one screen at a time so scroll reveals fire, photographing each screen
const frames = [readFileSync(fold).toString('base64')];
const frameChecks = [await page.eval(frame).catch(() => null)];
const screens = Math.min(11, Math.max(0, Math.ceil(metrics.page.height / h) - 1));
for (let i = 1; i <= screens; i++) {
await page.eval(`window.scrollTo({ top: ${i * h}, behavior: 'instant' })`).catch(() => {});
await page.wheel(2);
await sleep(650);
frames.push(await page.screenshot({ quality: 78 }));
frameChecks.push(await page.eval(frame).catch(() => null));
await page.eval('window.__motionSample && window.__motionSample()').catch(() => {});
}
const motion = await page.eval('window.__motion ? window.__motion() : null').catch(() => null);
// re-measure after everything has revealed (catches content that only exists post-reveal)
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
await sleep(350);
const after = await page.eval(probe).catch(() => metrics);
const m = after.headings.length >= metrics.headings.length ? after : metrics;
const lay = await page.eval(layout).catch(() => null);
const appM = appMode ? await page.eval(app).catch(() => null) : null;
if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null);
// scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room
const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : [];
const sceneFindings = [];
if (sceneState.length) {
const idle = sceneState.filter((s) => !s.live && !s.rest);
if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of <body> (references/scenes.md, "Putting a scene on a page").' });
const atRest = sceneState.filter((s) => s.rest);
if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' });
if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' });
const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0);
if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' });
}
// --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a
// scroll scene shows its in-between states, not only where each screen lands.
const filmSheets = [];
if (film && w >= 1100) {
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
await sleep(700);
const shots = []; let lastY = -1, still = 0;
for (let i = 0; i < 96; i++) {
shots.push(await page.screenshot({ quality: 70 }));
await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 });
await sleep(420);
const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1);
if (y === lastY) { if (++still >= 2) break; } else still = 0;
lastY = y;
}
for (let i = 0; i < shots.length; i += 24) {
const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
filmSheets.push(p);
}
}
const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4;
const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390;
const cellH = Math.round((cellW * h) / w);
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
const sheets = [];
for (let i = 0; i < frames.length; i += perSheet) {
const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
sheets.push(p);
}
let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f))));
if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' });
}
// a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s
if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' });
if (lay) findings.push(...judgeLayout(lay, { width: w, allow }));
if (appMode) {
findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title');
if (appM) findings.push(...judgeApp(appM, m, { width: w, allow }));
}
// what only shows up while scrolling: text hidden under something, and screens with almost nothing on them
const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' });
const clippedText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.clipped || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
if (clippedText.length && !allow.includes('clipped-text')) findings.push({ level: 'FAIL', id: 'clipped-text', width: w, message: 'Large text is cut off along its top by the box that holds it: ' + clippedText.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen})`).join('; '), fix: 'Jurors read a wordmark sliced at the top as broken. Give the box room for the ascenders (line-height and padding), or crop at the bottom or a side edge on purpose.' });
const overPictures = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.pinnedOver || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
if (overPictures.length && !allow.includes('pinned-over-picture')) findings.push({ level: 'WARN', id: 'pinned-over-picture', width: w, message: 'Pinned large text slides across pictures while the page scrolls: ' + overPictures.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% over a picture)`).join('; '), fix: 'Jurors read a pinned title crossing drawings or photographs as a collision. Keep the pictures in the column the title does not occupy, or unpin it.' });
const empty = frameChecks.map((f, i) => ({ f, i })).filter(({ f }) => f && f.filled < 0.2);
// the same large statement on screen for three screens or more in a row
const runs = new Map();
frameChecks.forEach((f, i) => { for (const t of f?.big || []) { const r = runs.get(t) || { start: i, last: i - 1, best: 0 }; if (r.last === i - 1) r.last = i; else { r.start = i; r.last = i; } r.best = Math.max(r.best, r.last - r.start + 1); runs.set(t, r); } });
const longPins = [...runs.entries()].filter(([, r]) => r.best >= 3);
if (w >= 1100 && longPins.length && !allow.includes('long-pin')) findings.push({ level: 'WARN', id: 'long-pin', width: w, message: `The same large text stays on screen for ${Math.max(...longPins.map(([, r]) => r.best))} screens in a row: ` + longPins.slice(0, 2).map(([t]) => `"${t}"`).join('; '), fix: 'A pinned element earns two screens, and something must change at each. Shorten the run or give every screen its own content.' });
// a page that barely moves: counted from what really animated while it loaded and was scrolled
if (w >= 1100 && motion && !allow.includes('still-page')) {
const moving = motion.elements + motion.playingVideos * 4;
if (moving < MOTION_MIN) findings.push({ level: 'FAIL', id: 'still-page', width: w, message: `Only ${motion.elements} element(s) moved while the page loaded and scrolled${motion.playingVideos ? ` (plus ${motion.playingVideos} playing video)` : ''}.`, fix: 'Build the motion layer (SKILL.md step 9, references/motion.md): the signature move, an entrance for the parts of every section, feedback on every control, and one moving picture. Transform and opacity only, with a reduced-motion fallback.' });
else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
}
if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) });
report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings));
report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets });
await page.close();
}
// Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an
// animation that will not run. Two in three studied award pages had no reduced-motion rules at all.
if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) {
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
try {
await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 });
const hidden = await rp.eval(`(() => {
const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = w.nextNode(); n; n = w.nextNode()) {
const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue;
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue;
let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; }
if (gone || o < 0.1) out.push(t.slice(0, 40));
}
return out;
})()`).catch(() => []);
if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' });
} finally { await rp.close(); }
}
} finally {
await browser.close();
if (server) server.close();
}
// de-duplicate findings that repeat across viewports (keep the widest occurrence, note the others)
const merged = [];
for (const f of report.findings) {
const same = merged.find((x) => x.id === f.id && x.level === f.level);
if (same && f.id !== 'headline-lines' && f.id !== 'overflow') { same.alsoAt = [...(same.alsoAt || []), f.width]; continue; }
merged.push(f);
}
report.findings = merged;
writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 1));
if (asJson) { console.log(JSON.stringify(report, null, 1)); process.exit(merged.some((f) => f.level === 'FAIL') ? 1 : 0); }
const d = report.viewports[0].metrics;
const line = (s = '') => console.log(s);
line(`TASTESKILL · INSPECT ${report.url}`);
line('');
line('MEASURED (first viewport listed)');
if (d.hero) line(` hero headline "${d.hero.text.slice(0, 70)}" ${d.hero.words} words · ${d.hero.lines} line(s) · ${d.hero.size}px (${d.hero.vw}vw) · ${d.hero.font} ${d.hero.weight} · tracking ${d.hero.tracking}em · leading ${d.hero.leading}`);
for (const v of report.viewports.slice(1)) if (v.metrics.hero) line(` @${v.width}px ${v.metrics.hero.lines} line(s) · ${v.metrics.hero.size}px`);
line(` typefaces ${d.type.families.filter((f) => f.share >= 0.02).map((f) => `${f.family} ${Math.round(f.share * 100)}%`).join(', ')}`);
line(` sizes in use ${d.type.sizes.join(' / ')} px${d.type.body ? ` body ${d.type.body.size}px / ${d.type.body.leading}` : ''}`);
line(` colour page ${d.color.bodyBg}${d.color.sectionBgs.length ? ' · sections ' + d.color.sectionBgs.map((s) => s.color).slice(0, 6).join(' ') : ''}${d.color.button ? ' · button ' + d.color.button.bg : ''}`);
line(` page ${d.page.screens} screens · ${d.page.words} words · ${d.page.img} images · hero media ${d.heroMedia[0] ? Math.round(d.heroMedia[0].ratio * 100) + '% ' + d.heroMedia[0].type : 'none'}`);
line(` headings ${d.headingSummary.count} · longest ${d.headingSummary.maxLines} line(s) · median ${d.headingSummary.medianWords ?? '-'} words`);
line('');
const fails = merged.filter((f) => f.level === 'FAIL'), warns = merged.filter((f) => f.level === 'WARN');
if (!fails.length && !warns.length) line('FINDINGS none. Measurements are clean — now judge the pictures.');
for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN — fix, or be able to defend', warns]]) {
if (!list.length) continue;
line(title);
for (const f of list) {
line(` [${f.id}] @${f.width}px${f.alsoAt ? ' (+' + f.alsoAt.join(', ') + ')' : ''} ${f.message}`);
line(` → ${f.fix}`);
}
line('');
}
line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:');
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(` ${'scroll film'.padEnd(15)} ${rel(p)} (6 frames a row, a third of a screen apart)`); }
line('');
line(`report: ${rel(join(outDir, 'report.json'))}`);
line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`);
process.exit(fails.length ? 1 : 0);
@@ -0,0 +1,203 @@
// tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox).
//
// node draw.mjs --app --kind <kind> --brief "one line" [--screens overview,list,detail,settings]
// [--theme light|dark] [--keep palette,type] [--options 3] [--style <id>] [--seed N]
// node draw.mjs --show reprint the saved sheet
// node draw.mjs --redraw style|shell|screen:<name>
// node draw.mjs --app --list kinds|styles|screens
//
// Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not
// end up in the same default look. The library (library/app.json) was distilled from a study of real
// product screens; it holds styles (tokens and the one move that makes each recognisable), shells and
// screen patterns.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { homedir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8'));
const argv = process.argv.slice(2);
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
const outDir = resolve(String(opt('out', '.taste-skill')));
const sheetPath = join(outDir, 'direction.json');
const KINDS = Object.keys(LIB.kinds);
// signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens
const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))];
const SIGNATURES = LIB.patterns.filter((p) => p.signature);
const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); };
if (opt('list')) {
const what = opt('list');
if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`);
else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`);
else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`);
else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`);
process.exit(0);
}
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
const startSeed = seed;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } };
const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style);
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); }
function stylePool(input) {
const pref = LIB.kinds[input.kind]?.styles || [];
let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id));
if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; }
const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind));
if (fit.length >= 2) pool = fit;
return pool;
}
function dealStyle(input, exclude = []) {
let pool = stylePool(input).filter((s) => !exclude.includes(s.id));
if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id));
const fresh = pool.filter((s) => !recentStyles.includes(s.id));
return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles);
}
// The style's own shell usually carries part of its move, so it is dealt about two times in three.
function dealShell(style, input, exclude = []) {
const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id));
const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id));
if (own && (!alts.length || rnd() < 0.66)) return own;
if (alts.length) return pick(alts);
const rest = LIB.shells.filter((s) => !exclude.includes(s.id));
return pick(rest.length ? rest : LIB.shells);
}
function dealScreen(name, style, input, exclude = []) {
let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id));
// a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a
// leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead
pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind));
return pool.length ? pick(pool) : null;
}
function deal(input) {
const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input);
if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); }
const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] };
for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) });
return s;
}
let sheet;
if (opt('redraw') && saved?.kind === 'app') {
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1;
const what = String(opt('redraw'));
if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); }
else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]);
else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; }
else { console.error('Redraw style, shell or screen:<name>.'); process.exit(2); }
} else {
const kind = String(opt('kind', ''));
if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n ${KINDS.join(', ')}`); process.exit(2); }
const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']);
const bad = screens.filter((x) => !SCREENS.includes(x));
if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); }
const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null };
if (opt('options')) {
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = [];
let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool;
while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); }
console.log(`# ${out.length} app styles dealt for this brief\n`);
console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style <id>` (and without --options).\n');
for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`);
process.exit(0);
}
sheet = deal(input);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id });
console.log(render(sheet));
process.exit(0);
function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
// A Google Fonts link for the style's faces (all of them are free there).
function fontLink(f) {
const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';');
const fams = [fam(f.family, f.weights || [400, 500, 600])];
const second = f.second && f.second.split(',')[0].trim();
if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500]));
return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n<link href="https://fonts.googleapis.com/css2?${fams.join('&')}&display=swap" rel="stylesheet">`;
}
function render(s) {
const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x);
p(`# APP SHEET — ${st.name}`);
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`);
if (s.input.brief) p(`Brief: ${s.input.brief}`);
p();
p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the');
p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is');
p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them');
p('as given rather than rounding them towards your habits.');
p();
p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p();
if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`);
if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.');
if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`);
p();
// the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3)
if (st.identity?.length) {
p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to');
p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.');
for (const x of st.identity) p('- ' + x);
p();
}
const sigs = signaturesFor(s.input.brief);
if (sigs.length) {
p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):');
for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`);
p();
}
const f = t.font || {};
if (!s.input.keep.includes('palette')) {
p('## 2. Tokens'); p('```css'); p(':root {');
for (const [k, v] of Object.entries(t)) {
if (k === 'font' || k === 'density') continue;
if (typeof v === 'string') p(` --${kebab(k)}: ${v};`);
else if (typeof v === 'number') p(` --${kebab(k)}: ${v}px;`);
else if (k === 'chart') v.forEach((c, i) => p(` --chart-${i + 1}: ${c};`));
else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(` --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`);
else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(` --radius-${n}: ${r}px;`);
}
if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(` --fs-${n}: ${v}px;`);
p('}'); p('```');
p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`);
p();
} else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); }
if (!s.input.keep.includes('type') && f.family) {
p(`## 3. Type — ${f.family}`);
p('```html'); p(fontLink(f)); p('```');
p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`);
p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p();
}
p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint);
if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.');
if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p();
p('## 5. Screens');
for (const sc of s.screens) {
p();
if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; }
p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`);
}
p();
p('## 6. Always');
p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,');
p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,');
p('rainbow charts, emoji, lorem or Acme. `<skill>/assets/app.css` and `<skill>/assets/charts.js` are a neutral start set to these');
p('rules. Check with `node <skill>/scripts/lint.mjs <files>` and `node <skill>/scripts/inspect.mjs <page>`: this sheet switches both');
p('to app rules.');
p();
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
return o.join('\n');
}
@@ -0,0 +1,248 @@
// Zero-dependency Chrome DevTools Protocol client.
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
// Requires Node 22+. No npm install.
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir, hostname } 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;
}
// Each profile carries an owner file naming the machine and the two processes that use it (the run and
// its Chrome). A later run removes a profile left by a killed run only when that file names this machine
// and neither process is alive. A profile without one (made before 2.1.4, or by another tool) is never touched.
const OWNER = 'taste-skill-owner.json';
function alive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
}
export function sweepProfiles(base) {
let names;
try { names = readdirSync(base); } catch { return; }
for (const name of names) {
if (!name.startsWith('ad-chrome-')) continue;
const p = join(base, name);
try {
const o = JSON.parse(readFileSync(join(p, OWNER), 'utf8'));
if (o.host !== hostname() || !Number.isInteger(o.pid) || alive(o.pid) || alive(o.chrome)) continue;
rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
} catch {}
}
}
export class Browser {
static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) {
if (typeof WebSocket === 'undefined') {
throw new Error('Node 22+ is required (built-in WebSocket missing). Current: ' + process.version);
}
const exe = findChrome();
if (!exe) {
throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
}
// A throwaway profile per run, its cache capped (each run starts cold anyway), removed on exit. It is
// claimed with an owner file before Chrome starts, so another run's sweep can tell when it was abandoned.
const base = process.env.TASTE_SKILL_TMP || tmpdir();
sweepProfiles(base);
const dir = mkdtempSync(join(base, 'ad-chrome-'));
const claim = (chrome) => {
try { writeFileSync(join(dir, OWNER), JSON.stringify({ host: hostname(), pid: process.pid, chrome })); } catch {}
};
claim();
// On exit Chrome still holds the profile's files: stop it first, then remove the folder.
let pid = 0;
const drop = () => {
try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {}
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
};
process.once('exit', drop);
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); });
const args = [
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
'--disk-cache-size=16777216', '--media-cache-size=16777216',
'--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter',
'--force-color-profile=srgb', '--autoplay-policy=no-user-gesture-required',
'--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank',
];
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
pid = proc.pid;
claim(pid);
const wsUrl = await new Promise((resolve, reject) => {
let buf = '';
const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
proc.stderr.on('data', (d) => {
buf += d.toString();
const m = buf.match(/DevTools listening on (ws:\/\/[^\s]+)/);
if (m) { clearTimeout(t); resolve(m[1]); }
});
proc.on('exit', (code) => { clearTimeout(t); reject(new Error('Chrome exited early, code ' + code + '\n' + buf.slice(-600))); });
});
const b = new Browser();
b.proc = proc; b.dir = dir; b.nextId = 1; b.pending = new Map(); b.listeners = new Map();
b.ws = new WebSocket(wsUrl);
await new Promise((res, rej) => { b.ws.onopen = res; b.ws.onerror = () => rej(new Error('CDP socket failed')); });
b.ws.onmessage = (ev) => {
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString());
if (msg.id && b.pending.has(msg.id)) {
const { resolve, reject, timer } = b.pending.get(msg.id);
clearTimeout(timer); b.pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
} else if (msg.method) {
const fns = b.listeners.get((msg.sessionId || '') + '|' + msg.method);
if (fns) for (const fn of [...fns]) fn(msg.params);
}
};
return b;
}
send(method, params = {}, sessionId, timeoutMs = 30000) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
});
}
on(sessionId, method, fn) {
const k = (sessionId || '') + '|' + method;
if (!this.listeners.has(k)) this.listeners.set(k, new Set());
this.listeners.get(k).add(fn);
return () => this.listeners.get(k)?.delete(fn);
}
async newPage({ width = 1440, height = 900, dsf = 1, mobile = false, reducedMotion = false } = {}) {
const { targetId } = await this.send('Target.createTarget', { url: 'about:blank', newWindow: true });
const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
const p = new Page(this, targetId, sessionId);
await p.send('Page.enable');
await p.send('Runtime.enable');
await p.setViewport({ width, height, dsf, mobile });
if (reducedMotion) {
await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
}
return p;
}
async close() {
try { await this.send('Browser.close', {}, undefined, 3000); } catch {}
try { this.ws.close(); } catch {}
try { this.proc.kill(); } catch {}
await sleep(300);
try { rmSync(this.dir, { recursive: true, force: true, maxRetries: 3 }); } catch {}
}
}
export class Page {
constructor(browser, targetId, sessionId) {
this.browser = browser; this.targetId = targetId; this.sessionId = sessionId;
this.consoleErrors = [];
browser.on(sessionId, 'Runtime.exceptionThrown', (p) => {
this.consoleErrors.push((p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || '').slice(0, 300));
});
// Never let a JS dialog block the page.
browser.on(sessionId, 'Page.javascriptDialogOpening', () => {
this.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {});
});
}
send(method, params = {}, timeoutMs = 30000) {
return this.browser.send(method, params, this.sessionId, timeoutMs);
}
async setViewport({ width, height, dsf = 1, mobile = false }) {
this.viewport = { width, height, dsf, mobile };
await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dsf, mobile });
await this.send('Emulation.setTouchEmulationEnabled', { enabled: !!mobile });
}
// Navigates and resolves after the load event (or timeout), then waits `settleMs` for intros to finish.
async goto(url, { timeoutMs = 25000, settleMs = 1500 } = {}) {
let off;
const loaded = new Promise((resolve) => { off = this.browser.on(this.sessionId, 'Page.loadEventFired', () => resolve('load')); });
const nav = await this.send('Page.navigate', { url }, timeoutMs).catch((e) => ({ errorText: e.message }));
if (nav.errorText && !/ERR_ABORTED/.test(nav.errorText)) { off(); throw new Error('navigate: ' + nav.errorText); }
const how = await Promise.race([loaded, sleep(timeoutMs).then(() => 'timeout')]);
off();
await sleep(settleMs);
return how;
}
// Evaluates a function (serialised) or an expression string in the page and returns its JSON value.
async eval(fnOrExpr, arg) {
const expression = typeof fnOrExpr === 'function'
? `(${fnOrExpr.toString()})(${arg === undefined ? '' : JSON.stringify(arg)})`
: fnOrExpr;
const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, 30000);
if (r.exceptionDetails) {
throw new Error('page eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
}
return r.result?.value;
}
async screenshot({ path, format = 'jpeg', quality = 72, fullPage = false, clip } = {}) {
const params = { format, captureBeyondViewport: !!fullPage };
if (format !== 'png') params.quality = quality;
if (fullPage) {
const m = await this.send('Page.getLayoutMetrics');
const s = m.cssContentSize || m.contentSize;
params.clip = { x: 0, y: 0, width: Math.ceil(s.width), height: Math.min(Math.ceil(s.height), 16000), scale: 1 };
} else if (clip) {
params.clip = { scale: 1, ...clip };
}
const { data } = await this.send('Page.captureScreenshot', params, 45000);
if (path) writeFileSync(path, Buffer.from(data, 'base64'));
return data;
}
// Real wheel input, so smooth-scroll libraries and scroll-driven reveals behave as they do for a visitor.
async wheel(totalDeltaY, { step = 240, pauseMs = 45 } = {}) {
const { width, height } = this.viewport;
const dir = Math.sign(totalDeltaY) || 1;
let left = Math.abs(totalDeltaY);
while (left > 0) {
const d = Math.min(step, left);
await this.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: Math.round(width / 2), y: Math.round(height / 2), deltaX: 0, deltaY: d * dir }).catch(() => {});
left -= d;
await sleep(pauseMs);
}
}
async close() {
try { await this.browser.send('Target.closeTarget', { targetId: this.targetId }, undefined, 5000); } catch {}
}
}
export { sleep };
@@ -0,0 +1,909 @@
// probe() runs INSIDE the page (it is serialised and evaluated through cdp.mjs), so it must stay
// self-contained: no imports, no closures over module scope.
// It measures what a design reviewer looks at — headline line counts, the type system, colour,
// surfaces, decoration — and the specific tells this skill bans.
export function probe() {
const W = innerWidth, H = innerHeight, SY = scrollY;
const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
// --- colour normalisation (handles oklch(), color(), named colours, etc.)
const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
const ctx = cvs.getContext('2d', { willReadFrequently: true });
const colorCache = new Map();
const rgba = (c) => {
if (colorCache.has(c)) return colorCache.get(c);
ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
const v = { r: d[0], g: d[1], b: d[2], a: round(d[3] / 255) };
colorCache.set(c, v); return v;
};
const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
const lum = (v) => {
const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b);
};
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return round((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05), 1); };
const hsl = (v) => {
const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b);
const l = (mx + mn) / 2; let h = 0, s = 0;
if (mx !== mn) {
const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60;
}
return { h: Math.round(h), s: round(s), l: round(l) };
};
// --- monospace detection by measurement, not by font name
const mctx = document.createElement('canvas').getContext('2d');
const monoCache = new Map();
const isMono = (family) => {
if (monoCache.has(family)) return monoCache.get(family);
mctx.font = `40px ${family}`;
const a = mctx.measureText('iiiiiiii').width, b = mctx.measureText('WWWWWWWW').width;
const v = Math.abs(a - b) < 1;
monoCache.set(family, v); return v;
};
const firstFam = (ff) => ff.split(',')[0].replace(/["']/g, '').trim();
// Text kept for screen readers only (the usual .sr-only / .visually-hidden patterns) is not on the page.
const srHidden = (el) => {
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) {
const cs = getComputedStyle(p); const r = p.getBoundingClientRect();
if (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath) || (r.width <= 2 && r.height <= 2 && cs.overflow !== 'visible')) return true;
}
return false;
};
const GENERIC = /^(serif|sans-serif|monospace|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|cursive|fantasy|inherit|-apple-system|BlinkMacSystemFont|Segoe UI|Arial|Helvetica|Helvetica Neue|Times New Roman|Times|Georgia|Verdana|Tahoma|Courier New|Courier|Roboto|Apple Color Emoji)$/i;
// --- collect elements
const all = [...document.body.querySelectorAll('*')].slice(0, 9000);
const texts = [];
const big = [];
for (const el of all) {
const tag = el.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) continue;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const area = r.width * r.height;
if (area >= 60 * 30) big.push({ el, r, cs, area, top: r.top + SY });
let direct = '';
for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
direct = direct.replace(/\s+/g, ' ').trim();
if (!direct) continue;
if (tag === 'OPTION' || el.closest('svg') || srHidden(el)) continue;
const size = parseFloat(cs.fontSize);
if (!size || size < 6) continue;
const ls = cs.letterSpacing === 'normal' ? 0 : parseFloat(cs.letterSpacing) / size;
const lh = cs.lineHeight === 'normal' ? 1.2 : parseFloat(cs.lineHeight) / size;
texts.push({
el, tag, text: direct, chars: direct.length, fam: firstFam(cs.fontFamily), ff: cs.fontFamily,
size: round(size, 1), weight: +cs.fontWeight || 400, ls: round(ls, 3), lh: round(lh, 2),
upper: cs.textTransform === 'uppercase' || (direct.length > 2 && direct === direct.toUpperCase() && /[A-Z]{2}/.test(direct)),
italic: cs.fontStyle === 'italic', align: cs.textAlign, color: cs.color,
top: r.top + SY, bottom: r.bottom + SY, left: r.left, right: r.right, w: r.width, h: r.height,
clipText: (cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text'),
inCode: !!el.closest('pre, code, kbd, samp, textarea'), inUI: !!el.closest('[data-ui]'), textShadow: cs.textShadow !== 'none',
});
}
// --- heading blocks: climb from a large text node to the block that holds the whole headline
// An element that is a complete text unit by itself: separate links, list items or paragraphs set large
// are separate lines of content, never the wrapped lines of one headline.
const UNIT = 'h1, h2, h3, h4, h5, h6, [role="heading"], p, li, a, button, blockquote, dt, dd, figcaption, td, th, label, summary';
const blockOf = (t) => {
let cur = t.el;
// usable as the block when it holds only this size of type (a title with its own small description is not one block)
const clean = (el) => (el.innerText || '').trim().length <= 320 && !texts.some((o) => o.el !== t.el && el.contains(o.el) && o.size < t.size * 0.72);
let unit = t.el.closest(UNIT);
if (unit && unit.matches('a, button')) { const outer = unit.parentElement?.closest('h1, h2, h3, h4, h5, h6, p, blockquote, li'); if (outer && clean(outer)) unit = outer; }
if (unit && clean(unit)) return unit;
while (cur.parentElement && cur.parentElement !== document.body) {
if (cur.matches(UNIT)) break;
const p = cur.parentElement;
if (p.matches('ul, ol, dl, nav, menu, table, tbody')) break;
const inner = (p.innerText || '').trim();
if (inner.length > 220) break;
let ok = true;
for (const o of texts) if (o.el !== t.el && p.contains(o.el) && o.size < t.size * 0.72) { ok = false; break; }
if (!ok) break;
cur = p;
}
return cur;
};
// Count lines from the text itself: inline images or media set inside a sentence are not lines.
const linesOf = (el, size) => {
const tops = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const range = document.createRange();
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.nodeValue.trim()) continue;
range.selectNodeContents(node);
for (const rc of range.getClientRects()) {
if (rc.width < 1 || rc.height < size * 0.35) continue;
const mid = rc.top + rc.height / 2;
if (!tops.some((t) => Math.abs(t - mid) < size * 0.55)) tops.push(mid);
}
}
return Math.max(1, tops.length);
};
const describeBlock = (el, t) => {
const r = el.getBoundingClientRect();
const text = (el.innerText || t.text).replace(/\s+/g, ' ').trim();
const cx = (r.left + r.right) / 2 / W;
const fams = new Set(texts.filter((o) => el.contains(o.el)).map((o) => o.fam + (o.italic ? ' italic' : '')));
return {
text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, chars: text.length,
lines: linesOf(el, t.size), size: t.size, vw: round((t.size / W) * 100, 1), font: t.fam, weight: t.weight,
tracking: t.ls, leading: t.lh, upper: t.upper, italic: t.italic, mono: isMono(t.ff),
align: /center/.test(t.align) ? 'center' : /right|end/.test(t.align) ? 'right' : 'left',
position: cx < 0.42 ? 'left' : cx > 0.58 ? 'right' : 'center',
widthRatio: round(r.width / W), top: Math.round(r.top + SY), mixedFonts: fams.size > 1 ? [...fams] : undefined,
gradientText: texts.some((o) => el.contains(o.el) && o.clipText) || undefined, tag: el.tagName.toLowerCase(),
// a title (h1–h6, or unmarked large text) or a statement (a paragraph, quotation or list item set large)
kind: el.closest('h1,h2,h3,h4,h5,h6,[role="heading"]') || el.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"]') ? 'heading'
: t.el.closest('p, blockquote, li, dd, q, figcaption') || text.length > 150 ? 'statement' : 'heading',
};
};
const headingMin = W < 600 ? 22 : Math.max(26, W * 0.019);
const seenBlocks = new Set();
const headings = [];
for (const t of texts.filter((x) => x.size >= headingMin && !x.inCode).sort((a, b) => b.size - a.size)) {
const blk = blockOf(t);
if (seenBlocks.has(blk) || [...seenBlocks].some((b) => b.contains(blk) || blk.contains(b))) continue;
seenBlocks.add(blk);
const d = describeBlock(blk, t);
if (d.kind === 'statement' && d.chars > 150 && d.size <= 30) continue; // a lead paragraph, not display type
headings.push({ ...d, _el: blk });
}
headings.sort((a, b) => a.top - b.top);
// hero headline = largest heading block that starts inside the first viewport
const inHero = headings.filter((h) => h.top < H * 0.95);
let hero = inHero.slice().sort((a, b) => b.size - a.size || b.chars - a.chars)[0] || null;
if (hero) hero.kind = 'heading'; // the main line of the first screen is a headline whatever its tag
const h1el = document.querySelector('h1');
if (!hero) {
// quiet pages: fall back to the h1, then to the largest text in the first viewport
const h1r = h1el && h1el.getBoundingClientRect();
const pick = (h1r && h1r.top < H * 0.95 && h1r.width > 0 && texts.find((t) => h1el.contains(t.el))) ||
texts.filter((t) => t.top - SY < H * 0.95 && t.size >= 18 && t.chars >= 8).sort((a, b) => b.size - a.size)[0];
if (pick) { const blk = h1el && h1el.contains(pick.el) ? h1el : blockOf(pick); hero = { ...describeBlock(blk, pick), _el: blk }; }
}
let h1 = null;
if (h1el && h1el.getBoundingClientRect().width > 0) {
const cs = getComputedStyle(h1el); const size = parseFloat(cs.fontSize);
const text = (h1el.innerText || '').replace(/\s+/g, ' ').trim();
h1 = { text: text.slice(0, 200), words: text.split(' ').filter(Boolean).length, lines: linesOf(h1el, size), size: round(size, 1), font: firstFam(cs.fontFamily) };
}
// --- eyebrows: a small short label sitting directly above a heading block
// Sticky and fixed layers overlap other content by design; their geometry says nothing about what labels what.
const pinned = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; } return false; };
const eyebrows = [];
for (const h of headings) {
const hr = h._el.getBoundingClientRect(); const htop = hr.top + SY;
if (h._el.closest('[data-ui]')) continue;
const cand = texts.filter((t) => !t.inUI && !h._el.contains(t.el) && t.size <= Math.min(18, h.size * 0.5) && t.chars <= 44 && t.chars >= 2 &&
t.bottom <= htop + 4 && htop - t.bottom <= Math.min(140, Math.max(72, h.size * 1.1)) && t.right > hr.left - 8 && t.left < hr.right + 8 &&
!t.el.closest('button') &&
!t.el.closest('nav, [role="navigation"], footer') && !(t.top < 120 && t.el.closest('header')) && !pinned(t.el) && pinned(h._el) === false)
.sort((a, b) => b.bottom - a.bottom)[0];
if (!cand) continue;
// must be alone on its row (a real paragraph above a heading is not an eyebrow)
if (texts.some((t) => t !== cand && Math.abs(t.top - cand.top) < 6 && t.chars > 44)) continue;
// a note that sits closer to the heading above it than to this one belongs to that heading
const gapDown = htop - cand.bottom;
if (headings.some((o) => o !== h && !o._el.contains(cand.el) && (() => { const or = o._el.getBoundingClientRect(); const gapUp = cand.top - (or.bottom + SY); return gapUp >= -2 && gapUp < gapDown && or.left < cand.right && or.right > cand.left; })())) continue;
let pill = false; let p = cand.el;
for (let i = 0; i < 3 && p; i++, p = p.parentElement) {
const cs = getComputedStyle(p); const pr = p.getBoundingClientRect();
if (pr.width < 420 && pr.height < 56 && parseFloat(cs.borderTopLeftRadius) >= 10 && (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0)) pill = true;
}
eyebrows.push({ text: cand.text.slice(0, 50), above: h.text.slice(0, 40), upper: cand.upper, tracked: cand.ls >= 0.04, mono: isMono(cand.ff), pill, size: cand.size, top: Math.round(cand.top) });
}
// --- decorative numbering: 01 / 02 / (03) / 04. / 001 / "Step 02"
const numbered = texts.filter((t) => !t.inCode && !t.inUI && (/^[(\[\/#]?\s?0\d{1,2}\s?[)\]\/.:—–-]?$/.test(t.text) || /^0\d\s?[\/.—–-]\s/.test(t.text) || /^(step|phase|no\.?|n°|nº|chapter|part|fig\.?)\s?0\d{1,2}\b/i.test(t.text)));
// --- type system
const famChars = new Map(); const sizeChars = new Map(); let totalChars = 0; let monoChars = 0; let monoProse = 0; const monoFams = new Set(); const monoSamples = [];
let microLabels = 0; let microLabelChars = 0; let upperChars = 0; let centerChars = 0; const microSamples = [];
for (const t of texts) {
totalChars += t.chars;
famChars.set(t.fam, (famChars.get(t.fam) || 0) + t.chars);
if (!t.inUI) { const s = Math.round(t.size); sizeChars.set(s, (sizeChars.get(s) || 0) + t.chars); } // interface fragments keep their own sizes
if (isMono(t.ff)) {
monoChars += t.chars; monoFams.add(t.fam);
if (!t.inCode) { monoProse += t.chars; if (monoSamples.length < 6) monoSamples.push(t.text.slice(0, 40)); }
}
if (t.size <= 14 && t.upper && t.ls >= 0.05 && !t.inUI && !t.el.closest('nav, button, a[href]')) { microLabels++; microLabelChars += t.chars; if (microSamples.length < 6) microSamples.push(t.text.slice(0, 40)); }
if (t.upper) upperChars += t.chars;
if (/center/.test(t.align)) centerChars += t.chars;
}
const families = [...famChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([family, chars]) => ({ family, share: round(chars / totalChars), mono: isMono(`"${family}", serif`) && isMono(`"${family}", sans-serif`) }));
const bodyTexts = texts.filter((t) => t.size >= 10 && t.size <= 24 && t.chars >= 60 && !t.inCode && !t.inUI);
const bodyBy = new Map();
for (const t of bodyTexts) { const k = t.fam + '|' + Math.round(t.size); bodyBy.set(k, (bodyBy.get(k) || 0) + t.chars); }
const bodyTop = [...bodyBy.entries()].sort((a, b) => b[1] - a[1])[0];
const bodySample = bodyTop ? bodyTexts.find((t) => t.fam + '|' + Math.round(t.size) === bodyTop[0]) : null;
const longest = bodyTexts.slice().sort((a, b) => b.chars - a.chars)[0];
const sizes = [...sizeChars.entries()].sort((a, b) => a[0] - b[0]).map(([s]) => s);
const maxText = texts.slice().sort((a, b) => b.size - a.size)[0];
const smallText = texts.filter((t) => t.size < 13 && t.chars >= 12 && !t.inUI && !t.inCode).length; // interface fragments keep their true sizes
const loaded = new Set([...document.fonts].filter((f) => f.status === 'loaded').map((f) => f.family.replace(/["']/g, '').toLowerCase()));
const declared = new Set([...document.fonts].map((f) => f.family.replace(/["']/g, '').toLowerCase()));
const notLoaded = families.map((f) => f.family).filter((f) => !GENERIC.test(f) && !loaded.has(f.toLowerCase()) && (declared.has(f.toLowerCase()) || declared.size > 0));
// --- colours
const bodyBg = (() => {
for (const el of [document.body, document.documentElement]) { const v = rgba(getComputedStyle(el).backgroundColor); if (v.a > 0.5) return v; }
return { r: 255, g: 255, b: 255, a: 1 };
})();
const sectionBgs = [];
for (const b of big) {
if (b.area < W * H * 0.3) continue;
const v = rgba(b.cs.backgroundColor); if (v.a < 0.9) continue;
const hx = hex(v); if (!sectionBgs.some((s) => s.color === hx)) sectionBgs.push({ color: hx, top: Math.round(b.top), hsl: hsl(v) });
}
const textColorChars = new Map();
for (const t of texts) { const v = rgba(t.color); if (v.a < 0.3) continue; const hx = hex(v); textColorChars.set(hx, (textColorChars.get(hx) || 0) + t.chars); }
const textColors = [...textColorChars.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([color, c]) => ({ color, share: round(c / totalChars) }));
// effective background behind an element (null when an image or gradient sits behind it)
const bgBehind = (el) => {
for (let p = el; p && p !== document.documentElement; p = p.parentElement) {
const cs = getComputedStyle(p);
if (cs.backgroundImage !== 'none' || p.tagName === 'VIDEO' || p.tagName === 'CANVAS') return null;
const v = rgba(cs.backgroundColor); if (v.a >= 0.9) return v;
if (cs.position === 'absolute' || cs.position === 'fixed') return null; // may overlap media
}
return bodyBg;
};
// media that may sit behind text without being its ancestor (an absolutely placed hero image, a video layer)
const mediaRects = big.filter((b) => b.area >= 120 * 80 && (b.el.tagName === 'IMG' || b.el.tagName === 'VIDEO' || b.el.tagName === 'CANVAS' || b.el.tagName === 'PICTURE' || b.el.tagName === 'IFRAME' || b.el.tagName === 'svg' || b.cs.backgroundImage !== 'none')).map((b) => ({ el: b.el, l: b.r.left, r: b.r.right, t: b.r.top + SY, b: b.r.bottom + SY }));
const overMedia = (t) => {
const area = Math.max(1, t.w * t.h);
for (const m of mediaRects) {
const ox = Math.min(t.right, m.r) - Math.max(t.left, m.l), oy = Math.min(t.bottom, m.b) - Math.max(t.top, m.t);
if (ox > 0 && oy > 0 && (ox * oy) / area >= 0.25) return true;
}
return false;
};
const lowContrast = [];
for (const t of texts) {
if (t.chars < 4 || t.top - SY > H * 6) continue;
const fg = rgba(t.color); if (fg.a < 0.99) continue;
if (overMedia(t)) continue; // cannot know the pixels behind it; judge that from the screenshot
// text blended with difference or exclusion inverts whatever lies under it; its colour alone says nothing
let blended = false;
for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) { blended = true; break; }
if (blended) continue;
const bg = bgBehind(t.el); if (!bg) continue;
const c = contrast(fg, bg); const need = t.size >= 24 || (t.size >= 18.5 && t.weight >= 700) ? 3 : 4.5;
if (c < need) lowContrast.push({ text: t.text.slice(0, 40), ratio: c, need, size: t.size, fg: hex(fg), bg: hex(bg) });
}
lowContrast.sort((a, b) => a.ratio - b.ratio);
// --- buttons
const buttons = [];
for (const el of document.querySelectorAll('a, button, [role="button"], input[type="submit"]')) {
const r = el.getBoundingClientRect(); if (r.height < 28 || r.height > 84 || r.width < 64 || r.width > Math.min(W * 0.96, 900)) continue;
const cs = getComputedStyle(el); let bg = rgba(cs.backgroundColor); const bw = parseFloat(cs.borderTopWidth) || 0;
const grad = cs.backgroundImage.includes('gradient(');
if (grad && bg.a < 0.5) { const first = cs.backgroundImage.match(/(rgba?\([^)]+\)|oklch\([^)]+\)|color\([^)]+\)|#[0-9a-f]{3,8})/i); if (first) bg = rgba(first[1]); }
if (bg.a < 0.5 && bw < 1) continue;
const rad = parseFloat(cs.borderTopLeftRadius) || 0;
buttons.push({
text: (el.innerText || el.value || '').replace(/\s+/g, ' ').trim().slice(0, 40), filled: bg.a >= 0.5, bg: bg.a >= 0.5 ? hex(bg) : null, bgHsl: bg.a >= 0.5 ? hsl(bg) : null, fg: hex(rgba(cs.color)),
radius: rad >= r.height / 2 - 1 ? 'pill' : Math.round(rad), h: Math.round(r.height), top: Math.round(r.top + SY),
shadow: cs.boxShadow !== 'none' && !/^rgba?\(0, 0, 0, 0\)/.test(cs.boxShadow), gradient: grad, upper: cs.textTransform === 'uppercase', mono: isMono(cs.fontFamily),
});
}
const mode = (arr) => { const m = new Map(); for (const a of arr) m.set(JSON.stringify(a), (m.get(JSON.stringify(a)) || 0) + 1); const top = [...m.entries()].sort((a, b) => b[1] - a[1])[0]; return top ? JSON.parse(top[0]) : undefined; };
const filledBtns = buttons.filter((b) => b.filled);
// --- surfaces and decoration
const isOverlay = (el) => { for (let p = el, i = 0; p && i < 4; p = p.parentElement, i++) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') return true; if (p.matches('dialog, [role="dialog"], [role="menu"], [role="tooltip"], [popover]')) return true; } return false; };
// real tables, form fields and interface fragments (marked data-ui) keep their own lines
const formish = (el) => el.matches('input, textarea, select, table, thead, tbody, tr, td, th, hr') || !!el.closest('table, [data-ui]');
let shadowStatic = 0, shadowOverlay = 0; const shadowSamples = []; let gradients = 0; let scrims = 0; const gradientSamples = []; let gradientText = 0;
let hairlines = 0; const hairlineSamples = []; let outlinedBoxes = 0; let blurred = 0; let roundedBoxes = 0; let squareMedia = 0; let roundedMedia = 0; const mediaRadii = []; const boxRadii = [];
let dashed = 0;
const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
for (const b of big) {
const { cs, r, el } = b;
const sh = cs.boxShadow;
if (sh !== 'none' && !/^rgba?\(0, 0, 0, 0\) 0px 0px 0px 0px$/.test(sh)) {
const outer = sh.split(/,(?![^(]*\))/).some((part) => !/inset/.test(part) && !/ 0px 0px 0px 0px/.test(part) && !/rgba\(\d+, \d+, \d+, 0\)/.test(part));
if (outer) { if (isOverlay(el) || el.closest('[data-ui]')) shadowOverlay++; else { shadowStatic++; if (shadowSamples.length < 4) shadowSamples.push(label(el) + ' → ' + sh.slice(0, 80)); } }
}
if (cs.backgroundImage.includes('gradient(')) {
if ((cs.webkitBackgroundClip || cs.backgroundClip || '').includes('text')) gradientText++;
else if (b.area >= W * H * 0.02) {
// neutral stops with changing alpha = a legibility scrim over media, not decoration
const stops = [...cs.backgroundImage.matchAll(/rgba?\(([^)]+)\)/g)].map((mm) => mm[1].split(/[,\s/]+/).filter(Boolean).map(Number));
const exotic = /oklch|oklab|color\(|lab\(|lch\(|hsl/.test(cs.backgroundImage);
const scrim = !exotic && stops.length > 0 && stops.every((c) => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) <= 14) && stops.some((c) => c.length > 3 && c[3] < 1);
if (scrim) scrims++; else { gradients++; if (gradientSamples.length < 4) gradientSamples.push(label(el) + ' → ' + cs.backgroundImage.slice(0, 110)); }
}
}
if ((cs.backdropFilter && cs.backdropFilter !== 'none') || (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none')) blurred++;
const bt = parseFloat(cs.borderTopWidth) || 0, bb = parseFloat(cs.borderBottomWidth) || 0, bl = parseFloat(cs.borderLeftWidth) || 0, br = parseFloat(cs.borderRightWidth) || 0;
const thin = (w, c, style) => w > 0 && w <= 1.5 && style !== 'none' && rgba(c).a > 0.02;
const topLine = thin(bt, cs.borderTopColor, cs.borderTopStyle), botLine = thin(bb, cs.borderBottomColor, cs.borderBottomStyle);
const leftLine = thin(bl, cs.borderLeftColor, cs.borderLeftStyle), rightLine = thin(br, cs.borderRightColor, cs.borderRightStyle);
if (!formish(el)) {
if (topLine && botLine && leftLine && rightLine) { if (r.width >= 150 && r.height >= 80) outlinedBoxes++; }
else if ((r.width >= 240 && (topLine || botLine)) || (r.height >= 240 && (leftLine || rightLine))) { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(r.width) + 'px'); }
// thin dashed or dotted lines; a heavy dotted edge (3px or more) is a drawn perforation, not a hairline
if ([[cs.borderTopStyle, bt], [cs.borderBottomStyle, bb], [cs.borderLeftStyle, bl], [cs.borderRightStyle, br]].some(([st, w]) => /dashed|dotted/.test(st) && w > 0 && w < 3)) dashed++;
}
const rad = parseFloat(cs.borderTopLeftRadius) || 0;
const isMedia = el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'CANVAS' || /url\(/.test(cs.backgroundImage);
if (isMedia && r.width >= 200 && r.height >= 140) {
let effective = rad; let p = el.parentElement;
for (let i = 0; i < 3 && p && effective === 0; i++, p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflow !== 'visible') effective = parseFloat(pcs.borderTopLeftRadius) || 0; }
if (effective > 1) { roundedMedia++; mediaRadii.push(Math.round(effective)); } else squareMedia++;
} else if (rad >= 6 && r.width >= 150 && r.height >= 80 && (rgba(cs.backgroundColor).a > 0.04 || bt > 0)) { roundedBoxes++; boxRadii.push(Math.round(Math.min(rad, 99))); }
}
// elements that are themselves a thin rule (1px tall or 1px wide)
for (const el of all) {
const r = el.getBoundingClientRect();
const horiz = r.height > 0 && r.height <= 1.5 && r.width >= 160, vert = r.width > 0 && r.width <= 1.5 && r.height >= 160;
if (!horiz && !vert) continue; if (formish(el) && (el.tagName !== 'HR' || el.closest('[data-ui]'))) continue;
const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.03) continue;
if (rgba(cs.backgroundColor).a > 0.02 || parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderLeftWidth) > 0 || cs.backgroundImage !== 'none') { hairlines++; if (hairlineSamples.length < 5) hairlineSamples.push(label(el) + ' ' + Math.round(Math.max(r.width, r.height)) + 'px rule'); }
}
// --- repeated card rows (three or more same-size boxed siblings)
const cardRows = [];
const seenParents = new Set();
for (const b of big) {
const p = b.el.parentElement; if (!p || seenParents.has(p)) continue; seenParents.add(p);
// cards carry text; a row of photographs or media tiles is not a card row
const kids = [...p.children].filter((k) => { const r = k.getBoundingClientRect(); return r.width >= 150 && r.width < W * 0.9 && r.height >= 90 && !k.matches('img, picture, video, canvas, svg, figure:not(:has(figcaption))') && (k.innerText || '').trim().length >= 12; }); // full-width bands are sections, not cards
if (kids.length < 3) continue;
const boxed = kids.filter((k) => { const cs = getComputedStyle(k); return (rgba(cs.backgroundColor).a > 0.04 || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none') && true; });
if (boxed.length < 3) continue;
const w0 = boxed[0].getBoundingClientRect().width;
const same = boxed.filter((k) => Math.abs(k.getBoundingClientRect().width - w0) < 3);
if (same.length < 3) continue;
const iconTop = same.filter((k) => { const first = k.querySelector('svg, img, i, [class*="icon" i]'); if (!first) return false; const r = first.getBoundingClientRect(); return r.width > 0 && r.width <= 64 && r.height <= 64; }).length;
cardRows.push({ count: same.length, iconTopped: iconTop >= same.length - 1 && iconTop >= 3, rounded: (parseFloat(getComputedStyle(same[0]).borderTopLeftRadius) || 0) >= 6, top: Math.round(same[0].getBoundingClientRect().top + SY) });
}
// --- hero media
const heroMedia = [];
for (const b of big) {
const { el, r, cs } = b; if (r.top > H * 0.9 || r.bottom < 0) continue;
const type = el.tagName === 'IMG' ? 'img' : el.tagName === 'VIDEO' ? 'video' : el.tagName === 'CANVAS' ? 'canvas' : el.tagName === 'svg' && r.width > 240 ? 'svg' : /url\(/.test(cs.backgroundImage) ? 'bg-image' : el.tagName === 'IFRAME' ? 'iframe' : null;
if (!type) continue;
const vw = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0)), vh = Math.max(0, Math.min(r.bottom, H) - Math.max(r.top, 0));
const ratio = (vw * vh) / (W * H); if (ratio >= 0.08) heroMedia.push({ type, ratio: round(ratio) });
}
heroMedia.sort((a, b) => b.ratio - a.ratio);
const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getBoundingClientRect().width > 0 && (i.currentSrc || i.src)).map((i) => (i.currentSrc || i.src).slice(0, 120)).slice(0, 8);
// --- navigation
const navEl = document.querySelector('header nav, nav, header, [role="navigation"]');
let nav = null;
if (navEl) {
let host = navEl; let fixed = false; let p = navEl;
for (let i = 0; i < 4 && p && p !== document.body; i++, p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed' || pos === 'sticky') { fixed = true; host = p; break; } }
const hr = host.getBoundingClientRect(); const hcs = getComputedStyle(host);
const links = [...navEl.querySelectorAll('a')].filter((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.top < 140; });
nav = { links: links.length, fixed, height: Math.round(hr.height), floatingPill: hr.width < W * 0.92 && parseFloat(hcs.borderTopLeftRadius) >= 16 && hr.top > 4, blur: (hcs.backdropFilter || 'none') !== 'none' };
}
// data-ui is for cropped interface fragments, not for wrapping the page
const uiAbuse = [...document.querySelectorAll('[data-ui]')].some((el) => el.querySelector('h1') || el.getBoundingClientRect().height > H * 1.6 || el === document.body);
const uiFragments = document.querySelectorAll('[data-ui]').length;
// pictures that were not drawn by the page's own code: photographs, footage, raster artwork.
// Inline SVG and .svg files do not count; cropped interface fragments (data-ui) are counted apart.
const svgSrc = (u) => /\.svg(\?|#|$)|^data:image\/svg/i.test(u || '');
let realPictures = 0, videoCount = 0; const pictureSamples = [];
for (const el of all) {
if (el.closest('[data-ui]') || el.closest('svg')) continue;
const r = el.getBoundingClientRect(); if (r.width < 120 || r.height < 90 || r.width * r.height < 160 * 120) continue;
if (el.tagName === 'IMG') {
const src = el.currentSrc || el.src;
if (el.naturalWidth > 0 && !svgSrc(src)) { realPictures++; if (pictureSamples.length < 8) pictureSamples.push(src.split('/').pop().slice(0, 48)); }
continue;
}
if (el.tagName === 'VIDEO') { realPictures++; videoCount++; continue; }
const bg = getComputedStyle(el).backgroundImage;
if (bg && bg.includes('url(')) { const u = (bg.match(/url\(["']?([^"')]+)/) || [])[1]; if (!svgSrc(u)) realPictures++; }
}
const uiPictures = [...document.querySelectorAll('[data-ui]')].filter((el) => { const r = el.getBoundingClientRect(); return r.width >= 300 && r.height >= 200; }).length;
// which elements push the page wider than the viewport (the deepest ones that are not clipped by an ancestor)
let overflowCulprits = [];
if (document.documentElement.scrollWidth > W + 2) {
const clippedByAncestor = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (getComputedStyle(p).overflowX !== 'visible' && p.getBoundingClientRect().right <= W + 2) return true; } return false; };
const wide = all.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.right > W + 2 && !clippedByAncestor(el); });
overflowCulprits = wide.filter((el) => !wide.some((o) => o !== el && el.contains(o))).slice(0, 3).map((el) => `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.classList.length ? '.' + [...el.classList].slice(0, 2).join('.') : ''} (+${Math.round(el.getBoundingClientRect().right - W)}px)`);
}
// rows that scroll sideways with content past their edge (read as clipped in screenshots and by most visitors)
const sideScrollers = all.filter((el) => { if (el.closest('[data-ui], table, pre, code')) return false; const cs = getComputedStyle(el); return /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 24 && el.clientHeight > 40; }).length;
const blendText = texts.filter((t) => { for (let p = t.el, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) if (/difference|exclusion/.test(getComputedStyle(p).mixBlendMode)) return true; return false; }).length;
const bodyText = document.body.innerText || '';
const emoji = (bodyText.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu) || []);
const doc = document.documentElement; const pageH = Math.max(doc.scrollHeight, document.body.scrollHeight);
const headingStats = headings.map(({ _el, ...h }) => h);
const heroOut = hero ? (({ _el, ...h }) => h)(hero) : null;
const below = headingStats.filter((h) => !heroOut || h.top !== heroOut.top);
return {
viewport: { w: W, h: H },
page: { height: pageH, screens: round(pageH / H, 1), words: bodyText.split(/\s+/).filter(Boolean).length,
img: document.images.length, video: document.querySelectorAll('video').length, canvas: document.querySelectorAll('canvas').length, svg: document.querySelectorAll('svg').length, sections: document.querySelectorAll('section').length,
horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10),
videosNoPoster: [...document.querySelectorAll('video')].filter((v) => !v.getAttribute('poster') && v.getBoundingClientRect().width >= 160).length },
hero: heroOut, h1,
headings: headingStats.slice(0, 40),
headingSummary: { count: headingStats.length, maxLines: Math.max(0, ...headingStats.map((h) => h.lines)), over2Lines: headingStats.filter((h) => h.lines > 2 && h.kind !== 'statement').length, statements: headingStats.filter((h) => h.kind === 'statement').length, centered: headingStats.filter((h) => h.align === 'center').length, medianWords: below.length ? below.map((h) => h.words).sort((a, b) => a - b)[Math.floor(below.length / 2)] : null, mixedFontHeadings: headingStats.filter((h) => h.mixedFonts).length },
type: {
families, familyCount: famChars.size, sizes, sizeCount: sizes.length, notLoaded,
body: bodySample ? { font: bodySample.fam, size: bodySample.size, leading: bodySample.lh, weight: bodySample.weight, tracking: bodySample.ls, contrast: (() => { const bg = bgBehind(bodySample.el); return bg ? contrast(rgba(bodySample.color), bg) : null; })() } : null,
measureChars: longest ? Math.round(longest.w / (longest.size * 0.5)) : null,
max: maxText ? { size: maxText.size, vw: round((maxText.size / W) * 100, 1), font: maxText.fam, text: maxText.text.slice(0, 40) } : null,
monoShare: round(monoChars / Math.max(1, totalChars)), monoProseShare: round(monoProse / Math.max(1, totalChars)), monoFamilies: [...monoFams], monoSamples,
microLabels, microLabelShare: round(microLabelChars / Math.max(1, totalChars), 3), microSamples,
upperShare: round(upperChars / Math.max(1, totalChars)), centerShare: round(centerChars / Math.max(1, totalChars)), smallText,
},
eyebrows, numbered: numbered.slice(0, 12).map((t) => ({ text: t.text.slice(0, 30), size: t.size, top: Math.round(t.top) })),
color: {
bodyBg: hex(bodyBg), bodyBgHsl: hsl(bodyBg), dark: lum(bodyBg) < 0.18, sectionBgs: sectionBgs.slice(0, 10), textColors,
button: filledBtns.length ? { bg: mode(filledBtns.map((b) => b.bg)), hsl: mode(filledBtns.map((b) => b.bgHsl)), fg: mode(filledBtns.map((b) => b.fg)) } : null,
lowContrast: lowContrast.slice(0, 6),
},
buttons: { count: buttons.length, inHero: buttons.filter((b) => b.top < H).length, radius: mode(buttons.map((b) => b.radius)), height: mode(buttons.map((b) => b.h)), withShadow: buttons.filter((b) => b.shadow).length, withGradient: buttons.filter((b) => b.gradient).length, mono: buttons.filter((b) => b.mono).length, upper: buttons.filter((b) => b.upper).length, heroLabels: buttons.filter((b) => b.top < H).map((b) => b.text).filter(Boolean).slice(0, 5) },
surfaces: { shadowStatic, shadowOverlay, shadowSamples, gradients, scrims, gradientSamples, gradientText, hairlines, hairlineSamples, outlinedBoxes, dashed, blurred, roundedBoxes, boxRadius: mode(boxRadii) ?? 0, squareMedia, roundedMedia, mediaRadius: mode(mediaRadii) ?? 0, textShadow: texts.filter((t) => t.textShadow).length, cardRows: cardRows.slice(0, 8) },
heroMedia: heroMedia.slice(0, 4), nav,
fontsLoaded: [...loaded].slice(0, 12),
};
}
// frame() runs at each scroll position the inspector photographs. It reports large text that something
// else is lying on top of (a picture sliding over a headline), and how much of the screen holds anything.
export function frame() {
const W = innerWidth, H = innerHeight;
const pinnedTop = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const cs = getComputedStyle(p); if ((cs.position === 'fixed' || cs.position === 'sticky') && p.getBoundingClientRect().top < 140 && p.getBoundingClientRect().height < 180) return true; } return false; };
// 1. covered text. Hit-testing skips elements with pointer-events: none (a pinned title usually has it),
// which would make whatever lies beneath look as if it were on top; switch it on while sampling.
const hitStyle = document.createElement('style');
hitStyle.textContent = '*, *::before, *::after { pointer-events: auto !important; }';
document.head.appendChild(hitStyle);
const covered = [], clipped = [], pinnedOver = [];
const label = (el, direct) => (el.innerText || direct).replace(/\s+/g, ' ').trim().slice(0, 48);
const pinnedLow = (el) => { for (let p = el; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.position === 'fixed' || pcs.position === 'sticky') return !pinnedTop(el); } return false; };
// Does the element found on top actually paint anything there? A transparent layout box does not hide text.
const paints = (hit, el) => {
for (let p = hit; p && p !== document.body && !p.contains(el); p = p.parentElement) {
if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME|svg)$/.test(p.tagName) || p.closest('svg')) return true;
const pcs = getComputedStyle(p);
if (pcs.backgroundImage !== 'none') return true;
const m = pcs.backgroundColor.match(/rgba?\(([^)]+)\)/); const parts = m ? m[1].split(/[,\s/]+/).filter(Boolean) : [];
if (parts.length && (parts.length < 4 || parseFloat(parts[3]) > 0.05)) return true;
if (p === hit) for (const n of p.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) return true; // its own text lies on ours
}
return false;
};
const pictures = [...document.querySelectorAll('img, picture, svg, canvas, video')].filter((m) => !m.parentElement?.closest('svg')).map((m) => ({ m, r: m.getBoundingClientRect() })).filter(({ r }) => r.width >= 80 && r.height >= 80 && r.bottom > 0 && r.top < H);
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, li, a, button, span, div')) {
let direct = ''; for (const n of el.childNodes) if (n.nodeType === 3) direct += n.nodeValue;
if (direct.trim().length < 3) continue;
const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); if (size < 24 || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
let srOnly = false;
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) { const pcs = getComputedStyle(p); const pr = p.getBoundingClientRect(); if (pcs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(pcs.clipPath) || (pr.width <= 2 && pr.height <= 2 && pcs.overflow !== 'visible')) { srOnly = true; break; } }
if (srOnly) continue;
// the boxes that clip this text (overflow other than visible, or a clip-path)
const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); }
// a scroll scene moves pictures across its own held stage by design; its text is judged by the scene rules
const pinned = size >= 32 && pinnedLow(el) && !el.closest('.scene.is-live');
// lines of one heading set as separate spans overlap each other's glyph boxes; that is not a collision
const unit = el.closest('h1, h2, h3, h4, h5, h6, p, blockquote');
const range = document.createRange(); let total = 0, hidden = 0, cutTop = false, area = 0, over = 0;
for (const node of el.childNodes) {
if (node.nodeType !== 3 || !node.nodeValue.trim()) continue;
range.selectNodeContents(node);
for (const r of range.getClientRects()) {
if (r.width < 6 || r.bottom < 0 || r.top > H) continue;
// sampled at two heights inside the line box (a display face's glyph box often spills past it and
// under the next line), so a band lying over the top or the bottom of the letters is found too
const lh = parseFloat(cs.lineHeight); const band = Math.min(r.height, Number.isFinite(lh) ? lh : r.height);
for (const dy of [-0.2, 0.2]) for (const fx of [0.12, 0.37, 0.63, 0.88]) {
const x = r.left + r.width * fx, y = r.top + r.height * 0.55 + band * dy; if (x < 0 || x >= W || y < 0 || y >= H) continue;
total++; const hit = document.elementFromPoint(x, y);
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el) && !pinnedTop(hit) && !(unit && unit.contains(hit) && !/^(IMG|VIDEO|CANVAS|PICTURE|svg)$/.test(hit.tagName) && !hit.closest('svg')) && paints(hit, el)) hidden++;
}
// the glyph box starts above the capitals (ascender room), so only a cut well into it is a real crop
if (size >= 40 && r.top >= -4 && clips.some((c) => c.top > r.top + r.height * 0.3 && c.top < r.bottom - r.height * 0.2)) cutTop = true;
if (pinned) { area += r.width * r.height; for (const { m, r: pr } of pictures) { if (m.contains(el) || el.contains(m)) continue; const ox = Math.min(r.right, pr.right) - Math.max(r.left, pr.left), oy = Math.min(r.bottom, pr.bottom) - Math.max(r.top, pr.top); if (ox > 0 && oy > 0) over += ox * oy; } }
}
}
if (total >= 4 && hidden / total >= 0.2) covered.push({ text: label(el, direct), share: Math.round((hidden / total) * 100) / 100 });
if (cutTop) clipped.push({ text: label(el, direct) });
if (pinned && area && over / area >= 0.15) pinnedOver.push({ text: label(el, direct), share: Math.min(1, Math.round((over / area) * 100) / 100) });
}
hitStyle.remove();
// 2. how much of the screen holds anything: text glyph boxes, media, or a ground that differs from the page's
const opaque = (c) => c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c);
const pageBg = [document.body, document.documentElement].map((e) => getComputedStyle(e).backgroundColor).find(opaque) || 'rgb(255, 255, 255)';
const textRects = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
if (!n.nodeValue.trim() || !n.parentElement) continue; const pcs = getComputedStyle(n.parentElement); if (pcs.visibility === 'hidden' || +pcs.opacity < 0.2) continue;
rg.selectNodeContents(n); for (const r of rg.getClientRects()) if (r.bottom > 0 && r.top < H && r.width > 1) textRects.push(r);
}
let filled = 0, samples = 0;
for (let gx = 0; gx < 24; gx++) for (let gy = 0; gy < 15; gy++) {
const x = (gx + 0.5) * (W / 24), y = (gy + 0.5) * (H / 15); samples++;
if (textRects.some((r) => x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 4 && y <= r.bottom + 4)) { filled++; continue; }
let el = document.elementFromPoint(x, y);
for (let i = 0; el && i < 6 && el !== document.body && el !== document.documentElement; i++, el = el.parentElement) {
if (/^(IMG|VIDEO|CANVAS|PICTURE|svg|IFRAME)$/.test(el.tagName) || el.closest('svg')) { filled++; break; }
const cs = getComputedStyle(el);
if (cs.backgroundImage !== 'none') { filled++; break; }
const bg = cs.backgroundColor; if (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { if (bg !== pageBg) filled++; break; }
}
}
// 3. the large text in view, to spot one statement held on screen for screen after screen
const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return !el.closest('.scene.is-live') && parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
return { covered, clipped, pinnedOver, filled: Math.round((filled / samples) * 100) / 100, big, y: Math.round(scrollY) };
}
// layout() runs once, back at the top after the page has been scrolled through (so reveals have fired).
// It measures the frame of the page rather than its details: the two outer margins, sections whose content
// huddles on one side, blank stretches with nothing in them, the title, untitled sections, text pressed
// against the edge of the screen and how many left edges the text uses.
export function layout() {
const W = innerWidth, H = innerHeight, SY = scrollY;
const D = document.documentElement, B = document.body;
const pageH = Math.max(D.scrollHeight, B.scrollHeight);
const pct = (arr, p) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * p)))]; };
const shown = (el) => { for (let p = el, i = 0; p && p !== B && i < 30; p = p.parentElement, i++) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; if (i < 4 && (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath))) return false; } return true; };
const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; };
const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
const boxes = [];
// text, one box per text node (its line boxes merged)
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) {
const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t.length < 2) continue;
const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue;
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
if (!shown(el)) continue;
const cs = getComputedStyle(el);
boxes.push({ kind: 'text', el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
}
// pictures, drawings and controls
for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
const tag = el.tagName;
let kind = null;
if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(tag)) kind = 'media';
else if (tag === 'svg') { if (el.parentElement && el.parentElement.closest('svg')) continue; kind = 'media'; }
else if (/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(tag) || el.getAttribute('role') === 'button') kind = 'control';
else { const bi = getComputedStyle(el).backgroundImage; if (bi && bi.includes('url(')) kind = 'media'; else continue; }
const r = el.getBoundingClientRect(); if (r.width < 24 || r.height < 24) continue;
if (kind === 'media' && r.width * r.height < 48 * 48) continue;
if (!shown(el)) continue;
boxes.push({ kind, el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: 0, size: 0, layer: layerOf(el), tag });
}
const flow = boxes.filter((b) => b.layer !== 'fixed');
// 1. the frame: how far the page's content comes to each edge (full-bleed things say nothing about margins)
// a row that runs off one edge (a carousel, a bled display line) says nothing about that side's margin
const framed = flow.filter((b) => b.r - b.l < W * 0.97 && b.l >= -1 && b.r <= W + 1);
const L = framed.length ? Math.max(0, pct(framed.map((b) => b.l), 0.02)) : null;
const R = framed.length ? Math.max(0, W - pct(framed.map((b) => b.r), 0.98)) : null;
// 2. sections: descend through single wrappers, then take the blocks
const tall = (c) => { const r = c.getBoundingClientRect(); return r.height >= 120 && r.width >= W * 0.6 && getComputedStyle(c).position !== 'fixed' && shown(c); };
let root = B;
for (let i = 0; i < 12; i++) { const kids = [...root.children].filter(tall); if (kids.length === 1) root = kids[0]; else break; }
// a tall block is split into its parts; the parts of a block that has its own heading continue it
let blocks = [...root.children].filter(tall).map((el) => ({ el, cont: false }));
for (let pass = 0; pass < 2; pass++) blocks = blocks.flatMap((b) => { const k = [...b.el.children].filter(tall); if (b.el.getBoundingClientRect().height > H * 1.6 && k.length >= 2) { const titled = b.cont || !!b.el.querySelector('h1, h2, h3, h4, [role="heading"]'); return k.map((el) => ({ el, cont: titled })); } return [b]; });
const continues = new Set(blocks.filter((b) => b.cont).map((b) => b.el));
blocks = blocks.map((b) => b.el);
const bodySize = (() => { const m = new Map(); for (const b of flow) if (b.kind === 'text' && b.chars >= 40) m.set(Math.round(b.size), (m.get(Math.round(b.size)) || 0) + b.chars); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 16; })();
const sections = blocks.slice(0, 40).map((s, i) => {
const r = s.getBoundingClientRect(); const top = r.top + SY, bot = r.bottom + SY;
const inside = flow.filter((b) => b.t >= top - 2 && b.b <= bot + 2 && s.contains(b.el));
const own = inside.filter((b) => b.r - b.l < W * 0.97);
const text = inside.filter((b) => b.kind === 'text');
const media = inside.filter((b) => b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= 160 * 120);
const words = text.reduce((a, b) => a + b.text.split(' ').length + Math.max(0, Math.round((b.chars - 50) / 6)), 0);
const headRe = /^H[1-4]$/;
const heading = text.filter((b) => (b.el.closest('h1,h2,h3,h4,[role="heading"]') || b.size >= Math.max(22, bodySize * 1.35)) && b.t <= top + Math.max(520, (bot - top) * 0.45)).sort((a, b) => b.size - a.size)[0];
const cs = getComputedStyle(s);
// the widest stretch across the section's content band that nothing covers, edge to edge excluded
let middle = 0;
if (own.length >= 2) {
const iv = own.map((b) => [Math.max(0, b.l), Math.min(W, b.r)]).sort((a, b) => a[0] - b[0]);
let end = iv[0][1];
for (const [a, b2] of iv.slice(1)) { if (a > end) middle = Math.max(middle, a - end); end = Math.max(end, b2); }
}
return {
sel: sel(s), tag: s.tagName.toLowerCase(), top: Math.round(top), height: Math.round(r.height), words, media: media.length, middle: Math.round(middle),
heading: heading ? { text: heading.text, size: Math.round(heading.size), tag: (heading.el.closest('h1,h2,h3,h4,h5,h6') || heading.el).tagName.toLowerCase() } : null,
left: own.length ? Math.round(Math.max(0, Math.min(...own.map((b) => b.l)))) : null, right: own.length ? Math.round(Math.max(0, W - Math.max(...own.map((b) => b.r)))) : null,
textLeft: text.length ? Math.round(Math.min(...text.map((b) => b.l))) : null, textRight: text.length ? Math.round(W - Math.max(...text.map((b) => b.r))) : null,
fullBleed: inside.some((b) => b.kind === 'media' && b.r - b.l >= W * 0.97), footer: s.tagName === 'FOOTER' || !!s.querySelector('footer') || i === blocks.length - 1,
hero: top < H * 0.5, continues: continues.has(s), pinned: cs.position === 'sticky' || !!s.querySelector('.pin-spacer') || [...s.querySelectorAll('*')].slice(0, 400).some((e) => getComputedStyle(e).position === 'sticky' && e.getBoundingClientRect().height > H * 0.5),
};
});
// 3. blank stretches: rows of the page with no text, picture or control, between the first and the last content.
// A sticky element's track (its parent) and a pinning spacer are scroll distance, not blank page.
const STEP = 4, rows = Math.ceil(pageH / STEP), occ = new Uint8Array(rows);
const mark = (t, b, v) => { for (let y = Math.max(0, Math.floor(t / STEP)); y < Math.min(rows, Math.ceil(b / STEP)); y++) occ[y] = v; };
for (const b of flow) if (b.layer !== 'sticky') mark(b.t, b.b, 1);
for (const el of B.querySelectorAll('.pin-spacer')) { const r = el.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); }
let scanned = 0;
for (const el of B.querySelectorAll('*')) { if (++scanned > 9000) break; const cs = getComputedStyle(el); if (cs.position === 'sticky' && el.parentElement) { const r = el.parentElement.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } }
let first = 0; while (first < rows && occ[first] !== 1) first++;
let last = rows - 1; while (last > 0 && occ[last] !== 1) last--;
const gaps = []; let run = 0;
for (let y = first; y <= last + 1; y++) { if (y <= last && occ[y] === 0) run++; else { if (run * STEP >= H * 0.3) gaps.push({ top: (y - run) * STEP, height: run * STEP }); run = 0; } }
// 4. the title
const h1s = [...document.querySelectorAll('h1')].filter((h) => { const r = h.getBoundingClientRect(); return r.width > 0 && r.height > 0 && shown(h); }).map((h) => { const r = h.getBoundingClientRect(); return { text: (h.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80), size: Math.round(parseFloat(getComputedStyle(h).fontSize)), top: Math.round(r.top + SY), firstScreen: r.top + SY < H * 0.95 }; });
// 5. text pressed against the edge of the screen (type deliberately bled past the edge is a different device)
// fixed headers and corner labels count here: that is where text most often creeps to the edge
const edge = 16;
const edgeText = boxes.filter((b) => b.kind === 'text' && b.chars >= 3 && b.size < 40 && b.l >= -1 && b.r <= W + 1 && (b.l < edge || b.r > W - edge) && !b.el.closest('[class*="marquee" i],[class*="ticker" i],marquee,[data-ui],table')).slice(0, 8).map((b) => ({ text: b.text, side: b.l < edge ? 'left' : 'right', px: Math.round(b.l < edge ? b.l : W - b.r) }));
// display type that runs off one edge by a sliver while it starts inside the margin: a fit error, not a bleed
const slivers = boxes.filter((b) => b.kind === 'text' && b.size >= 40 && b.chars >= 3 && ((b.l >= 8 && b.r > W + 1 && b.r - W < b.size * 0.25) || (b.r <= W - 8 && b.l < -1 && -b.l < b.size * 0.25))).slice(0, 4).map((b) => ({ text: b.text.slice(0, 30), px: Math.round(b.r > W ? b.r - W : -b.l), side: b.r > W ? 'right' : 'left' }));
// the largest empty rectangle on the first screen (below a 90px band for the navigation), on a 24 x 15 grid
let voidShare = 0, voidBox = null;
if (W >= 1000) {
const C = 24, Rn = 15, cw = W / C, ch = H / Rn, occ2 = Array.from({ length: Rn }, () => new Array(C).fill(0));
for (const b of boxes) { if (b.t - SY > H || b.b - SY < 0) continue; const c0 = Math.max(0, Math.floor(b.l / cw)), c1 = Math.min(C - 1, Math.floor((b.r - 1) / cw)), r0 = Math.max(0, Math.floor((b.t - SY) / ch)), r1 = Math.min(Rn - 1, Math.floor((b.b - SY - 1) / ch)); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) occ2[r][c] = 1; }
const top = Math.ceil(90 / ch), hgt = new Array(C).fill(0);
for (let r = top; r < Rn; r++) {
for (let c = 0; c < C; c++) hgt[c] = occ2[r][c] ? 0 : hgt[c] + 1;
for (let c = 0; c < C; c++) { let mn = Infinity; for (let k = c; k < C && hgt[k]; k++) { mn = Math.min(mn, hgt[k]); const a = mn * (k - c + 1); if (a > voidShare) { voidShare = a; voidBox = { x: Math.round(c * cw), y: Math.round((r - mn + 1) * ch), w: Math.round((k - c + 1) * cw), h: Math.round(mn * ch) }; } } }
}
voidShare = Math.round((voidShare / (C * Rn)) * 100) / 100;
}
// a fixed picture layer that covers the screen (a canvas scene, a film) hides what the DOM cannot see
const stage = boxes.some((b) => b.layer === 'fixed' && b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= W * H * 0.9);
// the first screen: is the title a bare name, with nothing at reading size saying what this is?
const firstText = flow.filter((b) => b.kind === 'text' && b.t < H * 0.95 && b.size >= 18 && !b.el.closest('h1, nav, header nav, button, a'));
const explains = firstText.some((b) => b.text.split(' ').length >= 6 || b.chars >= 36);
// 6. left edges used by running text and headings (left-aligned blocks only)
// headings and paragraphs only: the items of a grid or a list each start their own column on purpose
const lefts = flow.filter((b) => b.kind === 'text' && b.size >= 14 && !/center|right|end/.test(b.align) && b.l >= 0 && (b.el.closest('h1, h2, h3') || (b.chars >= 60 && b.el.closest('p, blockquote'))) && !b.el.closest('nav, footer, [data-ui], table, li, figcaption')).map((b) => b.l).sort((a, b) => a - b);
const edges = []; for (const x of lefts) { const e = edges.find((g) => Math.abs(g.x - x) <= 10); if (e) e.n++; else edges.push({ x: Math.round(x), n: 1 }); }
edges.sort((a, b) => b.n - a.n);
return { viewport: { w: W, h: H }, pageH, margins: { left: L == null ? null : Math.round(L), right: R == null ? null : Math.round(R) }, sections, gaps, h1s, edgeText, edges: edges.slice(0, 12), leftBlocks: lefts.length, bodySize, stage, explains, slivers, voidShare, voidBox };
}
// app() measures what an app screen (dashboard, admin, settings, inbox) gets right or wrong: the shell,
// how much of the canvas is used, panels and their edges, numbers in tables and KPIs, chart colours,
// the icon-in-a-tinted-square habit, placeholder data, and what happens to the sidebar on a phone.
export function app() {
const W = innerWidth, H = innerHeight, SY = scrollY, B = document.body;
const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
const ctx = cvs.getContext('2d', { willReadFrequently: true });
const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; };
const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100; };
const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; };
const shown = (el, cs) => { cs = cs || getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
const groundOf = (el) => { for (let p = el; p; p = p.parentElement) { const v = rgba(getComputedStyle(p).backgroundColor); if (v.a >= 0.9) return v; } return { r: 255, g: 255, b: 255, a: 1 }; };
const pageBg = groundOf(B);
const all = [...B.querySelectorAll('*')].slice(0, 12000);
// 1. the shell: a tall column at one side (sidebar) and a bar across the top
let sidebar = null, topbar = null;
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 40) continue;
if (!sidebar && r.left <= 1 && r.top <= 80 && r.height >= H * 0.7 && r.width >= 48 && r.width <= Math.min(360, W * 0.45) && (el.querySelectorAll('a, button').length >= 3) && shown(el)) sidebar = { w: Math.round(r.width), tag: el.tagName.toLowerCase(), links: el.querySelectorAll('a, button').length, share: Math.round((r.width / W) * 100) / 100 };
if (!topbar && r.top <= 1 && r.left <= (sidebar ? sidebar.w + 2 : 2) && r.width >= W * 0.6 && r.height >= 36 && r.height <= 96 && shown(el)) topbar = { h: Math.round(r.height) };
if (sidebar && topbar) break;
}
// 2. how much of the canvas the content uses (right of the sidebar)
const x0 = sidebar ? sidebar.w : 0;
// the text itself (a block element's box runs to the edge whatever its text does), pictures, charts and controls
const boxes = [];
const tw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const trg = document.createRange();
for (let n = tw.nextNode(); n && boxes.length < 5000; n = tw.nextNode()) {
if (n.nodeValue.trim().length < 2 || !n.parentElement || n.parentElement.closest('svg')) continue;
const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue;
trg.selectNodeContents(n); const r = trg.getBoundingClientRect(); if (r.width < 2 || r.left < x0 - 1 || (cs.position === 'fixed' && r.left > W * 0.5)) continue;
boxes.push(r);
}
for (const el of B.querySelectorAll('svg, canvas, img, video, input, select, textarea, button, table')) {
if (el.parentElement && el.parentElement.closest('svg')) continue;
const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || r.left < x0 - 1 || !shown(el)) continue;
boxes.push(r);
}
const rights = boxes.map((r) => r.right).sort((a, b) => a - b), lefts = boxes.map((r) => r.left).sort((a, b) => a - b);
const canvas = boxes.length ? { left: Math.round(lefts[Math.floor(lefts.length * 0.02)] - x0), right: Math.round(W - rights[Math.floor(rights.length * 0.98)]) } : null;
// 3. panels: boxes that read as cards (a fill one step from the ground, a border, or a shadow)
const panels = []; const radii = new Map(); let heavyBorders = 0, heavyShadows = 0, softShadows = 0, glass = 0; const borderSamples = [], shadowSamples = [];
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 140 || r.height < 72 || r.width > W * 0.98) continue;
const cs = getComputedStyle(el); if (!shown(el, cs)) continue;
const bg = rgba(cs.backgroundColor), bw = parseFloat(cs.borderTopWidth) || 0, bc = rgba(cs.borderTopColor);
const ground = groundOf(el.parentElement || B);
const filled = bg.a >= 0.9 && contrast(bg, ground) >= 1.04, bordered = bw >= 0.5 && cs.borderTopStyle !== 'none' && bc.a > 0.05;
const sh = cs.boxShadow !== 'none' ? cs.boxShadow : '';
if (!filled && !bordered && !sh) continue;
if (!(el.innerText || '').trim()) continue;
panels.push({ el, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top + SY), left: Math.round(r.left), bottom: Math.round(r.bottom + SY), right: Math.round(r.right) });
const rad = Math.round(parseFloat(cs.borderTopLeftRadius) || 0); radii.set(rad, (radii.get(rad) || 0) + 1);
if (bordered) { const c = contrast({ ...bc, a: 1 }, bg.a >= 0.9 ? bg : ground); if (c >= (lum(ground) > 0.4 ? 1.45 : 1.9)) { heavyBorders++; if (borderSamples.length < 3) borderSamples.push(`${hex(bc)} on ${hex(bg.a >= 0.9 ? bg : ground)} (${c}:1)`); } }
if (sh) {
for (const part of sh.split(/,(?![^(]*\))/)) {
if (/inset/.test(part)) continue; const col = part.match(/rgba?\([^)]+\)/); const nums = part.replace(/rgba?\([^)]+\)/, '').trim().split(/\s+/).map(parseFloat);
const a = col ? rgba(col[0]).a : 1; const blur = nums[2] || 0, y = Math.abs(nums[1] || 0);
if (a <= 0.02 || (blur === 0 && y === 0 && !(nums[3] > 0))) continue;
// on a dark ground a black shadow barely shows; there it only counts when it is near-opaque
const darkGround = lum(ground) < 0.2;
if (darkGround ? a >= 0.45 : ((a >= 0.12 && blur >= 12) || y >= 8 || a >= 0.25)) { heavyShadows++; if (shadowSamples.length < 3) shadowSamples.push(part.trim().slice(0, 70)); } else softShadows++;
}
}
if (cs.backdropFilter && cs.backdropFilter !== 'none') glass++;
}
// top-level panels only (a card inside a panel is part of it): rows that end ragged, and how many
// panels start on the first screen
const panelEls = new Set(panels.map((p) => p.el));
const tops = panels.filter((p) => { for (let a = p.el.parentElement; a && a !== B; a = a.parentElement) if (panelEls.has(a)) return false; return p.w < W * 0.97 || p.h < H * 2; });
// a column of cards stacked with a gutter of 24px or less is one block: two cards on the left that end level with
// one tall card on the right are not ragged. A pair is ragged only when the cards and their columns both end apart.
const below = (p) => tops.find((q) => q !== p && Math.abs(q.left - p.left) <= 4 && Math.abs(q.w - p.w) <= 4 && q.top >= p.bottom - 2 && q.top - p.bottom <= 24);
const columnBottom = (p) => { let b = p.bottom; for (let q = below(p), n = 0; q && n < 12; q = below(q), n++) b = q.bottom; return b; };
const ragged = [];
for (let i = 0; i < tops.length; i++) for (let k = i + 1; k < tops.length; k++) {
const a = tops[i], b = tops[k];
if (Math.abs(a.top - b.top) > 4 || a.w < 200 || b.w < 200 || (a.right > b.left - 4 && b.right > a.left - 4)) continue; // side by side, same row
const diff = Math.min(Math.abs(a.bottom - b.bottom), Math.abs(columnBottom(a) - columnBottom(b))), tall = Math.max(a.h, b.h);
if (diff > Math.max(24, tall * 0.12)) ragged.push({ diff, top: a.top });
}
// the largest blank band inside a card: the vertical gap between consecutive pieces of its content (text, charts,
// pictures, controls, rules, fills), or from the last piece to the card's padding edge. A legend pinned to the foot
// of a stretched card, or four rows in a card kept at its neighbour's height, leave one.
const voids = [];
const seen = new Map();
const visibleIn = (el, root) => { if (seen.has(el)) return seen.get(el); let ok = true; for (let p = el; p && p !== root.parentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05 || cs.position === 'fixed') { ok = false; break; } } seen.set(el, ok); return ok; };
for (const p of tops) {
if (p.el.matches('nav, aside, header, footer, dialog, [role="navigation"], [role="dialog"]') || p.el.querySelector('nav') || (sidebar && p.left < sidebar.w - 4) || p.h < 120) continue;
if (panels.filter((q) => q.el !== p.el && p.el.contains(q.el)).length >= 2) continue; // a frame of cards, not a card
const cs = getComputedStyle(p.el), pr = p.el.getBoundingClientRect();
const inTop = pr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0), inBottom = pr.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0);
const bg = rgba(cs.backgroundColor), spans = [];
const tw2 = document.createTreeWalker(p.el, NodeFilter.SHOW_TEXT), rg = document.createRange();
// text that opens a section (a heading, a summary, or text at 14px/600 or 15px/500 and up) may follow a wider, placed gap
for (let n = tw2.nextNode(); n; n = tw2.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement || n.parentElement.closest('svg') || !visibleIn(n.parentElement, p.el)) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { const pcs = getComputedStyle(n.parentElement); const fs = parseFloat(pcs.fontSize), fw = +pcs.fontWeight; spans.push([r.top, r.bottom, !!n.parentElement.closest('h1, h2, h3, h4, h5, h6, [role="heading"], summary, legend, caption') || (fw >= 600 && fs >= 14) || (fw >= 500 && fs >= 15)]); } }
for (const el of p.el.querySelectorAll('*')) {
if (el.parentElement && el.parentElement.closest('svg')) continue;
const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 1 || !visibleIn(el, p.el)) continue;
if (/^(svg|canvas|img|video|input|select|textarea|button|hr|progress|meter|iframe)$/i.test(el.tagName)) { spans.push([r.top, r.bottom]); continue; }
const ecs = getComputedStyle(el), fillC = rgba(ecs.backgroundColor);
if (fillC.a >= 0.1 && (bg.a < 0.9 || contrast({ ...fillC, a: 1 }, bg) >= 1.03) && r.height >= 2) spans.push([r.top, r.bottom]);
if ((parseFloat(ecs.borderTopWidth) || 0) >= 0.5 && ecs.borderTopStyle !== 'none' && rgba(ecs.borderTopColor).a > 0.05) spans.push([r.top, r.top + 1]);
if ((parseFloat(ecs.borderBottomWidth) || 0) >= 0.5 && ecs.borderBottomStyle !== 'none' && rgba(ecs.borderBottomColor).a > 0.05) spans.push([r.bottom - 1, r.bottom]);
}
const ys = spans.filter(([a, b]) => b > inTop - 2 && a < inBottom + 2).sort((a, b) => a[0] - b[0]);
if (!ys.length) continue;
// a band counts when it is over 40px, or over 64px when the content after it opens a new section
let end = ys[0][1], gap = 0, at = null;
for (const [a, b] of ys.slice(1)) { const g = a - end, section = ys.some(([t, , hd]) => hd && Math.abs(t - a) < 6); if (g > (section ? 64 : 40) && g > gap) { gap = g; at = end; } end = Math.max(end, b); }
if (inBottom - end > 40 && inBottom - end > gap) { gap = inBottom - end; at = end; }
const head = (p.el.querySelector('h1, h2, h3, h4, [role="heading"], header') || p.el).innerText || '';
if (gap > 40) voids.push({ gap: Math.round(gap), at: Math.round(at + SY), panel: head.trim().split('\n')[0].slice(0, 40) });
}
voids.sort((a, b) => b.gap - a.gap);
const firstScreen = tops.filter((p) => p.top < H - 48).length;
// the icon-in-a-tinted-square habit: small boxes with a coloured fill holding one icon, repeated
const chips = []; let tiles = 0;
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 24 || r.width > 64 || Math.abs(r.width - r.height) > 4) continue;
const cs = getComputedStyle(el); const bg = rgba(cs.backgroundColor); if (bg.a < 0.08) continue;
const icon = el.querySelector('svg, i, img, [class*="icon" i]'); if (!icon || (el.innerText || '').trim().length > 2) continue;
const c = hsl(bg); if (c.s < 0.18 && bg.a > 0.5) { if (r.width >= 28 && !el.closest('button, a, nav, aside, [role="navigation"]')) tiles++; continue; } // neutral: an avatar, a button, or a boxed icon
chips.push({ hue: c.h, size: Math.round(r.width), radius: cs.borderTopLeftRadius });
}
const chipHues = new Set(chips.map((c) => Math.round(c.hue / 30)));
// 5. numbers: tables and KPI figures
const numRe = /^[−\-+]?[$€£¥]?\s?\d[\d,.\s]*(%|[kKmMbB]|\s?(ms|s|h|min|GB|MB|kB|x))?$/;
const cells = [...B.querySelectorAll('td, [role="cell"], [role="gridcell"]')].filter((el) => numRe.test((el.innerText || '').trim()));
const leftNums = cells.filter((el) => { const a = getComputedStyle(el).textAlign; const inner = el.firstElementChild ? getComputedStyle(el.firstElementChild).textAlign : a; return /left|start/.test(a) && /left|start/.test(inner) && getComputedStyle(el).justifyContent !== 'flex-end'; }).length;
const figures = [];
for (const el of all) { if (el.children.length) continue; const t = (el.innerText || '').trim(); if (!numRe.test(t) || t.length < 2) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; figures.push({ size: parseFloat(cs.fontSize), tabular: /tabular-nums/.test(cs.fontVariantNumeric) || /"tnum"/.test(cs.fontFeatureSettings), weight: +cs.fontWeight }); }
const tabularShare = figures.length ? Math.round((figures.filter((f) => f.tabular).length / figures.length) * 100) / 100 : null;
const rows = [...B.querySelectorAll('tbody tr, [role="row"]')].map((tr) => Math.round(tr.getBoundingClientRect().height)).filter((h) => h > 0);
// 6. chart colours: saturated fills and strokes inside SVG charts and canvas-free charts
const hues = new Map();
for (const svg of B.querySelectorAll('svg')) {
const r = svg.getBoundingClientRect(); if (r.width < 120 || r.height < 60) continue;
for (const s of svg.querySelectorAll('path, rect, circle, line, polyline, polygon')) {
const cs = getComputedStyle(s);
for (const c of [cs.fill, cs.stroke]) { if (!c || c === 'none' || c.startsWith('url')) continue; const v = rgba(c); if (v.a < 0.3) continue; const h = hsl(v); if (h.s < 0.25 || h.l < 0.12 || h.l > 0.92) continue; const k = Math.round(h.h / 24); hues.set(k, (hues.get(k) || 0) + 1); }
}
}
// bars and segments drawn with divs count too (a common way to fake a chart)
// 7. placeholder data
const text = B.innerText || '';
const placeholders = [...new Set((text.match(/\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bLorem ipsum\b|\bexample\.com\b|\bUser Name\b|\bFirstname Lastname\b|\b123 Main St\b|\bfoo(bar)?\b/gi) || []).map((s) => s.toLowerCase()))];
// 8. type at app sizes
const sizes = new Map(); let tiny = 0; const tinySamples = [];
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n.nodeValue.trim(); if (t.length < 2 || !n.parentElement) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const s = Math.round(parseFloat(cs.fontSize)); sizes.set(s, (sizes.get(s) || 0) + t.length); if (s < 11 && t.length >= 3) { tiny++; if (tinySamples.length < 4) tinySamples.push(t.slice(0, 24) + ' ' + s + 'px'); } }
const total = [...sizes.values()].reduce((a, b) => a + b, 0) || 1;
const under13 = Math.round(([...sizes.entries()].filter(([s]) => s < 13).reduce((a, [, c]) => a + c, 0) / total) * 100) / 100;
const h1 = document.querySelector('h1'); const h1r = h1 && h1.getBoundingClientRect();
// the screen's hero: its h1 when that is on the first screen, else the largest text there. Text is read with a
// space between element children, so a figure and its unit ("£55.94" and "at cost") do not run together.
const spaced = (el) => { const out = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t && n.parentElement && !n.parentElement.closest('svg, script, style') && getComputedStyle(n.parentElement).display !== 'none') out.push(t); } return out.join(' ').replace(/\s+([,.;:!?%])/g, '$1').trim(); };
let heroEl = h1 && h1r && h1r.width > 0 && h1r.top + SY < H * 0.95 ? h1 : null;
if (!heroEl) {
let best = null; const hw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
for (let n = hw.nextNode(); n; n = hw.nextNode()) {
const el = n.parentElement; if (!el || n.nodeValue.trim().length < 2 || el.closest('svg, nav, aside, [role="navigation"]')) continue;
const r = el.getBoundingClientRect(); if (r.top + SY > H * 0.95 || r.width < 1 || (sidebar && r.left < sidebar.w - 2)) continue;
const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || +cs.opacity < 0.05) continue;
const size = parseFloat(cs.fontSize); if (!best || size > best.size) best = { el, size };
}
if (best) { heroEl = best.el; for (let p = heroEl; p && p !== B; p = p.parentElement) if (!/^inline/.test(getComputedStyle(p).display)) { heroEl = p; break; } }
}
let hero = null;
if (heroEl) {
const big = [...heroEl.querySelectorAll('*'), heroEl].map((e) => ({ e, s: parseFloat(getComputedStyle(e).fontSize) })).filter((o) => [...o.e.childNodes].some((c) => c.nodeType === 3 && c.nodeValue.trim())).sort((a, b) => b.s - a.s)[0]?.e || heroEl;
const cs = getComputedStyle(big), size = parseFloat(cs.fontSize), txt = spaced(heroEl), r = heroEl.getBoundingClientRect();
const tops2 = []; const lw = document.createTreeWalker(heroEl, NodeFilter.SHOW_TEXT); const lr = document.createRange();
for (let n = lw.nextNode(); n; n = lw.nextNode()) { if (!n.nodeValue.trim()) continue; lr.selectNodeContents(n); for (const rc of lr.getClientRects()) { const mid = rc.top + rc.height / 2; if (rc.width >= 1 && !tops2.some((t) => Math.abs(t - mid) < size * 0.55)) tops2.push(mid); } }
const lh = parseFloat(cs.lineHeight);
hero = { text: txt.slice(0, 200), words: txt.split(' ').filter(Boolean).length, chars: txt.length, lines: Math.max(1, tops2.length), size: Math.round(size), vw: Math.round((size / W) * 1000) / 10, font: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), weight: +cs.fontWeight, tracking: Math.round(((parseFloat(cs.letterSpacing) || 0) / size) * 100) / 100, leading: Number.isFinite(lh) ? Math.round((lh / size) * 100) / 100 : 1.2, top: Math.round(r.top + SY), tag: heroEl.tagName.toLowerCase(), kind: 'heading', align: 'left', position: 'left' };
}
// charts and timelines on the screen (for the data-hue check): sizeable SVGs with marks, or blocks named as one
const chartRe = /(^|[\s_-])(chart|plot|graph|timeline|calendar|cal|gantt|heatmap|matrix|strip)([\s_-]|$)/i;
const chartCount = [...B.querySelectorAll('svg')].filter((s) => { if (s.parentElement && s.parentElement.closest('svg')) return false; const r = s.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && s.querySelectorAll('path, rect, line, polyline, circle').length >= 6 && shown(s); }).length
+ [...B.querySelectorAll('[class]')].filter((e) => typeof e.className === 'string' && chartRe.test(e.className) && !e.closest('svg') && (() => { const r = e.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && shown(e); })()).length;
return {
viewport: { w: W, h: H }, dark: lum(pageBg) < 0.2, ground: hex(pageBg),
sidebar, topbar, canvas, hero, charts: chartCount,
layout: { panels: tops.length, firstScreen, ragged: ragged.slice(0, 6), voids: voids.slice(0, 4) },
tiles,
panels: { count: panels.length, radii: [...radii.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), heavyBorders, borderSamples, heavyShadows, softShadows, shadowSamples, glass },
chips: { count: chips.length, hues: chipHues.size, samples: chips.slice(0, 4) },
numbers: { cells: cells.length, leftAligned: leftNums, figures: figures.length, tabularShare, kpiMax: figures.length ? Math.max(...figures.map((f) => f.size)) : null },
rows: rows.length ? { count: rows.length, median: rows.sort((a, b) => a - b)[Math.floor(rows.length / 2)] } : null,
chartHues: hues.size,
placeholders,
type: { under13, tiny, tinySamples, sizes: [...sizes.keys()].sort((a, b) => a - b) },
title: h1 && h1r.width > 0 ? { text: (h1.innerText || '').trim().slice(0, 60), size: Math.round(parseFloat(getComputedStyle(h1).fontSize)), top: Math.round(h1r.top + SY) } : null,
};
}
// Share of a screenshot's pixels that are strongly coloured (saturated, neither near-black nor near-white).
// The studied app screens keep it near 3%; pale tints barely count. Runs inside any page; takes a data: URL.
export async function colourShare(dataUrl) {
const img = new Image(); img.src = dataUrl; await img.decode();
const w = 240, h = Math.max(1, Math.round((240 * img.height) / img.width));
const c = document.createElement('canvas'); c.width = w; c.height = h;
const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
const d = x.getImageData(0, 0, w, h).data; let n = 0;
for (let i = 0; i < d.length; i += 4) {
const r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn);
if (s >= 0.4 && l >= 0.18 && l <= 0.82) n++;
}
return Math.round((n / (w * h)) * 1000) / 1000;
}
// Dominant colours of a screenshot. Runs inside any page; takes a data: URL.
export async function palette(dataUrl) {
const img = new Image(); img.src = dataUrl; await img.decode();
const w = 96, h = Math.max(1, Math.round((96 * img.height) / img.width));
const c = document.createElement('canvas'); c.width = w; c.height = h;
const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
const d = x.getImageData(0, 0, w, h).data; const bins = new Map();
for (let i = 0; i < d.length; i += 4) {
const k = (d[i] >> 4) << 8 | (d[i + 1] >> 4) << 4 | (d[i + 2] >> 4);
const b = bins.get(k) || [0, 0, 0, 0]; b[0] += d[i]; b[1] += d[i + 1]; b[2] += d[i + 2]; b[3]++; bins.set(k, b);
}
const total = w * h;
return [...bins.values()].sort((a, b) => b[3] - a[3]).slice(0, 6).map((b) => ({
color: '#' + [0, 1, 2].map((i) => Math.round(b[i] / b[3]).toString(16).padStart(2, '0')).join(''), share: Math.round((b[3] / total) * 100) / 100,
}));
}
@@ -0,0 +1,243 @@
// Turns probe() metrics into findings. Each finding: { level: 'FAIL'|'WARN', id, message, fix }.
// FAIL = a banned tell or a broken page; the work is not done while one remains.
// WARN = usually wrong; keep only with a reason you could say out loud to a designer.
const inRange = (h, a, b) => (a <= b ? h >= a && h <= b : h >= a || h <= b);
// Colour families this skill treats as generated-page signatures.
export function colourFamily(hsl) {
if (!hsl) return null;
const { h, s, l } = hsl;
if (inRange(h, 22, 62) && s >= 0.16 && l >= 0.88 && l < 0.995) return 'cream';
if (inRange(h, 6, 30) && s >= 0.38 && s <= 0.85 && l >= 0.42 && l <= 0.72) return 'terracotta';
if (inRange(h, 244, 295) && s >= 0.42 && l >= 0.3 && l <= 0.8) return 'ai-purple';
return null;
}
const hexToHsl = (hex) => {
const n = parseInt(hex.slice(1), 16); const r = (n >> 16) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0;
if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; }
return { h: Math.round(h), s, l };
};
export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
const out = [];
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
const mobile = width < 600;
// --- headlines
// A title holds to two lines. A statement (a paragraph, quotation or list item set large) may run
// longer, but only at statement size; set at display size it is a headline again. In blind tests jurors
// read any large sentence of three lines or more as a long headline, so statement size is kept modest.
const stmtMaxPx = width < 600 ? 24 : width < 1100 ? 32 : 36;
const stmtMaxLines = width < 600 ? 4 : 3;
const isStatement = (h) => h.kind === 'statement' && h.size <= stmtMaxPx;
const long = m.headings.filter((h) => h.lines > 2 && !isStatement(h));
if (long.length) {
add('FAIL', 'headline-lines', `${long.length} headline(s) wrap past two lines at ${width}px: ` + long.slice(0, 4).map((h) => `"${h.text.slice(0, 48)}" → ${h.lines} lines (${h.words} words, ${h.size}px${h.kind === 'statement' ? ', a paragraph set above ' + stmtMaxPx + 'px' : ''})`).join('; '),
'Cut words first (a headline is a title, not a sentence). Then give it a wider measure. Lower the size only as a last resort and never below the scale you chose. A real statement paragraph belongs in a <p> or <blockquote> at ' + stmtMaxPx + 'px or less.');
}
const longStatements = m.headings.filter((h) => isStatement(h) && h.lines > stmtMaxLines);
if (longStatements.length) {
add('WARN', 'statement-length', `${longStatements.length} large-set statement(s) run past ${stmtMaxLines} lines at ${width}px: ` + longStatements.slice(0, 3).map((h) => `"${h.text.slice(0, 40)}…" → ${h.lines} lines at ${h.size}px`).join('; '),
'A statement set large is about 30 words. Cut it, or set the rest as ordinary body text.');
}
if (m.hero && m.hero.words > 10) add('WARN', 'hero-words', `Hero headline is ${m.hero.words} words ("${m.hero.text.slice(0, 60)}"). Gallery median is 5.`, 'Say the one thing. Move the rest to the supporting line.');
if (m.headingSummary.mixedFontHeadings) add('WARN', 'mixed-font-headline', `${m.headingSummary.mixedFontHeadings} headline(s) switch typeface or to italic mid-line (the "one italic serif word" move).`, 'Set the whole headline in one face. Emphasis comes from the words.');
// --- banned furniture
if (m.eyebrows.length) {
add('FAIL', 'eyebrow', `${m.eyebrows.length} eyebrow/kicker label(s) above headings: ` + m.eyebrows.slice(0, 5).map((e) => `"${e.text}"${e.pill ? ' (pill)' : ''} above "${e.above}"`).join('; '),
'Delete them. If the label carried real information, fold it into the headline or the supporting sentence.');
}
if (m.type.monoProseShare > 0) {
add('FAIL', 'mono', `Monospace type outside code: ${m.type.monoFamilies.join(', ')} — e.g. ${m.type.monoSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}`,
'Set it in the text face. Monospace is for literal code inside <pre>/<code> only.');
}
if (m.numbered.length) {
add('FAIL', 'numbering', `Decorative index numbers: ${m.numbered.slice(0, 6).map((n) => `"${n.text}"`).join(', ')}`,
'Remove them. If order truly matters, write it into the words ("First, …") or use a real <ol>.');
}
if (m.type.microLabels >= 3) add('FAIL', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced labels (e.g. ${m.type.microSamples.slice(0, 4).map((s) => `"${s}"`).join(', ')}).`, 'Use sentence-case text at a readable size, or delete the label.');
else if (m.type.microLabels > 0) add('WARN', 'micro-label', `${m.type.microLabels} tiny uppercase letter-spaced label(s): ${m.type.microSamples.slice(0, 3).map((s) => `"${s}"`).join(', ')}`, 'Prefer sentence case at body size.');
if (m.page.emoji.length) add('FAIL', 'emoji', `Emoji in the page: ${m.page.emoji.join(' ')}`, 'Remove. Use words or real imagery.');
// --- surfaces
const s = m.surfaces;
if (s.gradients) add('FAIL', 'gradient', `${s.gradients} gradient fill(s): ${s.gradientSamples.slice(0, 3).join(' | ')}`, 'Use a flat colour or a real image. The only gradient allowed by default is a neutral scrim over a photo for legibility.');
if (s.gradientText) add('FAIL', 'gradient-text', `${s.gradientText} gradient-filled text element(s).`, 'Solid colour text.');
if (s.shadowStatic) add('FAIL', 'shadow', `${s.shadowStatic} box-shadow(s) on elements at rest: ${s.shadowSamples.slice(0, 3).join(' | ')}`, 'Remove. Separate surfaces with a tone step or space. Shadows are for things that really float (menus, dialogs, sticky bars).');
if (s.textShadow) add('FAIL', 'text-shadow', `${s.textShadow} text-shadow(s).`, 'Remove; fix legibility with a scrim or a different crop.');
if (m.buttons.withGradient || m.buttons.withShadow) add('FAIL', 'button-fx', `Buttons with ${m.buttons.withGradient ? 'gradient fill' : ''}${m.buttons.withGradient && m.buttons.withShadow ? ' and ' : ''}${m.buttons.withShadow ? 'shadow/glow' : ''}.`, 'Flat fill, one colour.');
if (s.hairlines) add(s.hairlines >= 2 ? 'FAIL' : 'WARN', 'hairline', `${s.hairlines} thin rule(s)/divider line(s): ${s.hairlineSamples.slice(0, 4).join(' | ')}`, 'Delete. Separate with space, a tone change, or scale. Lines are only for real tables and form fields.');
if (s.outlinedBoxes >= 3) add('FAIL', 'outlined-box', `${s.outlinedBoxes} boxes drawn with a 1px outline.`, 'Define a surface by its fill (one tone step from the page) or leave the content unboxed.');
else if (s.outlinedBoxes) add('WARN', 'outlined-box', `${s.outlinedBoxes} box(es) drawn with a 1px outline.`, 'Prefer a filled surface or no box.');
if (s.dashed) add('WARN', 'dashed', `${s.dashed} dashed/dotted border(s).`, 'Remove unless it is a real drop zone.');
if (s.blurred > 1) add('WARN', 'glass', `${s.blurred} backdrop-blur surfaces.`, 'One blurred sticky bar at most; glass cards are a tell.');
const iconRows = s.cardRows.filter((c) => c.iconTopped);
if (iconRows.length) add('FAIL', 'icon-cards', `${iconRows.length} row(s) of identical cards each topped with a small icon (${iconRows.map((c) => c.count).join(', ')} cards).`, 'Replace with one composed section from the direction: a narrative split, a list set in large type, or real imagery per item.');
else if (s.cardRows.length >= 2) add('WARN', 'card-rows', `${s.cardRows.length} rows of identical boxed cards.`, 'Vary the composition; not every section is a card grid.');
if (m.nav?.floatingPill) add('WARN', 'pill-nav', 'Floating rounded pill navigation.', 'Use a plain bar: wordmark left, a few links, one action.');
// --- colour
const fams = new Map();
const note = (label, hsl) => { const f = colourFamily(hsl); if (f) fams.set(f, [...(fams.get(f) || []), label]); };
note('page background ' + m.color.bodyBg, m.color.bodyBgHsl);
for (const sb of m.color.sectionBgs) note('section ' + sb.color, sb.hsl || hexToHsl(sb.color));
if (m.color.button) note('button ' + m.color.button.bg, m.color.button.hsl || hexToHsl(m.color.button.bg));
for (const t of m.color.textColors) if (t.share >= 0.01) note('text ' + t.color, hexToHsl(t.color));
if (fams.has('terracotta') && fams.has('cream')) add('FAIL', 'palette-claude', `Cream + terracotta palette (${[...fams.get('cream'), ...fams.get('terracotta')].slice(0, 4).join(', ')}).`, 'Re-draw the palette from the library. This pairing reads as an assistant brand, not yours.');
else if (fams.has('terracotta')) add('FAIL', 'palette-terracotta', `Terracotta / rust-orange accent (${fams.get('terracotta').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
else if (fams.has('cream')) add('WARN', 'palette-cream', `Yellow-cream background (${fams.get('cream').slice(0, 3).join(', ')}).`, 'Use the drawn palette. If you want warmth, take a low-chroma stone or bone, not a yellow cream.');
if (fams.has('ai-purple')) add('FAIL', 'palette-purple', `Indigo / violet (${fams.get('ai-purple').slice(0, 3).join(', ')}).`, 'Use the accent from the drawn palette.');
if (m.color.textColors.some((t) => t.color === '#0000ee' || t.color === '#551a8b')) add('WARN', 'default-link', 'Browser-default blue/purple link colour is showing.', 'Style every link: inherit the text colour and mark it with weight, underline offset, or the accent.');
const veryLow = m.color.lowContrast.filter((c) => c.ratio < 2.6);
if (veryLow.length) add('FAIL', 'contrast', `Unreadable text: ` + veryLow.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 (${c.fg} on ${c.bg})`).join('; '), 'Raise contrast to at least 4.5:1 for text, 3:1 for large display.');
else if (m.color.lowContrast.length) add('WARN', 'contrast', `Low-contrast text: ` + m.color.lowContrast.slice(0, 4).map((c) => `"${c.text}" ${c.ratio}:1 needs ${c.need}:1`).join('; '), 'Darken or lighten the text colour.');
// --- type system
const realFams = m.type.families.filter((f) => f.share >= 0.02);
if (realFams.length > 3) add('FAIL', 'font-count', `${realFams.length} typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most: one display, one text. Often one is enough.');
else if (realFams.length === 3) add('WARN', 'font-count', `3 typefaces in use (${realFams.map((f) => f.family).join(', ')}).`, 'Two at most.');
if (m.type.notLoaded.length) add('WARN', 'font-fallback', `Typeface(s) declared but not loaded, so a fallback is rendering: ${m.type.notLoaded.join(', ')}`, 'Fix the font URL / family name, or the screenshots you are judging are not the design.');
if (m.type.sizeCount > 9) add('WARN', 'size-count', `${m.type.sizeCount} different font sizes (${m.type.sizes.join(', ')}).`, 'A page needs about five: display, heading, lead, body, small.');
// Tiny text is the fault the study's best sites share (about a quarter of them); it is not copied here.
if (m.type.body && m.type.body.size < 15) add('FAIL', 'body-size', `Body text is ${m.type.body.size}px (the size most of the running text is set in).`, 'Body copy 16–20px. Map small sizes from blueprints onto your body step.');
else if (m.type.body && m.type.body.size < 16 && !mobile) add('WARN', 'body-size', `Body text is ${m.type.body.size}px.`, 'Body copy 16–20px.');
if (m.type.body?.contrast && m.type.body.contrast < 7) add('WARN', 'grey-body', `The running text is a soft grey on its ground (${m.type.body.contrast}:1).`, 'Set body copy in the full ink (7:1 or more). Keep the muted ink for secondary notes; pages read thin and unfinished when everything is grey.');
if (m.buttons.inHero === 0) add('WARN', 'first-action', `No visible button in the first ${mobile ? 'phone ' : ''}screen.`, mobile ? 'On a phone the first screen still holds the headline, the supporting sentence and the action; move navigation into one row or a menu so it does not push them down.' : 'Give the one primary action a real button in the first screen (a filled or solid-bordered control, 44px or taller), not only a link in the navigation.');
// Models skip pictures when nothing makes them: the page must carry real ones (see SKILL.md, imagery).
if (width >= 1100 && m.page.realPictures != null) {
const pics = m.page.realPictures + m.page.uiPictures;
if (pics < 4) add('FAIL', 'few-pictures', `Only ${pics} picture(s) on the page (${m.page.realPictures} photographs, footage or raster images, ${m.page.uiPictures} interface fragments). Drawings in SVG do not count.`, 'Find real pictures of the subject\'s world with scripts/assets.mjs (photographs, archival images, maps, engravings, footage), download them to assets/ and give them room: a hero picture, one per section that has a subject, a full-width band. At least four, usually six to twelve.');
else if (m.page.realPictures < 2) add('WARN', 'no-real-pictures', `Only ${m.page.realPictures} real photograph(s) or clip(s); the rest are interface fragments.`, 'Add two or more real pictures of the customer\'s world from scripts/assets.mjs, not stock of people at laptops.');
if (!m.page.videoCount) add('WARN', 'no-footage', 'No video anywhere on the page.', 'Look for one honest clip with scripts/assets.mjs --video (the place, the material, the process) and use it as a muted loop with a poster frame. Defend its absence only if no clip fits the subject.');
}
if (m.page.videosNoPoster) add('WARN', 'no-poster', `${m.page.videosNoPoster} clip(s) without a poster.`, 'Give every clip a poster frame: in the study of 100 Sites of the Day only 31% had one, and the blank or grey boxes before the clips played were recorded as faults (references/imagery.md §11).');
if (m.page.sideScrollers) add('WARN', 'side-scroll', `${m.page.sideScrollers} row(s) scroll sideways with content past their edge.`, 'Jurors and most visitors read a row cut at the edge as clipped, not as scrollable. Wrap it onto more lines, or end it inside the grid.');
if (m.page.blendText) add('WARN', 'blend-text', `${m.page.blendText} text element(s) use mix-blend-mode difference or exclusion.`, 'Over anything but black, white or grey, blended text takes random hues (blue on orange, lilac on green) that jurors read as a glitch. Use a solid ink chosen for the ground, or keep the blended text over neutral grounds only.');
if (m.type.measureChars && m.type.measureChars > 88 && !mobile) add('WARN', 'measure', `Longest paragraph runs about ${m.type.measureChars} characters per line.`, 'Cap paragraphs at 45–75 characters (max-width: 60ch).');
if (m.type.smallText > 3) add('FAIL', 'small-text', `${m.type.smallText} text blocks under 13px.`, 'Nothing meant to be read goes below 13px.');
else if (m.type.smallText > 0) add('WARN', 'small-text', `${m.type.smallText} text block(s) under 13px.`, 'Nothing meant to be read goes below 13px.');
if (m.type.centerShare > 0.72 && m.headings.length >= 4) add('WARN', 'all-centred', `${Math.round(m.type.centerShare * 100)}% of the text is centre-aligned.`, 'Centre one moment at most; anchor the rest to a shared left edge.');
// --- breakage
if (m.page.uiAbuse) add('FAIL', 'ui-wrapper', 'A data-ui element wraps a heading or more than a screen and a half of the page.', 'data-ui marks a cropped interface fragment only. Remove it from page-level wrappers.');
if (m.page.horizontalOverflow) add('FAIL', 'overflow', `Page scrolls sideways by ${m.page.horizontalOverflow}px at ${width}px.${m.page.overflowCulprits?.length ? ' Widest offenders: ' + m.page.overflowCulprits.join(', ') + '.' : ''}`, 'Constrain the element that is wider than the viewport (max-width: 100%, or overflow: clip on its section).');
if (m.page.brokenImages.length) add('FAIL', 'broken-image', `Broken image(s): ${m.page.brokenImages.slice(0, 4).join(', ')}`, 'Fix the URL or replace the asset.');
if (!m.hero) add('WARN', 'no-hero', 'No headline found in the first viewport.', 'The first screen needs a clear focal statement or image.');
// One dominant thing per first screen: large type, or a picture that holds a third of it, or (for software) an interface fragment.
else if (!mobile && m.hero.vw < 4.5 && (m.heroMedia[0]?.ratio || 0) < 0.3 && !m.page.uiFragments) {
add('FAIL', 'focal-point', `The first screen has no dominant element: the largest type is ${m.hero.size}px (${m.hero.vw}vw) and no single picture holds a third of the viewport.`, 'Make one thing lead: the headline at display size, or one large picture. A grid of many small pictures is not one thing; in blind tests jurors marked a first screen without a headline down every time. On the strongest studied sites the largest type has a median of 8.4vw.');
}
return out;
}
// The frame of the page, measured once per width by layout(): margins, balance, blank stretches, titles.
// These catch the failures a reader names first: "no clear title", "random empty space", "it is all
// pushed to one side", "text touching the edge".
export function judgeLayout(L, { width = L.viewport.w, allow = [] } = {}) {
const out = [];
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
const mobile = width < 600, H = L.viewport.h;
// the outer frame: the two margins match (asymmetry belongs inside the grid, never in the frame)
const { left, right } = L.margins;
if (left != null && right != null) {
const diff = Math.abs(left - right), wide = Math.max(left, right);
if (diff >= Math.max(mobile ? 14 : 40, width * 0.045) && wide >= (mobile ? 28 : width * 0.07)) {
add('FAIL', 'off-centre', `The page sits off-centre: its content comes to ${left}px from the left edge but only to ${right}px from the right, at ${width}px.`, `Give the page one pair of equal margins. Check the wrapper for a max-width without margin-inline: auto, a grid with an empty last column, or padding on one side only. If one side is meant to hold air, it must still be closed by something that reaches the margin (a picture, the navigation's last item, a figure).`);
} else if (diff >= (mobile ? 12 : 16)) {
// strong sites hold their two margins within a few pixels; a 20-30px drift is the "shifted" look
add('WARN', 'off-centre', `The margins differ: ${left}px on the left, ${right}px on the right, at ${width}px.`, 'Put the navigation and the content on one pair of margins; check for padding on one side only, a scrollbar gutter, or a header on another inset than the page.');
}
}
// sections whose content huddles on one side, leaving the other half of the screen empty
if (!mobile && left != null) {
const lop = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.left != null && s.words >= 12 && s.height >= 180 && Math.min(s.left, s.right) <= Math.max(left, right) + 24 && Math.max(s.left, s.right) >= width * 0.42);
if (lop.length >= 2) add('FAIL', 'lopsided', `${lop.length} sections keep all their content on one side and leave ${Math.round(Math.max(...lop.map((s) => Math.max(s.left, s.right))) / width * 100)}% of the width empty: ` + lop.slice(0, 4).map((s) => `${s.heading ? '"' + s.heading.text + '"' : s.sel} (${s.right > s.left ? 'right' : 'left'} ${Math.max(s.left, s.right)}px empty)`).join('; '), 'Use the whole width the page has set up: put the section\'s picture, its second column, its list or its figure in the empty half, or set the text larger across the grid. Empty space must be placed, with something on each side of it.');
else if (lop.length) add('WARN', 'lopsided', `A section keeps its content on one side and leaves ${Math.max(lop[0].left, lop[0].right)}px of the width empty: ${lop[0].heading ? '"' + lop[0].heading.text + '"' : lop[0].sel}.`, 'Fill the other side with what the section has (its picture, a second column, the figure), or defend the void in one sentence.');
// the same hole with the empty part in the middle: a card at the left, a lone button at the right
const hollow = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.words >= 12 && s.middle >= width * 0.45);
if (hollow.length) add('WARN', 'empty-middle', `${hollow.length} section(s) have content at both edges and ${Math.max(...hollow.map((s) => s.middle))}px of nothing between: ` + hollow.slice(0, 3).map((s) => s.heading ? '"' + s.heading.text + '"' : s.sel).join('; '), 'Bring the two parts together on the grid, or put the picture or figure of the section in the middle. An empty middle third reads as a hole, the same as an empty half.');
}
// blank stretches: nothing at all for a long run down the page
const big = L.gaps.filter((g) => g.height >= H * 0.6), mid = L.gaps.filter((g) => g.height >= H * 0.42 && g.height < H * 0.6);
if (big.length || mid.length >= 2) add(L.stage ? 'WARN' : 'FAIL', 'blank-gap', `${big.length + mid.length} stretch(es) of the page hold nothing at all: ` + [...big, ...mid].slice(0, 4).map((g) => `${g.height}px at ${g.top}px`).join('; ') + ` (the screen is ${H}px tall).`, (L.stage ? 'A fixed picture layer covers the screen here, so the DOM cannot tell whether the stretch is empty: check the screenshots. ' : '') + 'Close the gap: a section gap is 120–240px on desktop and 72–112px on a phone. Look for a min-height: 100vh on a short section, an empty grid row, a hidden element still holding its space, or a reveal that never fired. A deliberate rest is a placed picture or colour field, not nothing.');
else if (mid.length) add('WARN', 'blank-gap', `A ${mid[0].height}px stretch at ${mid[0].top}px holds nothing.`, 'Check that it is a deliberate rest (once per page at most); otherwise close it to the normal section gap.');
// the title: one h1, on the first screen, that says what this is
if (!L.h1s.length) add('FAIL', 'no-title', 'The page has no visible h1.', 'Give the first screen a clear title in an h1 that says what this is (a name with what it does, the product with what it is for, the event with its date and place).');
else {
const first = L.h1s[0];
if (!first.firstScreen) add('FAIL', 'no-title', `The h1 ("${first.text.slice(0, 50)}") is not on the first screen (it starts ${first.top}px down).`, 'The title belongs on the first screen, where a visitor decides whether to stay.');
else if (first.size < (mobile ? 26 : 32)) add('WARN', 'small-title', `The h1 is ${first.size}px ("${first.text.slice(0, 50)}").`, 'Set the title at display size, or make the large text of the first screen the h1.');
if (first.firstScreen && first.text.split(' ').length <= 3 && L.explains === false) add('WARN', 'name-only-title', `The h1 is a bare name ("${first.text.slice(0, 40)}") and nothing else on the first screen says what this is.`, 'Keep the name if it leads, and give the line under it (18px or more) the plain words: what it is, for whom, where or when. In the study of 97 Sites of the Day, first screens that named nothing lost usability and content points.');
if (L.h1s.length > 1) add('WARN', 'many-h1', `${L.h1s.length} h1 elements.`, 'One h1 per page; section titles are h2.');
}
// sections with running text but no heading: the reader cannot tell what they are
const untitled = L.sections.filter((s) => !s.hero && !s.footer && !s.continues && !s.heading && s.words >= 30 && s.height >= 200);
if (untitled.length >= 2) add('FAIL', 'untitled-section', `${untitled.length} sections carry text but no heading: ` + untitled.slice(0, 4).map((s) => `${s.sel} at ${s.top}px (${s.words} words)`).join('; '), 'Give each section a short heading that names what it holds (the menu, the rooms, how it works, the price), in the heading face at the section size.');
else if (untitled.length) add('WARN', 'untitled-section', `A section carries text but no heading: ${untitled[0].sel} at ${untitled[0].top}px.`, 'Name it with a short heading unless it is the continuation of the section above.');
// the first screen: a large empty rectangle that nothing closes reads as "random empty space"
if (!mobile && L.voidShare >= 0.28 && !L.stage) add('WARN', 'hero-void', `${Math.round(L.voidShare * 100)}% of the first screen is one empty block (${L.voidBox.w}×${L.voidBox.h}px at ${L.voidBox.x},${L.voidBox.y}).`, 'Place the void on purpose or fill it: move the picture, the film or one fact into it, grow the headline to meet it, or shrink the screen to its content. A void reads as placed only when something closes it on the far side and it is the screen\'s one rest.');
// display type that starts inside the margin but runs off the opposite edge by a sliver: a fit error
if (L.slivers && L.slivers.length) add('WARN', 'one-sided-bleed', `${L.slivers.length} display line(s) start inside the margin but run off the ${L.slivers[0].side} edge by a few pixels: ` + L.slivers.map((t) => `"${t.text}" (${t.px}px)`).join('; '), 'Fit the line to the content box by measurement (a small fit script, or SVG text with textLength), or bleed it on purpose by a clear amount from the margin. A name sliced by a few pixels on one side reads as a mistake.');
// text pressed against the screen edge
const tight = L.edgeText.filter((t) => t.px < (mobile ? 12 : 16)), near = mobile ? L.edgeText.filter((t) => t.px >= 12) : [];
if (near.length && !tight.length) add('WARN', 'edge-text', `${near.length} text block(s) sit 12–15px from the phone's edge: ` + near.slice(0, 3).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px)`).join('; '), 'The page margin is 16–20px on a phone; the header and the corner labels sit on it too.');
if (tight.length) add('FAIL', 'edge-text', `${tight.length} text block(s) sit within ${mobile ? 12 : 16}px of the screen edge: ` + tight.slice(0, 4).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px from the ${t.side})`).join('; '), `Keep text inside the page margin (${mobile ? '16–20px' : '16–32px or more'}). Only display type deliberately bled past the edge may touch it.`);
// too many left edges: nothing lines up
if (!mobile && L.edges.length > 6 && L.leftBlocks >= 8) {
const top3 = L.edges.slice(0, 3).reduce((a, e) => a + e.n, 0) / L.leftBlocks;
if (top3 < 0.6) add('WARN', 'ragged-edges', `Left-aligned text starts at ${L.edges.length} different lines (${L.edges.slice(0, 7).map((e) => e.x + 'px').join(', ')}); the three most used hold only ${Math.round(top3 * 100)}% of it.`, 'Pick two or three anchor lines from the grid and start every heading and paragraph on one of them.');
}
return out;
}
// App screens (dashboards, admin, settings, inboxes) keep conventions a marketing page does not: tables
// and panels may have quiet borders, labels are small, cards repeat, there is no hero, no picture floor
// and no motion floor. These page rules do not apply to them; references/app-screens.md has the reasons.
export const APP_SKIP = new Set(['hero-words', 'eyebrow', 'mono', 'name-only-title', 'empty-middle', 'numbering', 'hairline', 'outlined-box', 'dashed', 'icon-cards', 'card-rows', 'pill-nav', 'grey-body', 'measure', 'first-action', 'few-pictures', 'no-real-pictures', 'no-footage', 'focal-point', 'no-hero', 'body-size', 'small-text', 'statement-length', 'shadow', 'glass', 'still-page', 'empty-screens', 'long-pin', 'side-scroll', 'pinned-over-picture', 'off-centre', 'lopsided', 'ragged-edges', 'untitled-section', 'size-count', 'all-centred']);
export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
const out = [];
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
const mobile = width < 600;
if (mobile && A.sidebar && A.sidebar.share > 0.3) add('FAIL', 'sidebar-phone', `The sidebar still takes ${Math.round(A.sidebar.share * 100)}% of the width on a ${width}px phone.`, 'On a phone the navigation folds into a top bar with a menu button or a bottom bar of four or five destinations; the screen\'s content gets the full width.');
if (!mobile && width >= 1100 && A.canvas && A.canvas.right > Math.max(120, width * 0.1) && A.canvas.right > A.canvas.left + 80) add('WARN', 'unused-canvas', `The content stops ${A.canvas.right}px short of the right edge while it starts ${A.canvas.left}px from the sidebar.`, 'Let the grid fill the canvas (columns in fr units), or centre a capped canvas with equal margins. A dead strip on one side reads as unfinished.');
if (A.panels.heavyShadows) add('FAIL', 'heavy-shadow', `${A.panels.heavyShadows} panel shadow(s) heavy enough to read as floating: ${A.panels.shadowSamples.join(' | ')}`, 'Panels sit on the ground: separate them with a fill one step from the ground or a quiet 1px border. A shadow, if any, is under 8% black with a small blur. Real overlays (menus, dialogs, popovers) may float.');
if (A.panels.glass) add('FAIL', 'glass', `${A.panels.glass} blurred glass panel(s).`, 'Opaque surfaces only.');
if (A.panels.heavyBorders >= 3) add('WARN', 'heavy-borders', `${A.panels.heavyBorders} panel borders are dark against their ground: ${A.panels.borderSamples.join(' | ')}`, 'A panel border is a whisper: about 1.1–1.3:1 against its ground on a light screen (#e5e7eb–#ededed on white), and about 1.2–1.5:1 on a dark one (#1a1a1a on #000, #2e2e2e on #111). Darker lines turn the screen into a grid of boxes; keep the stronger line for controls.');
const radii = A.panels.radii.filter(([, n]) => n >= 2).map(([r]) => r);
if (radii.length > 3) add('WARN', 'radius-mix', `Panels use ${radii.length} corner radii (${radii.join(', ')}px).`, 'One radius for panels, one for controls, full round only for pills and avatars.');
if (A.chips.count >= 3) add(A.chips.hues >= 2 ? 'FAIL' : 'WARN', 'icon-chips', `${A.chips.count} icons sit in tinted squares or circles${A.chips.hues >= 2 ? ` in ${A.chips.hues} different colours` : ''}.`, 'This is the commonest tell of a generated dashboard. Drop the tinted square: a KPI is a label and a number; an icon, if it helps, is plain and in the text colour.');
if (A.numbers.cells >= 6 && A.numbers.leftAligned / A.numbers.cells >= 0.5) add('WARN', 'numeric-align', `${A.numbers.leftAligned} of ${A.numbers.cells} numeric table cells are left-aligned.`, 'Right-align numbers, amounts and percentages (and their column headers) so the digits line up.');
if (A.numbers.figures >= 6 && A.numbers.tabularShare != null && A.numbers.tabularShare < 0.5) add('WARN', 'tabular', `Only ${Math.round(A.numbers.tabularShare * 100)}% of the figures use tabular numerals.`, 'Set font-variant-numeric: tabular-nums on tables, KPIs and anything that updates, so digits keep their width.');
if (A.chartHues > 6) add('FAIL', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'One hue for the series that matters, a neutral for the rest, and at most four series colours in total; status colours only for status.');
else if (A.chartHues > 4) add('WARN', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'Four series colours at most; prefer one accent and neutrals.');
if (A.colourShare != null && A.colourShare > 0.12) add('WARN', 'colour-sprawl', `${Math.round(A.colourShare * 100)}% of the first screen is strongly coloured.`, 'Keep the screen neutral and spend colour on data and status: the studied screens sit near 3%, and only tinted styles reach 10–15%, as pale tints rather than saturated fills.');
if (A.placeholders.length) add('FAIL', 'placeholder-data', `Placeholder data on screen: ${A.placeholders.join(', ')}.`, 'Use believable data for this product: real-looking names, amounts that add up, dates in order, units on every figure.');
if (A.type.tiny > 2) add('FAIL', 'tiny-text', `${A.type.tiny} text blocks under 11px: ${A.type.tinySamples.join(', ')}.`, 'Nothing below 11px; secondary text 12px, data 13–14px.');
else if (A.type.under13 > 0.45) add('WARN', 'tiny-text', `${Math.round(A.type.under13 * 100)}% of the text is under 13px.`, 'Data and labels read at 13–14px; keep 11–12px for sublines and axis ticks.');
if (!mobile && A.layout) {
if (A.layout.ragged.length) add('WARN', 'ragged-row', `${A.layout.ragged.length} pair(s) of cards in one row end at different heights (by ${A.layout.ragged.map((r) => r.diff + 'px').slice(0, 3).join(', ')}).`, 'Cards that share a row share a bottom edge: balance them by their content (the same number of rows, a block of similar height beside it), or stack them. In testing, ragged rows were marked down every time.');
if (width >= 1100 && A.layout.panels >= 4 && A.layout.firstScreen / A.layout.panels < 0.5) add('WARN', 'below-fold', `Only ${A.layout.firstScreen} of ${A.layout.panels} blocks start on the first ${width}px screen.`, 'Every block the brief names shows its header and first rows on the first desktop screen: size the headline block to its question, cap long lists with "View all", and put time-sensitive blocks before long tables.');
if (width >= 1100 && A.layout.voids?.length) add('WARN', 'panel-void', `${A.layout.voids.length} card(s) hold a blank band inside: ${A.layout.voids.slice(0, 3).map((v) => `${v.gap}px in "${v.panel}"`).join(', ')}.`, 'A card stretches to its neighbour only when it has content to fill. Keep a legend or a note directly under its chart (only an action or a total may sit at a stretched card\'s foot), let a short card keep its own height (align-items: start), and let a filtered block shrink to its rows.');
}
// an achromatic screen with a chart or a timeline and no hue on any data reads as a kit: the one chart hue goes on
// the main data element (today's line, the booked bars, the margin that breaks)
if (!mobile && A.charts > 0 && A.chartHues === 0 && A.colourShare != null && A.colourShare < 0.002) add('WARN', 'no-data-hue', `The screen has ${A.charts === 1 ? 'a chart' : A.charts + ' charts or timelines'} but no hue on any of its data (${(A.colourShare * 100).toFixed(1)}% coloured pixels).`, 'Put the style\'s one chart hue (or the brand\'s) on the screen\'s main data element: today\'s line, the booked bars, the current period, the cover margin. Keep the chrome neutral; the hue still stays under the style\'s colour budget.');
// in app mode the screen's hero is its h1 or the largest text on the first screen, not the page probe's guess
// (which can be a figure below the fold read without its spaces); the report and the MEASURED line use this one
if (A.hero && m) m.hero = A.hero;
if (A.title && A.title.size > (mobile ? 28 : 34)) add('WARN', 'app-title-size', `The h1 is ${A.title.size}px ("${A.title.text.slice(0, 40)}").`, 'An app screen is not a landing page: the title is 18-32px (20-24px on a phone).');
if (A.numbers.kpiMax && A.numbers.kpiMax >= 48) add('WARN', 'kpi-size', `A figure is set at ${Math.round(A.numbers.kpiMax)}px.`, 'Figures run 16-40px on app screens; none of the studied screens reaches 48px. Size and isolation make a number loud, not display type.');
if (A.type.sizes.length > 10) add('WARN', 'size-count', `${A.type.sizes.length} font sizes (${A.type.sizes.join(', ')}).`, 'App screens use 4-9 sizes (median 6): a scale such as 12/14/16/24 with one figure size.');
if (A.tiles >= 4) add('WARN', 'icon-tiles', `${A.tiles} icons sit in neutral boxes repeated down the screen.`, 'A boxed icon on every row or card is still the template habit, tinted or not. Keep one plain 16px icon where it helps the eye, or none.');
if (!A.title) add('FAIL', 'no-title', 'The screen has no h1.', 'Name the screen in an h1 at the top of the canvas (18–28px): "Overview", "Payouts", the project\'s name.');
if (m && m.type && m.type.monoProseShare > 0.15) add('WARN', 'mono', `Monospace is ${Math.round(m.type.monoProseShare * 100)}% of the text.`, 'Monospace for IDs, keys and code only; numbers use tabular figures in the text face.');
return out;
}
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy'];
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// tasteskill / lint — static scan of source files for the tells this skill bans.
// Catches them before a render, plus the ones a render cannot see (copy habits, motion values).
//
// node lint.mjs [files or directories ...] [--app] [--json]
//
// Exit code 1 when any FAIL is found. Zero dependencies.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname, relative, resolve } from 'node:path';
const args = process.argv.slice(2);
const asJson = args.includes('--json');
// App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels
// and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself.
let appMode = args.includes('--app');
try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {}
const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null };
const roots = args.filter((a) => !a.startsWith('--'));
if (!roots.length) roots.push('.');
const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']);
const SKIP = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', 'dist', 'build', 'out', '.vercel', '.taste-skill', 'coverage', '.turbo', 'vendor']);
const files = []; const tooBig = [];
const walk = (p) => {
let st; try { st = statSync(p); } catch { return; }
if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); }
else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
};
for (const r of roots) {
try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); }
walk(resolve(r));
}
if (!files.length) { console.error(`No source files (${[...EXT].join(' ')}) under: ${roots.join(', ')}`); process.exit(2); }
if (tooBig.length) console.error(`Skipped (over 4 MB, check by hand): ${tooBig.join(', ')}`);
// Each rule: [level, id, regex, message]. Tested per line. `code` lines (inside <pre>/<code>) skip the mono rule.
const R = [
['FAIL', 'mono', /\bfont-mono\b|font-family\s*:[^;{}]*(mono\b|monospace|Courier|Consolas|Menlo|Monaco)|(JetBrains|Geist|IBM\+?Plex|Space|DM|Roboto|Fragment|Azeret|Red\+?Hat|Martian|Commit|Departure|Fira)[+_ ]?Mono|Geist_Mono|GeistMono|Fira[+ ]Code|ui-monospace/i, 'Monospace type. Only literal code may be monospace.'],
['FAIL', 'gradient', /(linear|radial|conic)-gradient\(|\bbg-(gradient|linear|radial|conic)-|\b(from|via)-(?!\[?0)[a-z]+-\d{2,3}\b/, 'Gradient fill. Use a flat colour or a real image (a neutral scrim over a photo is the one exception).'],
['FAIL', 'gradient-text', /bg-clip-text|background-clip\s*:\s*text|text-transparent/, 'Gradient / clipped text. Use solid colour.'],
['FAIL', 'shadow', /box-shadow\s*:\s*(?!none|0 0 #0000|var\(--tw)|\bshadow(-(sm|md|lg|xl|2xl|inner|\[[^\]]+\]))?(?=["'\s`])(?<!-shadow)|drop-shadow\(|\bdrop-shadow(-|\b)|text-shadow\s*:\s*(?!none)/, 'Shadow. Elements at rest carry none; only real overlays (menu, dialog, sticky bar) may.'],
['FAIL', 'numbering', />\s*[(\[]?0[1-9][)\]]?\.?\s*<|["'`]\s*0[1-9]\s*["'`]|decimal-leading-zero|>\s*(Step|Phase|No\.?|Chapter)\s*0?\d\s*</i, 'Decorative index numbering (01 / 02 / 03).'],
['WARN', 'numbering', /padStart\(\s*2\s*,\s*["']0["']\s*\)/, 'Zero-padding in script. Fine for a clock, a date or a price; if it builds 01 / 02 / 03 labels, remove it.'],
['FAIL', 'eyebrow', /class(Name)?\s*=\s*["'`{][^"'`}]*\b(eyebrow|kicker|overline|pretitle|pre-title|suptitle|sup-title|tagline-label|section-label|section-tag)\b|\.(eyebrow|kicker|overline|pretitle|section-label|section-tag)\b\s*[{,]/i, 'Eyebrow / kicker label above a heading.'],
['FAIL', 'micro-label', /(?=[^"'`]*\buppercase\b)(?=[^"'`]*\btracking-(wide|wider|widest|\[[^\]]+\])\b)(?=[^"'`]*\btext-(xs|\[1[0-3]px\]|\[0?\.[67]\d*rem\])\b)[^"'`]+/, 'Tiny uppercase letter-spaced label.'],
['FAIL', 'purple', /\b(bg|text|from|to|via|border|ring|fill|stroke|shadow|decoration|outline)-(purple|violet|indigo|fuchsia)-\d{2,3}\b|#(6366f1|8b5cf6|7c3aed|a855f7|4f46e5|a78bfa|818cf8|9333ea|6d28d9|c084fc)\b/i, 'Indigo / violet — the generated-page accent.'],
['FAIL', 'claude-palette', /#(d97757|da7756|c15f3c|cc785c|bd5d3a|f0eee6|faf9f5|f5f4ed|eeece2|e8e6dc)\b/i, 'Claude-brand cream / terracotta colour.'],
['FAIL', 'emoji', /[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}]/u, 'Emoji used as UI.'],
['FAIL', 'lorem', /lorem ipsum|dolor sit amet/i, 'Placeholder copy shipped.'],
['FAIL', 'hairline', /<hr\b|\bdivide-[xy](-\d|\b)|\b(h|w)-px\b|border-(top|bottom|left|right)\s*:\s*(0?\.5|1)px|\bborder-[tblrxy](?![-\w])|\bborder-[tblrxy]-(?!0\b|none)\[?1?(px)?\]?(?=["'\s`])|height\s*:\s*1px/, 'Thin rule / divider line. Separate with space, tone or scale; lines belong to real tables and form fields.'],
['WARN', 'outlined-box', /border\s*:\s*1px\s+solid|\bborder\b(?=[^"'`]*\brounded)/, 'A 1px outlined box. Prefer a filled surface or no box.'],
['WARN', 'glass', /backdrop-blur|backdrop-filter\s*:/, 'Backdrop blur. One sticky bar at most; glass cards are a tell.'],
['WARN', 'copy', /\b(unlock|unleash|supercharge|seamless(ly)?|elevate|empower(s|ing)?|revolutioni[sz]e|game[- ]chang(er|ing)|cutting[- ]edge|next[- ]gen(eration)?|world[- ]class|best[- ]in[- ]class|all[- ]in[- ]one|effortless(ly)?|blazing(ly)?[- ]fast|lightning[- ]fast|reimagin(e|ed|ing)|delve|tapestry|leverage|synergy|streamline|robust|holistic|turbocharge|transform the way|take your .{1,30} to the next level|say goodbye to|in today'?s|the future of|built for the modern|not just .{1,40}[,—-] (it'?s|but)|more than (just )?a)\b/i, 'Stock marketing phrase. Say the specific thing the product does.'],
['WARN', 'cta-copy', />\s*(Learn more|Get started|Click here|Read more|Discover more|Explore now|Submit)\s*</i, 'Generic button label. Name the action or the thing ("Book a table", "See the plans").'],
['WARN', 'default-font', /family=(Inter|Poppins|Montserrat|Roboto|Open\+Sans|Space\+Grotesk|Instrument\+Serif|Playfair\+Display|Plus\+Jakarta\+Sans|Outfit|Sora|Lato|Nunito|Raleway)\b|font-family\s*:\s*["']?(Inter|Poppins|Montserrat|Roboto|Space Grotesk|Instrument Serif|Playfair Display|Plus Jakarta Sans|Outfit|Sora)\b|from ["']next\/font\/google["'].*\b(Inter|Poppins|Montserrat|Space_Grotesk|Instrument_Serif|Playfair_Display|Plus_Jakarta_Sans|Outfit|Sora)\b|\b(Inter|Poppins|Space_Grotesk|Instrument_Serif|Playfair_Display)\s*\(\s*\{/, 'A default typeface. Use the faces named by the drawn direction.'],
['WARN', 'transition-all', /transition\s*:\s*all\b|\btransition-all\b/, 'transition: all. Name the properties (transform, opacity).'],
['WARN', 'ease-in', /(?<![-\w])ease-in(?!-out)\b/, 'ease-in on UI feels sluggish. Use a strong ease-out for enters and exits.'],
['WARN', 'scale-zero', /scale\(\s*0\s*\)|\bscale-0\b/, 'Entering from scale(0). Start at 0.95–0.98 with opacity 0.'],
['WARN', 'h-screen', /\bh-screen\b|height\s*:\s*100vh/, '100vh jumps on mobile. Use 100svh / 100dvh.'],
['WARN', 'bounce', /animate-(bounce|ping|pulse)\b/, 'Looping attention animation (bounce / ping / pulse dot).'],
];
if (appMode) {
for (const r of R) if (r[1] in APP_LEVEL) r[0] = APP_LEVEL[r[1]];
R.push(['FAIL', 'placeholder-data', /\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bexample\.com\b|\bFirstname Lastname\b|\b123 Main St\b/, 'Placeholder data. Use believable names, amounts that add up and real units for this product.']);
R.push(['WARN', 'framework-colour', /#(3b82f6|2563eb|1d4ed8|60a5fa|10b981|22c55e|ef4444|f59e0b)\b/i, 'A framework default colour (Tailwind blue, green, red, amber). Take the accent and status colours from the dealt style.']);
}
const findings = [];
let emDashes = 0;
for (const file of files) {
const rel = relative(process.cwd(), file).split('\\').join('/');
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
let inCode = false; let selector = ''; let pending = '';
lines.forEach((line, i) => {
// track the CSS selector we are inside, so rules scoped to [data-ui] (interface fragments) keep their own lines
if (line.includes('{')) { selector = pending + line.slice(0, line.indexOf('{')); pending = ''; } else if (!line.includes('}')) pending = (pending + ' ' + line).slice(-300);
const inUi = /data-ui/.test(selector) || /data-ui/.test(line);
if (line.includes('}') && !line.includes('{')) { selector = ''; pending = ''; }
if (/<(pre|code)\b/i.test(line)) inCode = true;
const codeLine = inCode || /<(pre|code|kbd|samp)\b/i.test(line);
if (/<\/(pre|code)>/i.test(line)) inCode = false;
if (line.length > 2000) return; // minified or data
if (/taste-skill-ignore/.test(line)) return;
if (!/^\s*(\/\/|\/\*|\*|<!--)/.test(line)) emDashes += (line.replace(/\/\/.*$|\/\*.*?\*\/|<!--.*?-->/g, '').match(/—/g) || []).length; // comments are not copy
for (const [level, id, re, message] of R) {
if (!level) continue;
if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
if (id === 'hairline' && /sr-only|visually-hidden|clip(-path)?\s*:|outline/i.test(line + selector)) continue; // screen-reader helpers and focus outlines are not rules
if ((id === 'copy' || id === 'cta-copy' || id === 'emoji') && /^\s*(\/\/|\/\*|\*|import |@import|<!--)/.test(line)) continue;
const m = line.match(re);
if (m) findings.push({ level, id, file: rel, line: i + 1, match: m[0].trim().slice(0, 70), message });
}
});
}
if (emDashes > 4) findings.push({ level: 'WARN', id: 'em-dash', file: '(all files)', line: 0, match: `${emDashes} em dashes`, message: 'Em-dash habit. Use a full stop or a comma; keep at most one or two on a page.' });
if (asJson) { console.log(JSON.stringify({ files: files.length, findings }, null, 1)); process.exit(findings.some((f) => f.level === 'FAIL') ? 1 : 0); }
const byId = new Map();
for (const f of findings) { const k = f.level + ' ' + f.id; if (!byId.has(k)) byId.set(k, []); byId.get(k).push(f); }
console.log(`TASTESKILL · LINT ${files.length} file(s)`);
if (!findings.length) console.log('\nNo banned patterns in source. Now run inspect.mjs on the rendered page.');
for (const lvl of ['FAIL', 'WARN']) {
const keys = [...byId.keys()].filter((k) => k.startsWith(lvl));
if (!keys.length) continue;
console.log(`\n${lvl}`);
for (const k of keys) {
const list = byId.get(k);
console.log(` [${list[0].id}] ${list[0].message} (${list.length}×)`);
for (const f of list.slice(0, 6)) console.log(` ${f.file}:${f.line} ${f.match}`);
if (list.length > 6) console.log(` … ${list.length - 6} more`);
}
}
const fails = findings.filter((f) => f.level === 'FAIL').length;
console.log(`\nRESULT: ${fails} FAIL, ${findings.length - fails} WARN${fails ? ' — fix the FAILs before rendering.' : ''}`);
console.log('Interface fragments marked data-ui keep their own borders and shadows. Any other line that must keep a flagged pattern for a real reason can carry the comment taste-skill-ignore.');
process.exit(fails ? 1 : 0);
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// tasteskill / scenes — photograph every scroll scene on a page at exact points of its progress.
//
// node scenes.mjs <url | path/to/file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]
//
// For each <section class="scene"> (references/scenes.md): the screen before it, 0, 10, … 100% of its
// hold, and the screen after it, on desktop (1440x900) and on a phone (390x844), as labelled contact
// sheets; then the reduced-motion page screen by screen. Prints each scene's height in screens, frame
// timing while the page is wheeled through its scenes, and console errors. Read every sheet it writes.
//
// Exit code 0 = every scene ran; 1 = a scene never started, fell back to rest on desktop, or the page
// logged errors. Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
import { existsSync, mkdirSync, statSync, createReadStream } from 'node:fs';
import { resolve, join, dirname, extname, sep, relative } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
const args = process.argv.slice(2);
const flag = (k, d) => { const i = args.indexOf('--' + k); if (i === -1) return d; const v = args[i + 1]; args.splice(i, 2); return v; };
const outDir = resolve(flag('out', '.taste-skill/scenes'));
const STEPS = Math.max(2, +flag('steps', 10)), WAIT = +flag('wait', 900);
const only = flag('scene', '');
const target = args[0];
if (!target || target.startsWith('--')) { console.error('usage: node scenes.mjs <url | file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]'); process.exit(2); }
// A local file is served from 127.0.0.1 (the working directory when the file is inside it, else the
// file's folder), so relative assets and CDN scripts behave as they will when the page is hosted.
let url = target, server = null;
if (!/^https?:\/\//.test(target)) {
const file = resolve(target);
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
const cwd = resolve('.');
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff': 'font/woff', '.woff2': 'font/woff2' };
server = createServer((req, res) => {
try {
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
if (statSync(f).isDirectory()) f = join(f, 'index.html');
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
createReadStream(f).pipe(res);
} catch { res.writeHead(404); res.end('not found'); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
mkdirSync(outDir, { recursive: true });
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const problems = [];
async function sheet(frames, labels, cellW, cellH, cols, path) {
const rows = Math.ceil(frames.length / cols);
await helper.setViewport({ width: cols * cellW, height: rows * (cellH + 22), dsf: 1 });
await helper.eval((a) => {
document.body.style.cssText = `margin:0;background:#777;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px);font:12px/22px system-ui;color:#fff`;
document.body.innerHTML = a.frames.map((s, i) => `<figure style="margin:0"><img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #777"><figcaption style="padding-left:6px;background:#111">${a.labels[i]}</figcaption></figure>`).join('');
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
}, { frames, labels, cellW, cellH, cols });
await helper.screenshot({ path, quality: 78 });
}
const SCENES = `[...document.querySelectorAll(${JSON.stringify(only || 'section.scene, .scene[data-scene]')})].map((el, i) => { const r = el.getBoundingClientRect(); return { id: el.id || el.dataset.scene || ('scene-' + (i + 1)), top: Math.round(r.top + scrollY), height: Math.round(r.height), live: el.classList.contains('is-live'), rest: el.classList.contains('is-rest') }; })`;
const go = (y) => `(() => { const y = ${y}; if (window.Stage && Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true }); else window.scrollTo(0, y); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); })()`;
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
async function run(label, vp) {
const page = await browser.newPage(vp);
await page.goto(url, { timeoutMs: 40000, settleMs: 1200 });
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(500);
const scenes = await page.eval(SCENES);
if (!scenes.length) console.log(`${label}: no <section class="scene"> on the page`);
const H = vp.height;
for (const sc of scenes) {
if (!sc.live && !sc.rest) problems.push(`${label} ${sc.id}: never started (stage.js, the scene file or Stage.init() missing)`);
if (label === 'desktop' && sc.rest) problems.push(`${label} ${sc.id}: shows its finished composition instead of running (GSAP not loaded, or the scene threw)`);
const frames = [], labels = [];
const span = Math.max(0, sc.height - H);
const points = [{ y: Math.max(0, sc.top - H * 0.5), l: 'before' }, ...Array.from({ length: STEPS + 1 }, (_, k) => ({ y: sc.top + (span * k) / STEPS, l: `${Math.round((k / STEPS) * 100)}%` })), { y: sc.top + span + H * 0.6, l: 'after' }];
for (const p of points) {
await page.eval(go(Math.round(p.y))).catch(() => {});
await sleep(WAIT);
frames.push(await page.screenshot({ quality: 72 }));
labels.push(`${sc.id} ${p.l}`);
}
const cw = vp.width >= 1000 ? 480 : 240, ch = Math.round((cw * H) / vp.width);
const path = join(outDir, `${sc.id}-${label}.jpg`);
await sheet(frames, labels, cw, ch, vp.width >= 1000 ? 4 : 7, path);
console.log(`${label.padEnd(8)} ${sc.id}: ${(sc.height / H).toFixed(1)} screens tall, ${sc.live ? 'running' : sc.rest ? 'finished composition' : 'not started'} → ${rel(path)}`);
}
if (label === 'desktop' && scenes.length) {
// frame timing while the page is wheeled through its scenes
await page.eval(go(Math.max(0, scenes[0].top - H))).catch(() => {});
await sleep(400);
await page.eval(`(() => { window.__f = []; let last = performance.now(); const tick = (t) => { window.__f.push(t - last); last = t; if (window.__f.length < 600) requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()`);
await page.wheel(Math.min(scenes.reduce((a, s) => a + s.height, 0), 9000), { step: 120, pauseMs: 16 });
await sleep(600);
const f = await page.eval('window.__f.slice(2)');
const sorted = f.slice().sort((a, b) => a - b);
console.log(`smoothness: ${f.length} frames, median ${(sorted[Math.floor(f.length / 2)] || 0).toFixed(1)} ms, ${f.filter((d) => d > 34).length} over 34 ms (headless timing is rough; a handful is normal)`);
}
if (page.consoleErrors?.length) { problems.push(`${label} console: ${page.consoleErrors.slice(0, 3).join(' | ')}`); }
await page.close();
}
try {
await run('desktop', { width: 1440, height: 900, dsf: 1 });
await run('phone', { width: 390, height: 844, dsf: 1, mobile: true });
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
await rp.goto(url, { timeoutMs: 40000, settleMs: 1200 });
const h = await rp.eval('document.documentElement.scrollHeight');
const frames = [], labels = [];
for (let y = 0, k = 0; y < h && k < 16; y += 900, k++) { await rp.eval(`window.scrollTo(0, ${y})`); await sleep(350); frames.push(await rp.screenshot({ quality: 70 })); labels.push(`reduced motion, screen ${k + 1}`); }
await sheet(frames, labels, 360, 225, 4, join(outDir, 'reduced-desktop.jpg'));
console.log(`reduced ${frames.length} screens → ${rel(join(outDir, 'reduced-desktop.jpg'))}`);
await rp.close();
} finally {
await browser.close();
if (server) server.close();
}
if (problems.length) { console.log('\nPROBLEMS'); for (const p of problems) console.log(' ' + p); }
console.log(problems.length ? `\nRESULT: ${problems.length} problem(s). Fix them, then look at every sheet.` : '\nRESULT: every scene ran. Now look at every sheet: does each frame read, and is the scene as bold as it should be?');
process.exit(problems.length ? 1 : 0);