Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a206bbf75a | ||
|
|
a0b2a4fc66 | ||
|
|
24b0743db3 | ||
|
|
7d84c08f01 | ||
|
|
1ff6d4f4a6 | ||
|
|
13d5e8749f | ||
|
|
d6d281ee9c | ||
|
|
e37f888c81 | ||
|
|
3dba5acc82 | ||
|
|
9305aab5d0 | ||
|
|
e080b0ad8a | ||
|
|
e8ee6d2a05 | ||
|
|
6947655a9f | ||
|
|
74ad02e1d3 | ||
|
|
5b7e2ea396 | ||
|
|
5f19173d47 | ||
|
|
b2770b024c | ||
|
|
cd9078b559 | ||
|
|
f624f37a8c | ||
|
|
9b4ac7ffc2 | ||
|
|
a60a36dc2b | ||
|
|
8c25bad923 | ||
|
|
6dbbb03c9b |
No files matched your search
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"description": "tasteskill v2: art direction for coding agents, with a library of researched directions, real pictures from open collections and a real-browser inspector. Also image-generation, redesign and style skills.",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"source": "./",
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"description": "tasteskill v2: art direction for coding agents, with a library of researched directions, real pictures from open collections and a real-browser inspector. Also image-generation, redesign and style skills.",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
"email": "hello@tasteskill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "tasteskill v2: art direction for coding agents, plus image-to-code, redesign, image-generation and style skills.",
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
|
||||
@@ -4,6 +4,56 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
|
||||
|
||||
---
|
||||
|
||||
## 2.1.0 - 2026-10-19
|
||||
|
||||
### The scroll-scene engine
|
||||
|
||||
The groundwork for big scroll scenes, the 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). The library of tested scenes, taken apart from 43 motion-led Sites of the Day and rebuilt from scratch, follows in 2.2; this release ships what they run on and the checks that judge them.
|
||||
|
||||
- **`assets/scenes/stage.js` and `stage.css`**: a sticky stage scrubbed by native scroll with GSAP ScrollTrigger (pinned versions), optional Lenis smoothing that stays off for touch and reduced motion, and a finished composition for reduced motion, no script and a failed CDN. `references/scenes.md` says how to build a scene on it.
|
||||
- **`scripts/scenes.mjs`** photographs every scene on a page at 0-100% of its hold on desktop and phone, and the reduced-motion page. **`inspect.mjs --film`** films the whole scroll a third of a screen at a time, and the inspector fails a scene that never started or rests on desktop.
|
||||
- `draw.mjs` can deal scenes from `library/scenes.json` once it exists (`--redraw scenes`, `--list scenes`).
|
||||
- `motion.md`: smoothing allowed through the stage; inside a scene the scene's numbers apply; outside scenes the quiet limits stay. Still no preloader, gate, custom cursor or wheel capture.
|
||||
|
||||
### Product pages that show their app
|
||||
|
||||
A software product's page builds every interface fragment it shows (the hero's live screen, a feature crop, a chart) as a real piece of the app: its style dealt once with `draw.mjs --app --keep palette,type --out .taste-skill/app`, built with `app.css` and `charts.js`, real data that adds up.
|
||||
|
||||
### Fixes
|
||||
|
||||
- The inspector's throwaway Chrome profiles are removed reliably (Chrome is stopped before its profile is deleted, the profile cache is capped, profiles left by killed runs are swept); a long test session had left tens of GB in the temp folder. `TASTE_SKILL_TMP` moves them off the system drive.
|
||||
- `motion.md` and `research.md`: a claim that smooth-scroll pages showed empty frames mid-scroll was a capture artefact and is gone.
|
||||
|
||||
### App screens: dashboards, admin, settings, inboxes
|
||||
|
||||
The default skill now builds the screens people work in, not only the pages they read. `SKILL.md` asks first whether the job is a page or an app screen; an app screen takes its own route.
|
||||
|
||||
- **`references/app-screens.md`**, a new chapter: the run for an app screen (inventory the data and the jobs, name the screen's one question and its one finding, write the data with assertions, deal a style, build the shell, the data and every state, check, look), the shell, the canvas and title, KPIs, charts, tables and lists, forms and settings, type and colour for light and dark, density, empty, zero, loading and error states, the phone and the tablet, the house rules for app screens and the tells of generated dashboards with what replaces each.
|
||||
- Distilled from a study of 23 real product screens chosen by the owner (17 products, 17 light and 6 dark), measured from their pixels. The screenshots stay private; the skill carries only the measurements and the rules.
|
||||
- **`draw.mjs --app`** deals an app style from `library/app.json`: seven styles (achromatic ledger, framed sheet, accent ladder, editorial greeting, pastel consumer, true-black flat, data terminal), each with tokens, type, a colour budget and the one move that makes it recognisable, plus five shells and 21 screen patterns for 13 kinds of product.
|
||||
- **`inspect.mjs --app`** and **`lint.mjs --app`** (switched on by an app sheet) judge app screens by their own rules: quiet borders, small labels, repeated panels and tables are allowed; heavy shadows, glass, icons in tinted squares, rainbow charts, placeholder data, text under 11px, a sidebar left on the phone, ragged rows, a thin first screen, landing-page titles and figures, a dead strip beside the canvas and colour sprawl are not.
|
||||
- **`assets/app.css`** (a neutral shell, panels, divided KPI strip, tables, pills, controls, tabs, empty states and the phone fold) and **`assets/charts.js`** (line, bars, ring, bar list, plan against spent, share against target, with annotations, reference lines, forecasts and capped ranges), both set to the study's defaults.
|
||||
- Tested in four blind rounds of eight, six, five and three builds judged from screenshots against the reference screens; the warnings at the top of the chapter come from what the judges marked down. The last round's complaint, "a well-applied kit, not a product with its own face", led to a required face step (each style lists ways to take one from the brief) and signature patterns for kinds of product (a power-flow summary, a leave timeline, a cover matrix, a sell-out strip).
|
||||
|
||||
### The frame of a page
|
||||
|
||||
Generated pages most often fail before any detail: no clear title, margins that do not match, a section pushed to one side with half the screen empty, long blank stretches, sections with no heading, text against the screen edge.
|
||||
|
||||
- `SKILL.md` step 6 gains "The frame", the house rules three new rows, and a finish line before the report.
|
||||
- `inspect.mjs` measures each of them: `no-title`, `name-only-title`, `off-centre` (FAIL, and a WARN from 16px of drift), `lopsided`, `empty-middle`, `blank-gap`, `untitled-section`, `edge-text`, `ragged-edges`, `small-title`, `many-h1`. The thresholds come from the study below: strong pages hold their margins within a few pixels, about one text section in a hundred leaves half the width empty, 82% of text sections open with a heading.
|
||||
- `critique.md`: a five-second pass, a frame pass and new caps. `layout.md`: empty space rewritten and a new section on the frame.
|
||||
|
||||
### A study of the latest 100 Awwwards Sites of the Day
|
||||
|
||||
Captured on 18 October 2026 at 1440 and 390, with what moved and the CSS that loaded measured in the page, and read by a panel of one model reader per site (91 usable). Learning only: nothing a site owns is reproduced.
|
||||
|
||||
- **Pictures and footage:** `imagery.md` §12, where pictures go (picture share per screen, nineteen placements ranked with their geometry and phone versions, footage, crops, text over pictures) and stricter video rules (a poster on every clip, one moving picture per screen, contrast checked across the whole loop).
|
||||
- **Motion:** `motion.md` gains what strong pages do with motion (the curves and durations that actually ran, staggers, scroll triggers, the cost of preloaders), new tokens (`--ease-expo`, `--ease-exit`, `--t-open`, staggers), scroll behaviours ranked, ten new recipes (lines that rise, an opening window, a held picture with passing captions, a sideways gallery, a receding hero, an index anchored to its row, a 1.2-second load sequence, view transitions between pages, an ink fill, rolling figures), the gimmicks to avoid and rules. Seven new signature moves in `library/motions.json`.
|
||||
- **Library:** 10 hero and 13 section compositions, 21 new studied sites and media and motion notes on 70 more (`draw.mjs --site <id>`).
|
||||
- **New checks:** `late-title` (the title is not readable 1.2s after load), `reduced-hidden` (content that stays hidden under reduced motion), `no-poster`.
|
||||
|
||||
---
|
||||
|
||||
## 2.0.0 - 2026-10-13
|
||||
|
||||
### Repo
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
<img src="assets/readme-banner.webp" alt="Taste Skill - Anti-slop Agent Skills for premium frontends" width="100%" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Taste Skill</h1>
|
||||
|
||||
<p align="center">
|
||||
<em>Anti-slop design skills for coding agents</em>
|
||||
</p>
|
||||
|
||||
<p align="center" style="margin-bottom: 8px;">
|
||||
<a href="https://tasteskill.dev" title="Visit tasteskill.dev"><img src="https://img.shields.io/badge/website-tasteskill.dev-24292f?style=flat" alt="Visit tasteskill.dev" /></a>
|
||||
|
||||
<a href="https://tastecode.dev" title="Visit tastecode.dev"><img src="https://img.shields.io/badge/new-TasteCode-ff6b00?style=flat" alt="New: TasteCode" /></a>
|
||||
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&logo=github&logoColor=white" alt="MIT License" /></a>
|
||||
|
||||
<a href="https://github.com/vercel-labs/agent-skills"><img src="https://img.shields.io/badge/Agent_Skills-compatible-24292f?style=flat&logo=github&logoColor=white" alt="Agent Skills compatible" /></a>
|
||||
|
||||
<a href="#install"><img src="https://img.shields.io/badge/Codex_%2F_Cursor_%2F_Claude-install-24292f?style=flat" alt="Codex, Cursor, Claude" /></a>
|
||||
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tastecode.dev"><img src="assets/readme-tastecode.webp" alt="TasteCode is here. A new home for your coding agents, with a design agent built in. Visit tastecode.dev" width="100%" /></a>
|
||||
<a href="https://tastecode.dev"><img src="assets/readme-tastecode.webp" alt="TasteCode is here. Taste Skill as a desktop app, with a design agent built in. Free and open source. Download at tastecode.dev" width="100%" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>From the Taste Skill team: <a href="https://tastecode.dev"><strong>TasteCode</strong></a>, a local desktop workspace for Codex, Claude Code and Grok with a design agent that briefs, builds and visually reviews your UI. <a href="https://github.com/Leonxlnx/tastecode">Source on GitHub</a>.</sub></p>
|
||||
<p align="center"><sub>The easiest way to get Taste Skill results: <a href="https://tastecode.dev"><strong>download TasteCode</strong></a>. Its design agent briefs, builds and visually reviews your UI. <a href="https://github.com/Leonxlnx/tastecode">Source on GitHub</a>.</sub></p>
|
||||
|
||||
<h3 align="center">Sponsors</h3>
|
||||
|
||||
@@ -60,28 +62,22 @@
|
||||
|
||||
**Taste Skill** is a set of Agent Skills that make coding agents design websites instead of assembling the average landing page. The default skill gives the agent an art direction, real pictures and a way to check its own work in a real browser.
|
||||
|
||||
Same brief, same agent, same prompt. Without the skill on the left, with it on the right:
|
||||
Pages it has built, each from one brief:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="examples/v2/coffee-without.webp" alt="Coffee shop page built without the skill" /></td>
|
||||
<td width="50%"><img src="examples/v2/coffee-with.webp" alt="The same coffee shop brief built with the skill" /></td>
|
||||
<td width="50%"><img src="examples/v2/coffee-storefront.webp" alt="Coffee shop page with a drawn storefront as its hero" /></td>
|
||||
<td width="50%"><img src="examples/v2/night-dates.webp" alt="Techno night page with its dates set into an archive photograph" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="examples/v2/coffee-roast.webp" alt="Coffee shop page section showing the roast in four photographs" /></td>
|
||||
<td width="50%"><img src="examples/v2/coffee-questions.webp" alt="Coffee shop page questions as stacked index cards" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Across four briefs built both ways, the pages without the skill had 37 small labels above headings, 249 hairline rules and no photographs. The pages with it had none of the labels, none of the rules and 48 real photographs. More pairs are under [Examples](#examples).
|
||||
The photographs are real, found in open archives and credited by the skill ([credits](examples/v2/CREDITS.md)). Watch the [launch film](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&logo=github&logoColor=white" alt="MIT License" /></a>
|
||||
|
||||
<a href="https://github.com/vercel-labs/agent-skills"><img src="https://img.shields.io/badge/Agent_Skills-compatible-24292f?style=flat&logo=github&logoColor=white" alt="Agent Skills compatible" /></a>
|
||||
|
||||
<a href="#install"><img src="https://img.shields.io/badge/Codex_%2F_Cursor_%2F_Claude-install-24292f?style=flat" alt="Codex, Cursor, Claude" /></a>
|
||||
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub><a href="#install">Install</a> · <a href="#how-v2-works">How it works</a> · <a href="#examples">Examples</a> · <a href="#skills">Skills</a> · <a href="#common-questions">FAQ</a> · <a href="#support-the-project">Sponsors</a> · <a href="#license">License</a></sub></p>
|
||||
<p align="center"><sub><a href="#install">Install</a> · <a href="#how-v2-works">How it works</a> · <a href="#skills">Skills</a> · <a href="#common-questions">FAQ</a> · <a href="#support-the-project">Sponsors</a> · <a href="#license">License</a></sub></p>
|
||||
|
||||
## Install
|
||||
|
||||
@@ -134,40 +130,31 @@ Already have the skill? Run the install command again. The install name did not
|
||||
|
||||
## How v2 works
|
||||
|
||||
The default skill, `taste-skill`, is new in v2. It is built from a study of 247 curated sites and it is a folder: instructions, references, a library and four scripts. A build runs in six steps.
|
||||
The default skill is built from a study of 247 curated sites. A build runs in six steps:
|
||||
|
||||
1. **Read the brief.** The agent lists what only this client has: the name and what it means, the street, the numbers, the one constraint.
|
||||
2. **Draw a direction.** A script deals three of 20 researched directions. The agent picks the one that can carry something from that list, and the script deals the rest: a hero, section layouts, a type system, a palette, a motion.
|
||||
3. **Find the idea.** One sentence that ties the client's own material to the direction, checked against the nearest competitor, plus one thing the visitor can do on the page.
|
||||
4. **Build with real pictures.** When the project has no photographs, a script finds real pictures and footage of the subject's world in open collections, downloads them and records the credits. Only what no honest picture exists of is drawn in code.
|
||||
5. **Check it in a real browser.** An inspector opens the page at 1440, 768 and 390 px and fails it on eyebrow labels, hairlines, gradients, resting shadows, headlines over two lines, empty screens and covered text.
|
||||
6. **Look and fix.** The agent critiques its own screenshots, fixes the top three problems and repeats, up to three rounds.
|
||||
1. Reads the brief and lists what only this client has.
|
||||
2. A script deals three of 20 researched directions and the agent picks one.
|
||||
3. Finds the idea: one sentence tying the client's own material to that direction.
|
||||
4. Builds with real pictures and footage from open collections, credited.
|
||||
5. Checks the page in a real browser at three widths and fails it on slop.
|
||||
6. Looks at its own screenshots and fixes the top three problems, up to three rounds.
|
||||
|
||||
A build takes an agent roughly 30 to 45 minutes and a few hundred thousand tokens. The full write-up is in [CHANGELOG.md](CHANGELOG.md) and the study is in [`references/research.md`](skills/taste-skill/references/research.md).
|
||||
A build takes roughly 30 to 45 minutes and a few hundred thousand tokens. Details are in the [changelog](CHANGELOG.md) and the [study](skills/taste-skill/references/research.md).
|
||||
|
||||
## Examples
|
||||
Dashboards and app screens (v2.1) take their own route: the skill deals one of the app styles distilled from real product screens, builds the shell, the data, the charts and the tables to those numbers, and checks the result with app rules: quiet panels, aligned figures, colour spent on data, a phone layout where the navigation folds away. A product's landing page builds the app it shows the same way, so its screenshots look like a working product.
|
||||
|
||||
Four briefs, each built once without the skill and once with it. Nothing else changed.
|
||||
|
||||
| Without | With taste-skill v2 |
|
||||
| --- | --- |
|
||||
| <img src="examples/v2/night-without.webp" alt="Techno night page without the skill" /> | <img src="examples/v2/night-with.webp" alt="Techno night page with the skill" /> |
|
||||
| <img src="examples/v2/studio-without.webp" alt="Architecture studio page without the skill" /> | <img src="examples/v2/studio-with.webp" alt="Architecture studio page with the skill" /> |
|
||||
| <img src="examples/v2/legal-without.webp" alt="Contract tool page without the skill" /> | <img src="examples/v2/legal-with.webp" alt="Contract tool page with the skill" /> |
|
||||
|
||||
The pictures on the right are real photographs from open archives, found and credited by the skill. Their credits are in [`examples/v2/CREDITS.md`](examples/v2/CREDITS.md). The launch film is attached to the [v2.0.0 release](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
|
||||
v2.1 also checks the frame of every page (a clear title on the first screen, equal margins, no section shoved to one side, no random blank stretches) and moves the way strong award sites do, measured on the latest 100 Awwwards Sites of the Day.
|
||||
|
||||
## Skills
|
||||
|
||||
Each skill does one job. You do not need all of them. The **install name** is the value you pass to `--skill`.
|
||||
|
||||
### The default
|
||||
|
||||
| Skill | Install name | What it does |
|
||||
| --- | --- | --- |
|
||||
| **[taste-skill](skills/taste-skill/SKILL.md)** | `design-taste-frontend` | v2. Art direction for landing pages, marketing sites, portfolios and product pages, in any stack. Works as described above. |
|
||||
| **[taste-skill](skills/taste-skill/SKILL.md)** | `design-taste-frontend` | v2. Art direction for landing pages, marketing sites, portfolios and product pages, and since v2.1 for dashboards and app screens, in any stack. Works as described above. |
|
||||
|
||||
### For a specific job
|
||||
<details>
|
||||
<summary>Skills for a specific job (8)</summary>
|
||||
|
||||
| Skill | Install name | What it does |
|
||||
| --- | --- | --- |
|
||||
@@ -180,7 +167,10 @@ Each skill does one job. You do not need all of them. The **install name** is th
|
||||
| **[stitch-skill](skills/stitch-skill/SKILL.md)** | `stitch-design-taste` | Rules for Google Stitch, with an optional `DESIGN.md` export. |
|
||||
| **[output-skill](skills/output-skill/SKILL.md)** | `full-output-enforcement` | For when the model ships half-finished work: full output, no placeholder comments. |
|
||||
|
||||
### Image generation
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Image generation skills (3)</summary>
|
||||
|
||||
These produce design images only, no code. Use them with ChatGPT Images, Codex image mode or any agent that generates images, then hand the frames to your coding agent.
|
||||
|
||||
@@ -190,9 +180,11 @@ These produce design images only, no code. Use them with ChatGPT Images, Codex i
|
||||
| **[imagegen-frontend-mobile](skills/imagegen-frontend-mobile/SKILL.md)** | `imagegen-frontend-mobile` | Mobile screens and flows for iOS, Android and cross-platform. |
|
||||
| **[brandkit](skills/brandkit/SKILL.md)** | `brandkit` | Brand-kit boards: logo directions, palettes, type, applications. |
|
||||
|
||||
</details>
|
||||
|
||||
### Which one should I use?
|
||||
|
||||
- Building a new public page: **taste-skill**.
|
||||
- Building a new public page, a dashboard or an app screen: **taste-skill**.
|
||||
- Improving a page that already exists: **redesign-skill**.
|
||||
- The visual direction is already decided: **soft-skill**, **minimalist-skill** or **brutalist-skill**.
|
||||
- You want images first, then code: an image-generation skill, or **image-to-code-skill** for the whole pipeline in one go.
|
||||
@@ -219,26 +211,17 @@ Both have three 1 to 10 dials at the top of the file: **DESIGN_VARIANCE** (centr
|
||||
|
||||
## Common Questions
|
||||
|
||||
**What does the default skill need?**
|
||||
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. It needs the internet only to find pictures.
|
||||
|
||||
**Where do the pictures come from, and can I use them?**
|
||||
From open collections such as Wikimedia Commons, Openverse and the Met. Only public domain, CC0, CC BY and CC BY-SA files are offered, and the skill puts the credits on the page. Replace them with your own when you have them.
|
||||
|
||||
**Does it work with React, Vue, Svelte, plain HTML?**
|
||||
Yes. The rules are about design, not a framework.
|
||||
|
||||
**What does the default skill need on my machine?**
|
||||
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. Without Node 22 or a browser the agent can still follow the instructions, but the inspector cannot run.
|
||||
|
||||
**Where do the pictures come from, and can I use them?**
|
||||
From open collections: Openverse, Wikimedia Commons, the Art Institute of Chicago and the Met. By default the skill only offers public domain, CC0, CC BY and CC BY-SA files, records the title, creator, licence and source of every download, and puts the credits on the page. They are pictures of the subject's world, not of your product. Replace them with your own when you have them.
|
||||
|
||||
**Does it need the internet?**
|
||||
Only to find pictures and footage. Without it the agent draws what it needs and says so in its report.
|
||||
|
||||
**Is it for dashboards and app screens?**
|
||||
No. The default skill is for public-facing pages. Use **minimalist-skill** or **soft-skill** for product UI.
|
||||
|
||||
**What is a SKILL.md?**
|
||||
An instruction file an agent loads on its own when the task fits. Install it with `npx skills add`, or copy a single-file skill into your project or a conversation.
|
||||
|
||||
**How is this different from other design skills?**
|
||||
The default skill does not ask the model to invent a look, because a model asked to invent picks the category default every time. A script deals researched directions, a real browser measures the result, and the agent has to look at its own screenshots before it is done.
|
||||
Yes, since v2.1. The skill tells a page from an app screen and builds a dashboard, an admin view or a settings screen with its own run: a style dealt from a study of real product screens, quiet panels, honest data and charts, and its own checks (`inspect.mjs --app`).
|
||||
|
||||
## Support the project
|
||||
|
||||
@@ -259,7 +242,8 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Community Sponsors
|
||||
<details>
|
||||
<summary>Community sponsors</summary>
|
||||
|
||||
<a href="https://github.com/dnakov"><img src="https://github.com/dnakov.png" width="40" height="40" style="border-radius:50%" alt="dnakov" title="dnakov" /></a>
|
||||
<a href="https://github.com/AkramReshad"><img src="https://github.com/AkramReshad.png" width="40" height="40" style="border-radius:50%" alt="AkramReshad" title="AkramReshad" /></a>
|
||||
@@ -283,6 +267,7 @@ If Taste Skill helps you, consider sponsoring:
|
||||
<a href="https://github.com/emilkowalski"><img src="https://github.com/emilkowalski.png" width="40" height="40" style="border-radius:50%" alt="emilkowalski" title="emilkowalski" /></a>
|
||||
<a href="https://github.com/AnthonyLiuVa"><img src="https://github.com/AnthonyLiuVa.png" width="40" height="40" style="border-radius:50%" alt="AnthonyLiuVa" title="AnthonyLiuVa" /></a>
|
||||
<a href="https://github.com/Wolfy18"><img src="https://github.com/Wolfy18.png" width="40" height="40" style="border-radius:50%" alt="Wolfy18" title="Wolfy18" /></a>
|
||||
<a href="https://github.com/Gigslol"><img src="https://github.com/Gigslol.png" width="40" height="40" style="border-radius:50%" alt="Gigslol" title="Gigslol" /></a>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/leonxlnx/taste-skill">
|
||||
@@ -294,21 +279,11 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Research
|
||||
</details>
|
||||
|
||||
The default skill rests on a study of 247 curated sites: method, numbers and blind tests are in [`skills/taste-skill/references/research.md`](skills/taste-skill/references/research.md). Earlier background writing lives in [`research/`](research/).
|
||||
<sub>Feedback: open an issue or pull request, DM [@lexnlin](https://x.com/lexnlin) or [@blueemi99](https://x.com/blueemi99), or email [hello@tasteskill.dev](mailto:hello@tasteskill.dev).</sub>
|
||||
|
||||
## Feedback & Contributions
|
||||
|
||||
Suggestions and bug reports are welcome:
|
||||
|
||||
- Open an issue or a pull request on GitHub
|
||||
- DM [@lexnlin](https://x.com/lexnlin) or [@blueemi99](https://x.com/blueemi99)
|
||||
- Email [hello@tasteskill.dev](mailto:hello@tasteskill.dev)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Taste Skill has no official token, coin, or crypto project. Any token using my name, image, or project is unaffiliated and not endorsed by me.
|
||||
<sub>Taste Skill has no official token, coin, or crypto project. Any token using my name, image, or project is unaffiliated and not endorsed by me.</sub>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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. Not for dashboards or app screens.
|
||||
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
|
||||
@@ -31,6 +31,34 @@ In the commands below `<skill>` is this skill's folder. Run them from the projec
|
||||
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.
|
||||
@@ -102,7 +130,7 @@ 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 | section:<role> | direction`) only for a
|
||||
- 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;
|
||||
@@ -153,6 +181,12 @@ right now and how long until the last ferry; choose a size and see it against th
|
||||
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`:
|
||||
@@ -160,8 +194,10 @@ Write the whole page as a copy deck (plain text) before markup, following `refer
|
||||
- **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. One primary action, named for what it does, as a real button on the first
|
||||
screen.
|
||||
- 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
|
||||
@@ -211,6 +247,13 @@ not finished. Order of work:
|
||||
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
|
||||
@@ -221,11 +264,28 @@ not finished. Order of work:
|
||||
`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. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
|
||||
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.
|
||||
7. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
|
||||
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
|
||||
@@ -236,12 +296,18 @@ behind a decision; the laws below are the short form.
|
||||
```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 saves screenshots to
|
||||
`.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
|
||||
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.
|
||||
@@ -260,9 +326,9 @@ Judge only the pictures. If a problem is visible in a screenshot, it is real, wh
|
||||
|
||||
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). The layer has four parts:
|
||||
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.
|
||||
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.
|
||||
@@ -278,6 +344,16 @@ 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
|
||||
@@ -292,6 +368,9 @@ 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. |
|
||||
@@ -341,7 +420,9 @@ Short form. Numbers are from the study unless marked as house style.
|
||||
**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.
|
||||
- The gap between sections is clearly larger than any gap inside one: 120–220px on desktop.
|
||||
- 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**
|
||||
@@ -398,17 +479,22 @@ open collections could not be searched.
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions. Do not read it whole; use the script. |
|
||||
| `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%; }
|
||||
@@ -26,13 +26,18 @@
|
||||
--section: clamp(5rem, 12vw, 13rem); /* space between sections */
|
||||
--radius: 0px; /* one radius for the whole page: 0, or large, decided once */
|
||||
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);
|
||||
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--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 ---------- */
|
||||
|
||||
@@ -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()); }
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"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,
|
||||
@@ -43,6 +43,34 @@
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -890,5 +890,197 @@
|
||||
"aww-px-push",
|
||||
"opl-lorenzo-bocchi-photography"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "window-opens-to-the-edges",
|
||||
"name": "Window that opens to the edges",
|
||||
"archetype": "pinned-stage",
|
||||
"directions": [
|
||||
"pinned-poster",
|
||||
"place-carries-it",
|
||||
"hero-object"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A flat paper or pale ground, and one picture seen through a small window that becomes the whole screen as the page scrolls. The hero is a wrapper 200svh tall holding a stage with `position: sticky; top: 0; height: 100svh`. On the first frame the window is a 4:5 portrait opening 36% of the viewport wide (518×648px), centred, its top and bottom 126px from the edges, square corners; give it a shape from the subject if it has one (an arch, a rounded diamond, a lens). Build it as `clip-path: inset(126px 461px)` on a layer that is itself 100vw by 100svh, so the photograph or muted film inside is laid out full-bleed and never moves: only the opening grows. The headline sits left of the window on columns 1–3: two lines at clamp(44px, 4.4vw, 64px), leading 0.95, flush to the 32px margin, its last baseline level with the window's lower edge. Right of the window on columns 10–12, bottom-aligned to the same line: one supporting sentence at 18px, max 26ch, and the single action as a solid rectangular button. Wordmark top-left and three text links top-right at 15px on the same margin; nothing else sits on the ground. Over the first 100svh of scroll the inset runs to zero on a scroll-linked timeline while the text, which sits above the picture layer, rises and fades out before the opening reaches it; the picture then holds full-bleed for 50svh before the next section slides over it. Without scroll timelines, or under reduced motion, show the open picture with the headline over a neutral scrim in its bottom-left corner.",
|
||||
"mobile": "The stage stays sticky. The window starts 78vw wide at 4:5, centred, its top at 30% of the height, leaving about a quarter of the screen below it. The headline sits above it at 10vw on two lines inside 20px margins, 72px down; the supporting sentence at 16px and a full-width action sit under the window. The opening reaches the edges within 70svh of scroll. The links fold behind a menu word.",
|
||||
"headline": "Two short lines of up to about 9 characters each: the name with what it is, or a 2–4 word claim. It scrolls away as the picture takes the screen.",
|
||||
"motion": "pinned-story",
|
||||
"seenIn": [
|
||||
"aww-decathlon-yestalgia",
|
||||
"aww-leo-parpeix-portfolio-2026",
|
||||
"aww-revelatio-studio",
|
||||
"aww-vero-new-york"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ring-of-frames-around-the-headline",
|
||||
"name": "Ring of frames around the headline",
|
||||
"archetype": "mosaic",
|
||||
"directions": [
|
||||
"contact-sheet",
|
||||
"dark-room",
|
||||
"name-as-monument"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One near-black or deep flat field. Eighteen to twenty-four photographs or film stills sit on an ellipse centred on the viewport, with radii of 560px across and 310px down, so their centres run from x 160 to 1280 and from y 140 to 760. Frames have square corners and no border, mix 4:3 and 3:4, and grow towards the viewer: about 100px wide at the top of the ring and 150px at the bottom and at both ends, so the ring reads as tilted. Each frame is turned so its base lies along the curve (never more than 30 degrees), and neighbours may overlap by up to a fifth. Inside the ring an empty core about 600×340px holds the text, centred: the headline on two lines at clamp(56px, 5.4vw, 80px), leading 0.95, in the light ink; one supporting sentence at 18px, max 40ch, 24px under it; then the single action as a solid light rectangle. Wordmark top-left and three text links top-right at 15px on 32px margins, outside the ring. Every frame is a link to its project: pointing at one, or focusing it, brings it to full brightness, drops the others to 45% and replaces the supporting sentence with that project's name and year. Place the frames with a few lines of trigonometry (left and top in percentages) and keep them still; the wheel scrolls the page as usual.",
|
||||
"mobile": "The ellipse becomes a circle 132vw across, centred at half height, so the frames at its left and right ends run off both edges and the ring reads as two arcs, one above and one below the text. Keep twelve frames at 64–88px. The headline sits in the core at 10.5vw on two lines inside 28px margins, with the sentence at 16px and the action at the full width of the core beneath it. Wordmark top-left and a menu word top-right sit above the top arc. A tap on a frame opens its project.",
|
||||
"headline": "A 3–6 word line on two lines, at most about 14 characters per line, so it fits the empty core.",
|
||||
"motion": "masked-headline",
|
||||
"seenIn": [
|
||||
"aww-cipher",
|
||||
"aww-squarespace-foundations",
|
||||
"aww-studio-k95-3",
|
||||
"aww-kononenko-architectural-bureau"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "navigation-on-the-midline",
|
||||
"name": "Navigation on the midline",
|
||||
"archetype": "fullbleed-media",
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"quiet-instrument",
|
||||
"dark-room"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One photograph or muted film fills the viewport at 100vw by 100svh, `object-fit: cover`, framed so the middle of the screen cuts the subject: a face or a skyline in the upper half, hands or ground in the lower. There is no headline block and nothing in the top or bottom bands. One rail 64px tall runs across the exact vertical centre (its top at 418px) and carries everything on one baseline in the paper colour: the `h1` on the 24px left margin, the name with what it is in two to five words at clamp(32px, 2.6vw, 40px) in medium weight, doing the job of the wordmark; from column 6, one supporting sentence at 18px, max 44ch; at the right margin, two text links at 15px and the single action as a small solid button in the paper colour. If the frame is light behind the rail, give the rail alone a neutral dark scrim at 35%. The rail is `position: sticky; top: 0` in the hero's flow, so as the page scrolls it rises with the content and docks as the header bar without changing size or layout, taking the page ground once the pictures have passed. A second full-bleed photograph, a closer detail of the same subject, follows directly beneath, so the rail is seen crossing two pictures before it reaches the top. On a black ground with one photographed object instead of a film, stand the object on the rail at about 17% of the width, between the sentence and the links, so the line seems to pass behind it.",
|
||||
"mobile": "A portrait crop keeps the subject's middle on the screen's middle. The rail stays on the vertical centre, as tall as the `h1` needs at 28px on up to two lines, with the `h1` on the left and Menu on the right. The sentence leaves the rail and sits at 17px in the bottom-left on a 20px margin with the action as a full-width button under it, over a neutral scrim limited to that corner. The rail docks at the top as on desktop.",
|
||||
"headline": "The `h1` on the rail: the name with what it is, two to five words at 32–40px on one line, and one sentence of up to 50 characters beside it; the picture does the rest.",
|
||||
"motion": "slow-crossfade",
|
||||
"seenIn": [
|
||||
"aww-odyssee",
|
||||
"aww-milledollars",
|
||||
"aww-twks-1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "title-in-the-sky-line-on-the-horizon",
|
||||
"name": "Title in the sky, line on the horizon",
|
||||
"archetype": "fullbleed-media",
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"hero-object",
|
||||
"sentence-as-layout"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One wide photograph or render fills the viewport, chosen with the subject in the lower 55% and open sky, fog or a plain wall above it. The headline is centred in that empty upper zone: two lines at clamp(72px, 7.2vw, 112px), leading 0.9, its cap line 72px from the top, the first line short and the second longer so the block widens towards the subject. On the horizon, with its baseline at 52% of the height, one supporting sentence is split into two halves at 22px: the first half right-aligned to end at column 5, the second left-aligned from column 8, so the subject shows through the 260px gap between them. Wordmark top-left on a 32px margin; up to three text links from column 9; the single action top-right as a solid rectangle in the paper colour. All type is in one ink chosen against the sky; on a pale sky use the photograph's darkest tone rather than black. Nothing else crosses the lower half.",
|
||||
"mobile": "The picture is re-cropped to portrait with the subject in the lower 45%. The headline sits centred in the sky at 11vw on two lines, 96px down, and the two halves of the sentence become two centred lines at 17px directly under it. The action is a full-width button 24px above the bottom edge on a neutral scrim band 96px tall. Wordmark top-left and a menu word top-right.",
|
||||
"headline": "Two lines, the second the longer, at most about 14 characters per line: the name of the place, or a 3–5 word claim about it.",
|
||||
"motion": "shutter-image",
|
||||
"seenIn": [
|
||||
"aww-era-residence",
|
||||
"aww-vectr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "name-across-the-middle",
|
||||
"name": "Name across the middle",
|
||||
"archetype": "giant-wordmark",
|
||||
"directions": [
|
||||
"name-as-monument",
|
||||
"type-only-statement",
|
||||
"loud-flat-poster"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "Paper and ink only; no picture on the first screen. The name runs on one line across the vertical centre, sized to span from the 18px left margin to the 18px right margin (97.5% of the width). Set it as inline SVG text with a `viewBox` fitted to the word and `width: 100%`, or tune its vw size to the letter count, so it never wraps. In a heavy grotesk the letters stand about 280px tall, from about 310px to 590px; nothing touches or overlaps them. The four corners carry everything else on 18px margins. Top-left: one supporting sentence at 18px on two lines, max 36ch, and 16px below it the single action as a solid ink rectangle. Top-right: a menu word or three text links at 15px. Bottom-left, on a baseline 24px above the edge: the city or the kind of work at 16px. Bottom-right on the same baseline: two or three text links at 15px. The ground between the corners and the name, about 230px above and below it, stays empty and is closed on both sides by the corner texts. The name returns in the footer at the same width with ground and ink swapped and a two-line invitation above it, so the page closes like a bracket.",
|
||||
"mobile": "The name stays on one line across the width inside 16px margins and moves up to 22% of the height. The supporting sentence follows on the left margin at 17px, then the action at full width. The lower half holds the first picture of the work as a 4:5 frame cropped by the bottom edge, so the screen does not end in empty paper. A menu word sits top-right.",
|
||||
"headline": "The name itself on one line, up to about 12 characters; the 18px sentence in the top-left corner says what it is.",
|
||||
"motion": "scaling-wordmark",
|
||||
"seenIn": [
|
||||
"aww-nothin",
|
||||
"aww-produx-design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "square-over-a-moving-line",
|
||||
"name": "Square over a moving line",
|
||||
"archetype": "type-poster",
|
||||
"directions": [
|
||||
"name-as-monument",
|
||||
"place-carries-it",
|
||||
"loud-flat-poster"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A white or pale flat ground. One line of display type at clamp(150px, 15vw, 230px), weight 500, tracking -0.02em, runs edge to edge with its cap band centred 360px from the top, moving left at a slow, constant 40px a second: the name, or a claim of two or three words, repeated with a solid round dot one third of the cap height between copies and duplicated for a seamless loop. One square photograph 272px across (19vw), square corners, `object-fit: cover`, sits exactly on the band's centre behind the type, so the words slide across it; the moving line is set twice, the second copy in the paper colour and clipped to the square with `clip-path`, so the letters turn light as they cross the photograph and every word reads whole. It shows the subject's best detail. In the study the photograph sits on top of the words; here nothing lies on top of a headline. 150px under the photograph, centred: one supporting sentence at 22px on two lines, max 36ch, and 24px below it the key fact (a price, a date, an address) at 17px. Top row on 32px margins: one text link on the left, the wordmark centred, one text link on the right. Bottom row on the same margins, 40px above the edge: a location line at 16px on the left and the single action as a solid rectangle on the right. The `h1` holds the moving words once as plain text and the moving copies are `aria-hidden`. Under reduced motion the line stops with one copy centred and both ends cropped.",
|
||||
"mobile": "The line drops to 34vw with its band at 26% of the height and keeps moving; the photograph shrinks to a 46vw square and stays centred on it. The sentence sits centred at 18px about 64px below the photograph, then the fact at 16px. The action becomes a full-width button 20px above the bottom edge. The wordmark stays centred at the top and the two links fold into a menu word on the right.",
|
||||
"headline": "The name or a 2–3 word claim of at most 14 characters, set once in the `h1` and repeated in the moving line.",
|
||||
"motion": "edge-marquee",
|
||||
"seenIn": [
|
||||
"aww-realevate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "two-doors-on-a-dark-wall",
|
||||
"name": "Two doors on a dark wall",
|
||||
"archetype": "split",
|
||||
"directions": [
|
||||
"dark-room",
|
||||
"quiet-instrument"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "For a subject with two halves a visitor must choose between: two exhibitions, two audiences, two places, two collections. One near-black flat field. Two portrait photographs at 3:4, each 280×373px, stand side by side 80px apart, so the pair spans x 400–1040 (44% of the width) with their tops at 300px; square corners, no frame, the pictures supplying every colour on the screen. Each photograph is the link to its half, and directly beneath it, left-aligned to the frame, sit its name at 22px in the light ink and one line at 16px in the soft ink. The headline is centred above the pair: two lines at clamp(36px, 3.4vw, 52px), leading 1.05, its first line 104px from the top, at most 26 characters per line. Wordmark top-left and one text link top-right at 15px on 32px margins. About 150px of dark ground stays below the pair and 400px either side of it, closed by the two doors at the centre. Pointing at or focusing one door keeps it at full strength and dims the other to 50%. The page continues below with the two halves in order, so each door is also an anchor to its half.",
|
||||
"mobile": "The pair stays side by side: each photograph 168px wide at 3:4 with a 14px gap, centred at 46% of the height, its name at 17px and its line at 16px beneath it. The headline sits above at 8vw on two lines, centred, 64px down. The wordmark and the link stay in the top corners.",
|
||||
"headline": "A 4–8 word sentence on two lines that names the choice, at most 26 characters per line.",
|
||||
"motion": "stillness",
|
||||
"seenIn": [
|
||||
"aww-hearst-exhibit-2026"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "title-from-top-to-bottom-edge",
|
||||
"name": "Title from the top edge to the bottom edge",
|
||||
"archetype": "bottom-anchored-type",
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"name-as-monument",
|
||||
"dark-room"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One full-bleed photograph or film at 100vw by 100svh, with a horizon near half height and empty sky or darkness above. The headline is two lines in a heavy face at clamp(140px, 13vw, 200px), leading 0.85, pulled as far apart as the screen allows: line one flush to the 32px left margin with its cap line 72px from the top; line two indented 28vw (403px) and anchored to the bottom, its baseline 24px above the lower edge, running towards the right margin. Lay the hero out as a grid of three rows (`grid-template-rows: auto 1fr auto`): line one in the first row, line two in the third, both spans of one `h1` with `display: contents`. The middle row leaves the picture clear apart from one supporting sentence at 18px, max 44ch, crossing the horizon at 48% of the height from 30vw, and the single action directly beneath it as a solid rectangular button with its label at 18px. The wordmark and two text links sit top-right on the 32px margin at 15px; the top-left belongs to line one. Choose a picture whose upper-left and lower-right are calm enough for the type, or lay a neutral scrim under those two corners only.",
|
||||
"mobile": "Keep the two lines at 19vw: line one flush left on a 16px margin 88px down, line two right-aligned to the 16px margin with its baseline 20px above the bottom. The sentence at 17px and the action button sit between them on the left margin, at 46% of the height. The picture is re-cropped to portrait around the horizon.",
|
||||
"headline": "Two words or short phrases, one per line, at most about 8 characters each.",
|
||||
"motion": "masked-headline",
|
||||
"seenIn": [
|
||||
"aww-the-state-of-the-gallery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "line-up-on-an-arc",
|
||||
"name": "Line-up on an arc",
|
||||
"archetype": "object-centre",
|
||||
"directions": [
|
||||
"specimen-catalogue",
|
||||
"hero-object",
|
||||
"dark-room"
|
||||
],
|
||||
"needs": [
|
||||
"product-photo"
|
||||
],
|
||||
"blueprint": "For a range: flavours, colourways, editions, sizes. One deep flat ground, which may take the current item's own colour. The current item, a cut-out photograph, stands at the exact centre about 240px wide and 450px tall (y 210–660), tilted no more than 8 degrees. Its siblings stand behind it on a shallow arc, alternately a little higher and lower: each step out is 30% smaller, a quarter darker (`filter: brightness()`) and about 260px further from the centre, three on each side, so the outermost pair is cut by the left and right edges. Under the current item, centred: its name at clamp(32px, 2.8vw, 44px) and one fact at 17px (what it tastes of, what it is made of, the price). The headline sits top-left on columns 1–4 at clamp(36px, 3.2vw, 48px) on two lines with the supporting sentence at 17px under it; the single action, a solid rectangle, sits top-right on a 32px margin. The arc is a horizontal scroller with `scroll-snap-type: x mandatory`, each item snapping to the centre, its scale and brightness set from its distance to the centre, so a drag, a swipe, the arrow keys or a click on a sibling brings it forward; the ground colour follows with a 300ms transition. No arrows drawn on the screen and no counter.",
|
||||
"mobile": "The current item takes 55% of the width and half the height, centred at 46% of the height; one sibling peeks in at each edge at 60% scale, half cut off. The headline sits at the top at 8vw on two lines with the supporting sentence beneath; the item's name and fact sit under the item at 24px and 16px. The action becomes a full-width button 20px above the bottom edge.",
|
||||
"headline": "A plain 3–6 word line on two lines, at most 20 characters per line; the item's own name under the object changes with the selection.",
|
||||
"motion": "drag-row",
|
||||
"seenIn": [
|
||||
"aww-ciao-energy-launch-website"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "view-with-two-cards-in-the-corner",
|
||||
"name": "View with two cards in the corner",
|
||||
"archetype": "fullbleed-media",
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"hero-object",
|
||||
"editorial-report"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One photograph or muted film fills the viewport, `object-fit: cover`, with the subject in the right half. The headline sits on the left at mid-height: two lines at clamp(44px, 3.9vw, 60px), bold, leading 1.08, flush to the 32px margin on columns 1–5, the block vertically centred; 20px below it, one supporting sentence at 18px, max 40ch, in the paper colour. In the bottom-right corner, 32px from the right and bottom edges, two cards sit side by side with a 16px gap, each two columns wide (about 210px) at 3:2: a photograph of the newest thing (a project, an edition, an article) with a paper-colour bar 40px tall fused to its lower edge, holding its title at 16px on the left and a small arrow on the right. They are the next steps after the single action, which is a solid rectangle top-right beside a menu word. One short fact at 16px in the paper colour may be pinned onto the subject itself, beside a solid 8px dot on the point it describes, with no line joining them. Wordmark top-left on the 32px margin. If the left half is light, lay a neutral dark scrim under columns 1–6 only.",
|
||||
"mobile": "The picture is re-cropped to portrait with the subject in the upper half. The headline sits at 9vw on two lines on the 16px margin at 44% of the height, with the sentence at 16px beneath it. The two cards sit side by side across the full width at the bottom, 16px from the edges, each about 171px wide at 3:2 with its title bar. Wordmark top-left; the action and a menu word top-right.",
|
||||
"headline": "A plain two-line claim of 4–7 words, at most about 17 characters per line at 3.9vw.",
|
||||
"motion": "slow-crossfade",
|
||||
"seenIn": [
|
||||
"aww-cominvi",
|
||||
"aww-no-art"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -17,12 +17,12 @@
|
||||
{
|
||||
"id": "pinned-story",
|
||||
"name": "Pinned story",
|
||||
"description": "One element holds its place with `position: sticky` (a title, a frame, a product fragment) while the items that belong to it scroll past or swap inside it. The page's ordinary scrolling does the work; nothing is hijacked. Keep the pinned run under six screens. Recipe: 'Pinned story' in references/motion.md."
|
||||
"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: 'List with image preview' in references/motion.md."
|
||||
"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",
|
||||
@@ -53,5 +53,40 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -4132,5 +4132,298 @@
|
||||
"opl-near",
|
||||
"opl-midnight-2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "work-names-pass-a-held-square",
|
||||
"role": "work",
|
||||
"name": "Names passing a held square",
|
||||
"shape": "index",
|
||||
"tags": [
|
||||
"type-led",
|
||||
"photo-led",
|
||||
"poster"
|
||||
],
|
||||
"directions": [
|
||||
"name-as-monument",
|
||||
"loud-flat-poster",
|
||||
"pinned-poster",
|
||||
"specimen-catalogue"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A flat ground and one centred column of very large names: eight to fourteen project or product names in condensed capitals at clamp(96px, 14vw, 200px), leading 0.85, one per line and each line centred, so long names run nearly to the margins and short ones leave wide ground either side. A square frame 450px across (31vw), square corners, holds at the centre of the viewport behind the list: a sticky element (`position: sticky; top: calc(50svh - 225px)`) at the head of the list with a negative bottom margin equal to its height, centred, below the names in the stacking order, so the passing names cross the picture and are never hidden by it. Choose pictures with a calm centre, or lay a flat 30% scrim inside the frame, so the crossing name keeps 3:1. In the study the picture sits on top of the names; here nothing lies on top of large type. It shows the picture of whichever name is crossing its centre (an IntersectionObserver with a thin root margin at the viewport's middle sets the current name) and swaps with a 200ms crossfade. Level with the frame's middle, 48px to its left and right, two facts about the current item sit at 16px, the kind of work on the left and the year or price on the right, changing with it. The heading opens the list as its first line, at the same size in the soft ink, two words at most. Every name is a link. The section runs about three screens, so each name holds the centre for about a quarter of a screen.",
|
||||
"mobile": "Names drop to 15vw and may wrap to two lines, still centred. The frame shrinks to a 60vw square and stays sticky at the centre; the two facts move into one row at 16px directly under it. The heading line stays first.",
|
||||
"seenIn": [
|
||||
"aww-decathlon-yestalgia"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "work-title-torn-at-the-seam",
|
||||
"role": "work",
|
||||
"name": "Title torn at the seam",
|
||||
"shape": "pinned",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"poster"
|
||||
],
|
||||
"directions": [
|
||||
"split-stage",
|
||||
"pinned-poster",
|
||||
"dark-room",
|
||||
"name-as-monument"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A wrapper about 95svh tall per project holds a sticky stage of 100svh, split at exactly 50vw into two portrait panes of 720×900px, `object-fit: cover`, square corners, no gap: two different pictures or loops of the same project (the whole and a detail, before and after). The project's title sits on the seam, centred on x 720 at the optical middle: capitals in a heavy face at clamp(96px, 11vw, 160px), leading 0.8, tracking -0.04em, at most two lines, in one light colour chosen per project. It is set twice inside the one heading element, the second copy `aria-hidden`, each copy clipped to its own half with `clip-path: inset(0 50% 0 0)` and `inset(0 0 0 50%)`. Directly under it, centred, the discipline and year at 16px and a text link with an arrow at 16px. While a project crosses the stage, the left pane and left half-title travel up and the right pane and right half-title travel down (panes ±20svh, title ±30svh, on a scroll-linked timeline), so the name tears at the seam and lines up exactly when the project is centred. The section's two-word heading sits at clamp(28px, 2.4vw, 36px) in the stage's top-left corner, 32px in. Four to eight projects; the stage releases after the last. Under reduced motion the halves stay aligned and the projects change by crossfade.",
|
||||
"mobile": "Keep the split: two strips 195px wide running the full height of the screen with the same opposing travel. The title drops to 12.5vw on the seam, at most two lines, with the discipline line and the link under it at 16px; give the link a 44px hit area.",
|
||||
"seenIn": [
|
||||
"aww-glitch-grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "work-one-slide-two-neighbours",
|
||||
"role": "work",
|
||||
"name": "One slide, two neighbours",
|
||||
"shape": "strip",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"quiet"
|
||||
],
|
||||
"directions": [
|
||||
"editorial-report",
|
||||
"contact-sheet",
|
||||
"place-carries-it",
|
||||
"product-performs"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "Each project is one band the full width of the viewport. In its middle stands one slide 73% of the width (1046×600px, x 197–1243), square corners, `object-fit: cover`. The previous and next pictures of the same project peek in at the edges, 177px of each visible, with 20px gaps, so the row reads as a filmstrip cut by the viewport. The strip scrolls sideways with `overflow-x: auto` and `scroll-snap-type: x mandatory`, each picture snapping to the centre, the scrollbar hidden. 26px under the strip runs one row at 16px on the 12-column grid with 20px margins, every item on a fixed column start: the project's name in the full ink at column 1 with its category in the soft ink after it, the recognition it received at column 4, the year at column 7, the team or client at column 10. A text link with an arrow sits 24px under the name. 240px of ground separates one project from the next, and all projects share one photographic treatment so the run reads as a set. The section's heading, two or three words at clamp(40px, 4vw, 60px), sits on the left margin 96px above the first strip.",
|
||||
"mobile": "The slide is 78% of the width at 4:5, with 32px of each neighbour showing and 10px gaps, still snapping. The row of facts becomes two columns, name and category on the left, year and team from 50% of the width; the link drops under the name. 64px between projects.",
|
||||
"seenIn": [
|
||||
"aww-leo-parpeix-portfolio-2026"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "showcase-cards-cross-a-held-photograph",
|
||||
"role": "showcase",
|
||||
"name": "Cards crossing a held photograph",
|
||||
"shape": "carousel",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"colour-field"
|
||||
],
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"pinned-poster",
|
||||
"colour-chapters",
|
||||
"dark-room"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A wrapper about 350svh tall holds a sticky stage of 100svh. On the stage one full-bleed photograph of the subject's ground (a landscape, a material, an aerial texture) stays still, darkened evenly with `filter: brightness(0.6)`. Over it a track of four to six cards travels from right to left as the page scrolls (a `translateX` tied to the wrapper's scroll progress), so one card is always near the centre and the next shows about 300px at the right edge. Each card is 720×560px, square corners, vertically centred with 170px of photograph above and below it: its top 68% is a photograph of one item (a room, a building, a stop on the route), its lower 32% a flat panel in one earth tone from the palette, a different tone per card at a similar lightness, holding the item's name at clamp(32px, 3vw, 48px) bottom-left with 28px padding and a two-line description at 16px right-aligned beside it. Cards are 40px apart. The section heading, two to four words at clamp(40px, 4.4vw, 68px) in the paper colour, sits top-left on the stage over the photograph before the first card arrives and moves out ahead of it. Keep the run under three screens; at its end the stage releases into the next section.",
|
||||
"mobile": "No pin and no sideways travel. The photograph runs full-bleed as a 4:5 band under the heading at 9vw, and the cards follow it at full width minus 16px margins, 24px apart, each a 4:3 photograph over its colour panel 140px tall, with the name at 26px and the description at 16px.",
|
||||
"seenIn": [
|
||||
"aww-lpas",
|
||||
"aww-white-desert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-object-rides-the-schedule",
|
||||
"role": "list",
|
||||
"name": "Object riding down the schedule",
|
||||
"shape": "table",
|
||||
"tags": [
|
||||
"object-led",
|
||||
"type-led",
|
||||
"dense"
|
||||
],
|
||||
"directions": [
|
||||
"hero-object",
|
||||
"borrowed-category",
|
||||
"loud-flat-poster",
|
||||
"dark-room",
|
||||
"specimen-catalogue"
|
||||
],
|
||||
"needs": [
|
||||
"product-photo"
|
||||
],
|
||||
"blueprint": "A schedule of dated events (races, concerts, tastings, departures) with the subject's own object travelling down it. On a dark flat ground, on the page's own margins, the heading is one centred line of condensed capitals at clamp(40px, 3.6vw, 56px), split into its two words with a gap between them, and in the gap stands a cut-out photograph of the object seen from above or in profile, about 320px wide (22% of the width) and up to 64% of the viewport tall. Below come full-width rows 84px tall, each a two-part grid: the event's name at 26px in condensed capitals on the left with a one-line note at 16px in the soft ink under it, and the date and place right-aligned at 16px on the note's baseline. Rows alternate between the ground and a tone one step lighter instead of rules. As the page scrolls, the object leaves the heading and moves down the centre line over the rows (`position: sticky` in a centred column that spans the list, `pointer-events: none`), where it covers only the empty middle of each row and marks how far the reader has come. The row for the next event takes the accent colour across its whole band, with its name and date in the accent's ink. About ten rows fit on one screen; the list ends 120px under the last row.",
|
||||
"mobile": "Rows stack at full width on 20px margins: the name at 22px on up to two lines, the note at 16px, the date under the note. The object shrinks to 40% of the width and stays in the heading; it does not travel, because on a phone it would cover the rows. The accent band stays on the next event.",
|
||||
"seenIn": [
|
||||
"aww-nick-ho"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gallery-rising-triptych",
|
||||
"role": "gallery",
|
||||
"name": "Rising triptych",
|
||||
"shape": "pinned",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"quiet"
|
||||
],
|
||||
"directions": [
|
||||
"place-carries-it",
|
||||
"uneven-photographs",
|
||||
"pinned-poster",
|
||||
"editorial-report"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A wrapper about 200svh tall holds a sticky stage of 100svh on the plain page ground. The heading, two lines at clamp(32px, 2.8vw, 44px), is centred in the top fifth of the stage with one sentence at 17px under it. From the bottom edge three photographs rise as the page scrolls, each at its own speed. The centre one, a 4:5 portrait, starts 620px wide with only its top quarter showing and grows (scale and travel on a scroll-linked timeline) until it is about 760px wide with its top 40px below the sentence, cropped by the bottom edge, so it never reaches the heading. In the study it slides over the heading; here nothing lies on top of a heading. The two outer ones, each 360×530px, stand beside it with 10px gaps, their tops always 55px lower than the centre's, and by the end they are cut by the left and right viewport edges. Square corners, no captions, one consistent light across all three (warm interiors with warm, cold coast with cold). Under reduced motion show the end state with the heading above the three photographs.",
|
||||
"mobile": "The centre portrait starts at 60% of the width and grows to nearly the full height; the outer two show as 40px slivers at the screen edges. The heading and sentence sit in the top third above it.",
|
||||
"seenIn": [
|
||||
"aww-sobha-privy-collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "showcase-portrait-on-its-own-ground",
|
||||
"role": "showcase",
|
||||
"name": "Portrait on its own ground",
|
||||
"shape": "full-bleed",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"colour-field"
|
||||
],
|
||||
"directions": [
|
||||
"colour-chapters",
|
||||
"dark-room",
|
||||
"hero-object",
|
||||
"editorial-report"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "One chapter per screen, three to six chapters. Each is a full-bleed field 100svh tall whose flat colour is taken from its own photograph: the darkest large tone in the picture (a deep brown for a wooden interior, a slate for a winter coast), so picture and page seem to be one surface. In the centre stands one portrait photograph at 3:4, 506×675px (75% of the height), top 112px, square corners, no frame. Type sits in the corners on 32px margins in the light ink: the chapter's title at clamp(28px, 2.4vw, 36px) top-left as its heading; one sentence at 17px, max 32ch, bottom-left; a text link to the chapter's page bottom-right. As each chapter arrives its field starts as a panel inset 20px from the viewport edges and grows to full-bleed, while the portrait scales from 0.92 to 1 (background colour over 600ms, scale over 800ms, both easing out), so the change reads as the room changing, not as a new slide. A chapter may hold a short muted loop or a single enlarged letterform in place of the portrait, on the same kind of field. The first chapter's title doubles as the section heading.",
|
||||
"mobile": "The portrait becomes 80% of the width at 3:4, centred at 45% of the height. The title sits top-left at 24px, and the sentence and link stack bottom-left at 16px under the portrait. The field still takes the picture's darkest tone.",
|
||||
"seenIn": [
|
||||
"aww-squarespace-foundations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cta-question-at-both-edges",
|
||||
"role": "cta",
|
||||
"name": "Question at both edges",
|
||||
"shape": "statement",
|
||||
"tags": [
|
||||
"type-led",
|
||||
"poster"
|
||||
],
|
||||
"directions": [
|
||||
"type-only-statement",
|
||||
"sentence-as-layout",
|
||||
"dark-room",
|
||||
"loud-flat-poster"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "For a closing choice between two answers: book or ask, this date or that, the tasting or the tour. A flat field exactly 100svh tall in the darkest tone. The question is a two-line heading at clamp(110px, 11vw, 168px), leading 1.0, centred, about 54% of the width: line one with its cap line 32px under the field's top edge, line two with its baseline 24px above the bottom edge. The band of about 560px between them is empty except for one row at its optical centre: two solid rectangular buttons, each about 200×56px with its label at 18px in medium weight, their inner edges 120px either side of the centre axis, and between them, exactly on the axis, the word 'or' at 18px in the soft ink. 16px under the row, centred, one line at 16px in the soft ink says what happens after either choice. The type above and below closes the empty band, so it reads as held breath rather than a gap.",
|
||||
"mobile": "The question drops to 18vw, still two centred lines, line one 32px from the top and line two 24px above the bottom. The buttons stack at the centre at 72% of the width, 12px apart, with 'or' between them and the line of explanation under the second.",
|
||||
"seenIn": []
|
||||
},
|
||||
{
|
||||
"id": "intro-headline-crossing-onto-a-photograph",
|
||||
"role": "intro",
|
||||
"name": "Headline crossing onto a photograph",
|
||||
"shape": "split",
|
||||
"tags": [
|
||||
"type-led",
|
||||
"photo-led"
|
||||
],
|
||||
"directions": [
|
||||
"sentence-as-layout",
|
||||
"editorial-report",
|
||||
"place-carries-it",
|
||||
"uneven-photographs"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A white or pale ground. A photograph taller than it is wide, 720px wide (columns 7–12), sits flush against the right viewport edge with its top at 46% of the section's first screen and runs below the fold. The heading starts on the 32px left margin across columns 1–8: two lines of light-weight serif capitals at clamp(64px, 5.6vw, 88px), leading 1.04, the first line starting 34% down the screen and the second placed so that its right half runs onto the photograph's upper-left corner. Choose a picture whose upper-left quarter is pale enough for the ink, or set the second line twice, in the ink and in the paper colour, each copy clipped to its side of the photograph's left edge. Under the heading on columns 1–5, one paragraph of up to 25 words at 22px, leading 1.4. At the bottom-left a second, smaller photograph of 360×400px enters, cropped by the screen's lower edge, and on the next screen becomes the left half of a staggered pair with the large one. Nothing else is on the screen.",
|
||||
"mobile": "The heading sits at 10vw on two lines on the 20px margin. The photograph becomes 75% of the width, flush right, and starts under the heading's first line so the second line crosses its top-left corner. The paragraph follows at 17px, then the small photograph at 50% of the width on the left margin.",
|
||||
"seenIn": [
|
||||
"aww-sobha-privy-collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "features-four-cards-dealt-into-a-fan",
|
||||
"role": "features",
|
||||
"name": "Four cards dealt into a fan",
|
||||
"shape": "pinned",
|
||||
"tags": [
|
||||
"colour-field",
|
||||
"poster"
|
||||
],
|
||||
"directions": [
|
||||
"pinned-poster",
|
||||
"loud-flat-poster",
|
||||
"borrowed-category",
|
||||
"colour-chapters",
|
||||
"specimen-catalogue"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "Four values, rules or features, each on a card dealt onto the table as the page scrolls. A wrapper about 400svh tall holds a sticky stage of 100svh on the page's darkest flat ground. Each card is a portrait panel of 340×400px with a 20px radius, filled with its own palette colour (four colours that differ clearly in lightness), no border, no shadow. On a card, with 28px padding: its name at 32px in heavy capitals on one or two lines at the top, a photograph or a single picture of the thing at 60% of the card's width in the middle, and one sentence at 16px at the foot. Card one lands alone at the centre; each further quarter of the wrapper deals the next from below the bottom edge, landing 250px to the right of the one before, rotated between -4 and 4 degrees and overlapping only its right 90px, until the four form a fan about 1,090px wide centred on the stage. Keep each card's name, picture and sentence inside its left 240px, so no card ever covers another's words. In the study the cards overlap by two thirds and bury the earlier titles; here nothing lies on top of text. The heading, two to four words at clamp(40px, 4vw, 60px), sits top-left on the stage on the 32px margin with one sentence at 17px under it. The stage releases after the fourth card has settled; under reduced motion the fan is simply there.",
|
||||
"mobile": "No pin. The cards sit in one row that scrolls sideways with scroll-snap, each 82% of the width with the next peeking in at the right edge, all upright. The heading and sentence sit above the row.",
|
||||
"seenIn": [
|
||||
"aww-made-with-gsap-1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "work-small-index-large-picture",
|
||||
"role": "work",
|
||||
"name": "Small index, large picture",
|
||||
"shape": "pinned",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"type-led"
|
||||
],
|
||||
"directions": [
|
||||
"split-stage",
|
||||
"pinned-poster",
|
||||
"editorial-report",
|
||||
"product-performs"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A sticky stage of 100svh split 38/62. The right 62%, from x 560 to the right viewport edge, is one photograph the full height of the stage, bleeding off the right, top and bottom edges, square corners: a staged picture of the current project in use, in a real place. The left column, from the 20px margin to x 520, holds the heading at the top, two lines at clamp(80px, 8.2vw, 120px), leading 0.95, 40px under the navigation; then empty ground; then, anchored to the bottom with a 32px margin, the index: three to six rows 44px tall, each with the project's name at 28px on the left and two or three disciplines at 16px right-aligned in the soft ink. The current row is in the full ink and the others at 40% opacity; rows are told apart by space only. Each project gets one viewport of scroll: as it arrives, its photograph wipes in from the bottom edge (`clip-path: inset(100% 0 0 0)` to `inset(0)` over 900ms) and the current row moves down the list. The empty middle of the left column is closed by the heading above it and the index below.",
|
||||
"mobile": "The heading sits at the top at 14vw on two lines, then the photograph at full width at 4:5, sticky under the heading while the index rows below it change the picture; or let each project simply stack as photograph and row.",
|
||||
"seenIn": [
|
||||
"aww-neutomni"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-cards-fold-into-spines",
|
||||
"role": "list",
|
||||
"name": "Cards that fold into spines",
|
||||
"shape": "pinned",
|
||||
"tags": [
|
||||
"type-led",
|
||||
"colour-field",
|
||||
"dense"
|
||||
],
|
||||
"directions": [
|
||||
"editorial-report",
|
||||
"colour-chapters",
|
||||
"page-is-the-object",
|
||||
"dense-tiles"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "For a journal, a run of notes or a list of chapters without pictures. A wrapper about 400svh tall holds a sticky stage of 100svh. The heading, two to four words at clamp(48px, 4.2vw, 64px), sits top-left on the 32px margin, 120px from the top. Under it a band 360px tall spans the content width and holds a horizontal deck of text cards, each about 620px wide, in three neighbouring flat tones of one hue taken in turn. On a card, with 32px padding: its title at 30px on up to two lines, leading 1.1; an excerpt of two lines at 16px; and along the bottom edge the date on the left and a text link to read it on the right, both at 16px. As the page scrolls, each step slides the next card in from the right edge and squeezes the earlier ones into slivers 120px wide stacked edge to edge at the left, like book spines, where only the first letters of their titles show, clipped by the sliver. Clicking a sliver brings its card back. 48px under the band, the number of entries and a link to the full list sit at 17px on the left margin, so the stage does not end on an empty floor.",
|
||||
"mobile": "No pin and no slivers. The cards stack at full width with 12px gaps, each in its tone, the title at 24px, the excerpt at 16px, the date and link at the foot. The heading sits above them at 10vw.",
|
||||
"seenIn": [
|
||||
"aww-noho"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gallery-photographs-around-a-heading",
|
||||
"role": "gallery",
|
||||
"name": "Photographs gathered round a heading",
|
||||
"shape": "mosaic",
|
||||
"tags": [
|
||||
"photo-led",
|
||||
"quiet"
|
||||
],
|
||||
"directions": [
|
||||
"uneven-photographs",
|
||||
"place-carries-it",
|
||||
"quiet-instrument",
|
||||
"dark-room"
|
||||
],
|
||||
"needs": [],
|
||||
"blueprint": "A band about one screen tall on the plain page ground, used as the page's one deliberate rest. At its visual centre sits the heading, two lines at clamp(32px, 2.6vw, 40px) in a light weight, centred, with one sentence at 17px under it and a text link beneath that. Around it six to eight small photographs are scattered irregularly across the middle columns (x 320–1120; the outer 320px on each side stay empty, equally), each 160–200px wide at about 9:10, square corners, at different heights with 60–200px of air between them. Two of them come within 40px of the heading's block, which their drift never reaches, so the cluster reads as gathered round it rather than laid on a grid. No captions and no frames. Each photograph drifts at its own rate as the band passes (4–12% of its own height on a scroll-linked timeline), so the cluster loosens and tightens; under reduced motion it stays put. The pictures come from one place or one day and share one treatment.",
|
||||
"mobile": "Five photographs at 30% of the width sit offset left and right around the centred heading, two above it and three below, none cut by an edge. The heading drops to 7vw on two lines.",
|
||||
"seenIn": [
|
||||
"aww-tengile-malamala-collection",
|
||||
"aww-moto-finance"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -18,8 +18,10 @@ the code, so you will read your intentions into it. The pictures only show what
|
||||
Open `desktop-fold.jpg`, then the `desktop-page-*.jpg` sheets, then `mobile-fold.jpg` and the mobile
|
||||
sheets. Make each pass on its own; do not blend them.
|
||||
|
||||
1. **One second.** Glance and look away. What did you see? There should be exactly one answer: a
|
||||
word, an image, an object. If the answer is "a website", there is no focal point.
|
||||
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.
|
||||
@@ -29,9 +31,13 @@ sheets. Make each pass on its own; do not blend them.
|
||||
the pricing, the FAQ and the footer, or does the page turn into stock blocks after the first screen?
|
||||
4. **Edges.** Follow the left edge of every text block down the page. Count the distinct x positions.
|
||||
A composed page has two or three, reused on purpose. Six means things were placed one at a time.
|
||||
5. **Space.** Are related things close and unrelated things far? Is the gap between sections clearly
|
||||
larger than any gap inside a section? Is any region empty by accident (a hole) rather than by
|
||||
decision (a rest)? A hole usually sits beside a short column or under a removed decoration.
|
||||
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?
|
||||
@@ -122,6 +128,11 @@ These override the arithmetic.
|
||||
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**.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ how to crop and place them, and what to do when there are none.
|
||||
9. Type and colour alone
|
||||
10. Markup and credits
|
||||
11. Video
|
||||
12. Where pictures and footage go
|
||||
|
||||
## 1. What the study measured
|
||||
|
||||
@@ -32,6 +33,11 @@ how to crop and place them, and what to do when there are none.
|
||||
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
|
||||
|
||||
@@ -198,6 +204,7 @@ real footage with `--video` (section 11).
|
||||
- **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
|
||||
|
||||
@@ -339,12 +346,17 @@ shorter clip. Licences, credits and honesty work exactly as for pictures (sectio
|
||||
|
||||
- **Short:** 5 to 20 seconds that loop without a visible jump: water, smoke, a turning drum, traffic,
|
||||
leaves, a crowd from far away. Watch it to the end before choosing.
|
||||
- **Light:** under about 5 MB for a section and 10 MB for a hero. 720p is plenty behind type.
|
||||
- **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.
|
||||
- **One per page, two at most.** A page of moving tiles is noise.
|
||||
- **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.**
|
||||
|
||||
@@ -356,22 +368,424 @@ shorter clip. Licences, credits and honesty work exactly as for pictures (sectio
|
||||
- The poster shows before the clip plays, on slow connections and under reduced motion, so it must
|
||||
work as a picture on its own. If the source's still is a slate or a blur, take a frame from the clip
|
||||
yourself or use a found picture.
|
||||
- Under `prefers-reduced-motion: reduce`, stop it and show the poster:
|
||||
- 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)');
|
||||
for (const v of document.querySelectorAll('video[autoplay]')) {
|
||||
const sync = () => { if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); } else { v.play().catch(() => {}); } };
|
||||
sync(); calm.addEventListener('change', sync);
|
||||
}
|
||||
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. A headline over a clip gets the same neutral scrim as text on a photograph,
|
||||
and nothing lies on top of a headline or a statement.
|
||||
- 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.
|
||||
@@ -11,11 +11,12 @@ below are hand tallies from those notes, so read them as "about").
|
||||
2. Margins and the grid
|
||||
3. Where text sits
|
||||
4. Empty space
|
||||
5. Sections: carry the idea, vary the arrangement
|
||||
6. Navigation
|
||||
7. Endings
|
||||
8. The phone is a second composition
|
||||
9. How pages fail
|
||||
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
|
||||
|
||||
@@ -77,19 +78,132 @@ below are hand tallies from those notes, so read them as "about").
|
||||
|
||||
## 4. Empty space
|
||||
|
||||
Empty space is placed, not left over: an empty left half, a blank right third, 300px of nothing above
|
||||
the work.
|
||||
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").
|
||||
|
||||
- Decide where the void is and keep it clean: no stray caption, no decorative shape, no faint
|
||||
watermark.
|
||||
- The gap between sections is clearly larger than any gap inside one (120–220px on desktop,
|
||||
72–112px on a phone).
|
||||
- Related things sit close; unrelated things sit far. If a heading is equidistant from the paragraph
|
||||
above and the one below, fix it: at least twice as much space above as below.
|
||||
**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. Sections: carry the idea, vary the arrangement
|
||||
## 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
|
||||
@@ -110,7 +224,7 @@ points this way, including several above 7.5.
|
||||
- **Short pages are fine.** A quarter of the studied sites are one or two screens. Do not add sections
|
||||
to seem complete; each one must carry a claim (see `copy.md` §1).
|
||||
|
||||
## 6. Navigation
|
||||
## 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.
|
||||
@@ -127,7 +241,7 @@ points this way, including several above 7.5.
|
||||
the phone so the headline, the supporting sentence and the action stay in the first screen. In
|
||||
blind tests a nav that ate the hero cost a whole point of usability.
|
||||
|
||||
## 7. Endings
|
||||
## 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
|
||||
@@ -138,7 +252,7 @@ points this way, including several above 7.5.
|
||||
- Put the practical facts (address, hours, email, legal line) in the text face at a readable size, on
|
||||
the same anchor lines as the rest of the page.
|
||||
|
||||
## 8. The phone is a second composition
|
||||
## 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.
|
||||
@@ -153,7 +267,7 @@ lose usability points; weak ones are often just broken.
|
||||
- Check 390px in the inspector's screenshots for every round: sideways scroll and three-line headlines
|
||||
usually appear here first.
|
||||
|
||||
## 9. How pages fail
|
||||
## 10. How pages fail
|
||||
|
||||
From the panel's notes on the 54 sites scoring under 6.8 (counts out of 54):
|
||||
|
||||
|
||||
@@ -6,17 +6,26 @@ Read this before adding any animation. The static page must already be finished
|
||||
## Contents
|
||||
|
||||
1. The budget
|
||||
2. Four questions before any animation
|
||||
3. Tokens
|
||||
4. Recipes (copy, then adapt)
|
||||
5. Signature moves
|
||||
6. Reduced motion and performance
|
||||
7. What gives generated motion away
|
||||
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 four parts:
|
||||
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
|
||||
@@ -37,7 +46,46 @@ cited motion tell of generated pages; motion that hides content until a script r
|
||||
bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
|
||||
anything; choreographed motion with one protected moment says somebody did.
|
||||
|
||||
## 2. Four questions before any animation
|
||||
## 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
|
||||
@@ -52,24 +100,85 @@ anything; choreographed motion with one protected moment says somebody did.
|
||||
4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
|
||||
drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).
|
||||
|
||||
## 3. Tokens
|
||||
## 4. Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* enters, exits, reveals */
|
||||
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* things that travel while visible */
|
||||
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* long quiet reveals, image scale */
|
||||
--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 */
|
||||
}
|
||||
```
|
||||
|
||||
Name the properties you animate. `transition: all` animates things you did not intend (and costs
|
||||
layout); write `transition: transform var(--t-press) var(--ease-out)`.
|
||||
How the study bears on each:
|
||||
|
||||
## 4. Recipes
|
||||
- `--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.
|
||||
@@ -78,28 +187,45 @@ All recipes keep content visible when JavaScript is off or slow: the hidden stat
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
```
|
||||
|
||||
### Hero load sequence (CSS only)
|
||||
### Load sequence in 1.2 seconds
|
||||
|
||||
Stagger at most four things. With a headline of two lines this is short and reads as one gesture.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.js [data-rise] {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
animation: rise var(--t-reveal) var(--ease-out) forwards;
|
||||
animation-delay: calc(var(--i, 0) * 90ms + 120ms);
|
||||
}
|
||||
}
|
||||
@keyframes rise { to { opacity: 1; transform: none; } }
|
||||
```
|
||||
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
|
||||
<h1 data-rise style="--i:0">…</h1>
|
||||
<p data-rise style="--i:1">…</p>
|
||||
<a data-rise style="--i:2" class="button">…</a>
|
||||
<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
|
||||
@@ -209,20 +335,486 @@ A section whose heading stays put while items pass. Use `position: sticky`; do n
|
||||
@media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
|
||||
```
|
||||
|
||||
### List with image preview (work index, menu, team)
|
||||
### Index with preview, anchored to the row
|
||||
|
||||
Rows of large text; hovering a row shows its image in a fixed frame beside or behind the list.
|
||||
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__row { display: flex; justify-content: space-between; align-items: baseline; padding-block: 0.35em; font-size: var(--step-4); }
|
||||
.index__frame { position: absolute; inset-block: 0; right: 0; width: 34%; pointer-events: none; }
|
||||
.index__frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity var(--t-ui) ease; }
|
||||
.index__frame img.is-on { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .index:hover .index__row:not(:hover) { opacity: 0.35; } .index__row { transition: opacity var(--t-hover) ease; } }
|
||||
.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; } }
|
||||
```
|
||||
|
||||
On touch there is no hover, so each row shows its image inline above the title instead.
|
||||
```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
|
||||
|
||||
@@ -245,7 +837,7 @@ it in JavaScript.
|
||||
Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
|
||||
`scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.
|
||||
|
||||
## 5. Signature moves
|
||||
## 7. Signature moves
|
||||
|
||||
The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.
|
||||
|
||||
@@ -255,18 +847,31 @@ The direction sheet names one. Each of these was seen repeatedly on the studied
|
||||
| Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
|
||||
| Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
|
||||
| Pinned story | A heading holds while its items scroll past | Sticky recipe |
|
||||
| Index with preview | Text rows; the hovered row shows its image | List with image preview recipe |
|
||||
| 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.
|
||||
|
||||
## 6. Reduced motion and performance
|
||||
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
|
||||
@@ -278,10 +883,11 @@ decoration and the page is better without it.
|
||||
element that moves.
|
||||
- `will-change` only on an element that is about to animate, removed afterwards.
|
||||
- Pause loops when off screen or when the tab is hidden.
|
||||
- No scroll hijacking, no smooth-scroll library by default, no custom cursor. They cost usability,
|
||||
which is 30% of an Awwwards score and the line most winners score lowest on.
|
||||
- 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.
|
||||
|
||||
## 7. What gives generated motion away
|
||||
## 9. What gives generated motion away
|
||||
|
||||
- The same fade-up on every block.
|
||||
- A dozen staggered entrances in the hero.
|
||||
@@ -290,3 +896,85 @@ decoration and the page is better without it.
|
||||
- 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.
|
||||
@@ -16,6 +16,8 @@ others.
|
||||
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
|
||||
|
||||
@@ -304,3 +306,51 @@ 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.)
|
||||
@@ -104,7 +104,7 @@ effortless, cutting-edge, all-in-one …); the correction ("not just X, it's Y")
|
||||
## 7. Motion tells
|
||||
|
||||
Identical fade-up on every block; a dozen staggered hero entrances; hover lift on every card; bounce
|
||||
easing; infinite loops that mean nothing; reveals that fail and leave a blank band. See `motion.md` §7.
|
||||
easing; infinite loops that mean nothing; reveals that fail and leave a blank band. See `motion.md` §9.
|
||||
|
||||
## 8. Why removal is not enough
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
// [--options 3] show three dealt directions to choose from, write nothing
|
||||
// [--direction <id>] deal the full sheet for the chosen direction
|
||||
// node draw.mjs --show reprint the saved direction sheet
|
||||
// node draw.mjs --redraw hero|type|palette|photos|section:<role>|direction re-deal one slot
|
||||
// node draw.mjs --list sectors|directions|roles|tones
|
||||
// node draw.mjs --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';
|
||||
@@ -65,11 +66,16 @@ const DEFAULT_SECTIONS = {
|
||||
// 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);
|
||||
}
|
||||
@@ -113,7 +119,7 @@ if (opt('show')) {
|
||||
console.log(render(saved)); process.exit(0);
|
||||
}
|
||||
|
||||
const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions') };
|
||||
const 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);
|
||||
|
||||
@@ -149,6 +155,7 @@ if (opt('redraw')) {
|
||||
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('=');
|
||||
@@ -162,7 +169,7 @@ if (opt('redraw')) {
|
||||
if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives <role>.`); process.exit(2); }
|
||||
sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` };
|
||||
} else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i];
|
||||
} else { console.error('Unknown slot. Use hero, type, palette, photos, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
|
||||
} else { 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)) {
|
||||
@@ -198,7 +205,7 @@ if (opt('redraw')) {
|
||||
|
||||
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 });
|
||||
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 ----------
|
||||
@@ -252,11 +259,41 @@ function deal(input, redraws) {
|
||||
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) {
|
||||
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id));
|
||||
if (!pool.length) pool = L.heroes.filter((h) => (h.directions || []).includes(dir.id));
|
||||
// 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)));
|
||||
@@ -395,9 +432,11 @@ function dealSection(dir, role, usedIds, prevShape, nextShape, input) {
|
||||
}
|
||||
function dealReferences(dir, input) {
|
||||
const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean);
|
||||
const sameKind = L.sites.filter((s) => s.kind === input.sector && s.score >= 7.2 && !named.includes(s));
|
||||
// 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) => s.score >= 7.6)).slice(0, 2);
|
||||
return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2);
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
@@ -405,6 +444,8 @@ 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}`);
|
||||
@@ -471,7 +512,7 @@ function render(s) {
|
||||
? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)`
|
||||
: `\`${ASSETS_CMD} "<a place, material or object from your inventory>"\``;
|
||||
const MODE = {
|
||||
interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
|
||||
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.',
|
||||
@@ -507,17 +548,35 @@ function render(s) {
|
||||
});
|
||||
p();
|
||||
if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); }
|
||||
if (d.richness) { p('## 9. Where the richness comes from'); p(d.richness); p(); }
|
||||
if (d.pitfalls) { p('## 10. How this direction goes wrong'); p(d.pitfalls); p(); }
|
||||
if (s.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('## 11. Real references');
|
||||
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. `inspect.mjs` measures all of it.');
|
||||
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\``);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
|
||||
//
|
||||
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--json]
|
||||
// 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+.
|
||||
@@ -9,8 +16,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createRea
|
||||
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { Browser, sleep } from './lib/cdp.mjs';
|
||||
import { probe, frame } from './lib/probe.mjs';
|
||||
import { judge } from './lib/rules.mjs';
|
||||
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; };
|
||||
@@ -19,14 +26,17 @@ 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] [--json]');
|
||||
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 { allow = [...new Set([...allow, ...(JSON.parse(readFileSync(p, 'utf8')).allow || [])])]; } catch {} break; }
|
||||
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
|
||||
@@ -55,7 +65,7 @@ const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
|
||||
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
|
||||
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: [] };
|
||||
@@ -76,6 +86,22 @@ const MOTION_RECORDER = `(() => {
|
||||
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 });
|
||||
@@ -94,8 +120,10 @@ try {
|
||||
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(1400); // let the load choreography finish before judging the first screen
|
||||
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);
|
||||
@@ -118,6 +146,44 @@ try {
|
||||
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;
|
||||
@@ -129,7 +195,20 @@ try {
|
||||
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
|
||||
sheets.push(p);
|
||||
}
|
||||
const findings = judge(m, { width: w, allow });
|
||||
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.' });
|
||||
@@ -150,11 +229,30 @@ try {
|
||||
else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
|
||||
}
|
||||
if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
|
||||
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, consoleErrors: page.consoleErrors.slice(0, 5) });
|
||||
report.findings.push(...findings);
|
||||
report.images.push({ viewport: label, width: w, fold, pages: sheets });
|
||||
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();
|
||||
@@ -198,7 +296,7 @@ for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN
|
||||
}
|
||||
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 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.`);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Zero-dependency Chrome DevTools Protocol client.
|
||||
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
|
||||
// Requires Node 22+. No npm install.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readdirSync, statSync, openSync, closeSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -37,6 +37,24 @@ export function findChrome() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Profiles from runs that were killed before close(): removed once no Chrome holds them (a running
|
||||
// Chrome keeps the profile's lockfile open) and they are a few minutes old.
|
||||
function sweepProfiles(base) {
|
||||
try {
|
||||
const cutoff = Date.now() - 3 * 60 * 1000;
|
||||
for (const name of readdirSync(base)) {
|
||||
if (!name.startsWith('ad-chrome-')) continue;
|
||||
const p = join(base, name);
|
||||
try {
|
||||
if (statSync(p).mtimeMs > cutoff) continue;
|
||||
const lock = join(p, 'lockfile');
|
||||
if (existsSync(lock)) closeSync(openSync(lock, 'r+')); // throws while a Chrome holds it
|
||||
rmSync(p, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export class Browser {
|
||||
static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
@@ -46,9 +64,22 @@ export class Browser {
|
||||
if (!exe) {
|
||||
throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ad-chrome-'));
|
||||
// A throwaway profile per run. Its cache is capped (each run starts cold anyway), profiles left by
|
||||
// runs that were killed are swept once they are a few hours old, and the profile goes on exit.
|
||||
const base = process.env.TASTE_SKILL_TMP || tmpdir();
|
||||
sweepProfiles(base);
|
||||
const dir = mkdtempSync(join(base, 'ad-chrome-'));
|
||||
// On exit Chrome still holds the profile's files: stop it first, then remove the folder.
|
||||
let pid = 0;
|
||||
const drop = () => {
|
||||
try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {}
|
||||
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
|
||||
};
|
||||
process.once('exit', drop);
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); });
|
||||
const args = [
|
||||
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
|
||||
'--disk-cache-size=16777216', '--media-cache-size=16777216',
|
||||
'--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter',
|
||||
@@ -56,6 +87,7 @@ export class Browser {
|
||||
'--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank',
|
||||
];
|
||||
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
pid = proc.pid;
|
||||
const wsUrl = await new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
|
||||
|
||||
@@ -437,7 +437,8 @@ export function probe() {
|
||||
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) },
|
||||
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 },
|
||||
@@ -498,7 +499,8 @@ export function frame() {
|
||||
if (srOnly) continue;
|
||||
// the boxes that clip this text (overflow other than visible, or a clip-path)
|
||||
const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); }
|
||||
const pinned = size >= 32 && pinnedLow(el);
|
||||
// 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;
|
||||
@@ -547,10 +549,348 @@ export function frame() {
|
||||
}
|
||||
}
|
||||
// 3. the large text in view, to spot one statement held on screen for screen after screen
|
||||
const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
|
||||
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();
|
||||
|
||||
@@ -114,6 +114,7 @@ export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
|
||||
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).');
|
||||
@@ -134,4 +135,109 @@ export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
|
||||
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'];
|
||||
// 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'];
|
||||
@@ -2,7 +2,7 @@
|
||||
// tasteskill / lint — static scan of source files for the tells this skill bans.
|
||||
// Catches them before a render, plus the ones a render cannot see (copy habits, motion values).
|
||||
//
|
||||
// node lint.mjs [files or directories ...] [--json]
|
||||
// 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';
|
||||
@@ -10,6 +10,11 @@ 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']);
|
||||
@@ -19,7 +24,7 @@ const files = []; const tooBig = [];
|
||||
const walk = (p) => {
|
||||
let st; try { st = statSync(p); } catch { return; }
|
||||
if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); }
|
||||
else if (EXT.has(extname(p)) && !/\.min\.|lock|\.d\.ts$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
|
||||
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); }
|
||||
@@ -54,6 +59,11 @@ const R = [
|
||||
['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;
|
||||
@@ -73,6 +83,7 @@ for (const file of files) {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||