18 Commits
Author SHA1 Message Date
Leon Lin f6b76575f9 Merge pull request #196 from Leonxlnx/v2.5.1
tasteskill 2.5.1: finish gate, motion on every page, copy layer, photos over renders, trade palettes, phone checks
2026-11-07 13:33:21 +01:00
LeonxlnxandClaude Opus 5.5 b900a8075f tasteskill 2.5.1: finish gate, motion on every page, copy layer, photos over renders, trade palettes, accessibility and mobile checks
What a 44-site test of 2.5.0 showed, fixed:
- scripts/gate.mjs: a finish gate (checker, layout check, copy-lint; three
  rounds, five waivers) that runs as a Stop hook from the frontmatter
- every hero enters once on load; every page deals one or two moments
  (photo- and type-led off tech, new photo-chapters, a film look for
  picture-develops); shaders stay on tech
- photos.mjs search: licensed stand-in photographs instead of renders
- palettes by trade (start.mjs deal --trade), eight new place palettes
- copy layer (references/copy-*.md, scripts/copy-lint.mjs)
- accessibility, forms and metadata checks; phone checks (collide,
  tap-size, 320) and phone layout fixes
- the kit's page models pass the checker and check.mjs at 1440 and 390
  (tests/kit-clean.mjs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:32:28 +01:00
Leon LinandClaude Opus 5.5 9b4bf937a1 font-load: warn for each web font that never loads (#186)
Ported to 2.5: a new consistency.mjs check WARNs for each web font family the page's type tokens, h1, h2s or body text ask for that never loaded, as this PR proposed for the 2.1 font check (any missing family, not only all of them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:12:12 +01:00
LeonxlnxandClaude Opus 5.5 f578e23218 Merge main (#181, #182) into portfolio/alan/e596f504b4a2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:09:03 +01:00
Leon LinandClaude Opus 5.5 151afddbe3 --root for the scripts that serve a page file (#182)
Ported to 2.5: consistency.mjs, assets/layout/check.mjs, shoot.mjs and perf.mjs take --root <dir>, serve from that folder and refuse a page outside it; SKILL.md step 6 says when to pass it. Fixes #179.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:08:22 +01:00
LeonxlnxandClaude Opus 5.5 7a373bf2ed Merge main (#181) into fix/grok-build/inspect-root
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:07:12 +01:00
Leon LinandClaude Opus 5.5 8fcde309b3 Each checked page gets its own report folder (#181)
Ported to 2.5's scripts/consistency.mjs: index.html keeps .taste-skill/consistency, any other page writes to .taste-skill/consistency-<name>. Fixes #174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:05:29 +01:00
LeonxlnxandClaude Opus 5.5 386d187d86 Port to 2.5: font-load warns for each web font that never loads
2.5 has no font check (inspect.mjs and its font-not-dealt are gone). The new
consistency.mjs check reads the families the page's type tokens (--font-display,
--font-text, --font-mono), its h1, h2s and body text ask for, and WARNs for
each web font family that did not load, naming the family and where it is
used: any missing family, not only when all are missing, as this PR proposed.
Generic families, system stacks and installed faces pass. First width only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:00:33 +01:00
LeonxlnxandClaude Opus 5.5 4c492ecf6d Port to 2.5: --root for the scripts that serve a page file
2.5 replaced inspect.mjs; consistency.mjs, assets/layout/check.mjs, shoot.mjs
and perf.mjs serve a local page from its own folder, so a built page in a
subfolder with root-relative links (dist/about/index.html asking for
/_astro/page.css) was checked unstyled (#179). --root <dir> serves from that
folder and refuses a page outside it; without it nothing changes. SKILL.md
step 6 says when to pass it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:00:32 +01:00
LeonxlnxandClaude Opus 5.5 3324132353 Port to 2.5: each checked page gets its own report folder
2.5 replaced inspect.mjs with scripts/consistency.mjs, which wrote a local
page's crops and report.json to <page folder>/.taste-skill/consistency and
cleared the old crops first, so two pages in one folder overwrote each other
(#174). index.html keeps .taste-skill/consistency; any other page now writes
to .taste-skill/consistency-<name>. --out still overrides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:00:31 +01:00
LeonxlnxandClaude Opus 5.5 b152b5b8bd Merge main (tasteskill 2.5) into portfolio/alan/e596f504b4a2
2.5 replaced inspect.mjs and scripts/lib/, so the tree takes main as it is;
the fix is ported to the 2.5 scripts in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:33:07 +01:00
LeonxlnxandClaude Opus 5.5 ac4fffa237 Merge main (tasteskill 2.5) into fix/grok-build/inspect-root
2.5 replaced inspect.mjs and scripts/lib/, so the tree takes main as it is;
the fix is ported to the 2.5 scripts in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:33:05 +01:00
LeonxlnxandClaude Opus 5.5 f094ecf43e Merge main (tasteskill 2.5) into fix/inspect-per-page-out-dir
2.5 replaced inspect.mjs and scripts/lib/, so the tree takes main as it is;
the fix is ported to the 2.5 scripts in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:33:02 +01:00
Alan Vo e596f504b4 Fix font-not-dealt check to warn when any dealt font is missing 2026-11-02 08:58:55 +00:00
Bougie be1fd9bc97 fix(inspect): widen the page identity digest to 128 bits
The digest that tells two pages apart was 32 bits, and two URLs whose
queries differ only in the case of their letters can share the first four
bytes of a SHA-256 digest:

  http://localhost:3000/?variant=ABCDefGHIjklmNOpqr
  http://localhost:3000/?variant=ABcdefghIJkLMnOPqr

Both landed in `inspect-localhost-3000-variant-abcdefghijklmnopqr-9085d4d3`,
so the second run cleared and replaced the first one's screenshots. The
digest is 128 bits now, and a test keeps that pair apart.
2026-10-31 15:15:49 +08:00
Bougie 0280476a85 fix(inspect): keep each page's inspection folder unambiguous and short
The folder name was built from a lossy slug, so pages could still land in
the same folder: a URL's query and fragment were dropped, `a/b.html` and
`a-b.html` both read as `a-b`, case and non-ASCII names collapsed, and a
deep path could exceed the 255-byte file name limit.

The name now ends in a short digest of the page's identity (the whole URL,
query and fragment included, or the file's absolute path) and the readable
part is capped, so two different pages can never share a folder and the
folder always fits.
2026-10-31 15:08:25 +08:00
hummbl-dev 3a5767e80f fix(inspect): serve a production build from --root
inspect.mjs served a local file with the working directory as the web
root, so a production build's /style.css missed dist/. --root sets that
root. With no flag, a file inside the working directory is still served
from that directory.

node --test tests/preview-root.mjs tests/inspection-files.mjs: 7 pass, 0 fail, duration_ms 122.402.
2026-10-29 10:29:23 -04:00
BougieandClaude Opus 5.5 f810636b7b fix(inspect): give each inspected page its own screenshot folder
inspect.mjs wrote every run to .taste-skill/inspect and cleared the
screenshots of the run before it, so two inspections of different pages
from the same folder replaced each other's images, and the report of the
first run ended up pointing at the render of the second (#174).

- inspect.mjs: the default output folder is derived from the target
  (pages/about.html and pages/about/index.html both land in
  .taste-skill/inspect-pages-about, a URL in a slug of host and path),
  so concurrent runs cannot overwrite each other. --out still overrides it.
- scripts/lib/inspection-files.mjs: defaultOutDir() derives that folder and
  keeps it under .taste-skill, so the direction.json beside it is still found.
- SKILL.md: the Gate names the new folder.
- tests/inspection-files.mjs: regression tests for the folder names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:11:28 +08:00
88 changed files with 7523 additions and 1075 deletions

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "taste-skill",
"description": "tasteskill 2.5.0: professional company websites at the figma.com and x.ai level, checked in a real browser.",
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, checked in a real browser.",
"owner": {
"name": "leonxlnx",
"email": "hello@tasteskill.dev"
@@ -8,8 +8,8 @@
"plugins": [
{
"name": "taste-skill",
"description": "tasteskill 2.5.0: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser consistency checker.",
"version": "2.5.0",
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser checker and finish gate.",
"version": "2.5.1",
"source": "./",
"skills": ["./skills/taste-skill"],
"author": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "taste-skill",
"version": "2.5.0",
"description": "tasteskill 2.5.0: professional company websites at the figma.com and x.ai level, checked in a real browser.",
"version": "2.5.1",
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, checked in a real browser.",
"author": {
"name": "leonxlnx",
"url": "https://github.com/Leonxlnx"
@@ -21,7 +21,7 @@
"interface": {
"displayName": "Taste Skill",
"shortDescription": "Professional company websites, checked in a real browser.",
"longDescription": "tasteskill 2.5.0 from Leonxlnx/taste-skill: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser consistency checker.",
"longDescription": "tasteskill 2.5.1 from Leonxlnx/taste-skill: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser checker and finish gate.",
"developerName": "leonxlnx",
"category": "Design",
"capabilities": [],
+21
View File
@@ -4,6 +4,27 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
---
## 2.5.1 - 2026-11-07
What a 44-site test of `2.5.0` showed, fixed: builds now iterate until the checker is clean, every page moves a little, products without photographs are shown in photographs, palettes fit the trade, the copy gets a voice of its own, and nothing lies on anything else on a phone. The kit's own page models now pass the checker at 1440 and 390.
### What's new
- **The finish gate (`scripts/gate.mjs`, `references/gate.md`):** A build arms the gate at its first step (`gate.mjs --start`), and `start.mjs deal` arms it too. `gate.mjs index.html` runs the checker and `assets/layout/check.mjs` at `1440` and `390`, plus static checks for an edited kit file and for page code that hunts for a headless browser, and ends on exactly one line: `GATE: PASS`, `GATE: FAIL`, `GATE: OPEN` (three rounds used up), `GATE: SETUP` (no page yet, a framework's source shell or a page that is not local: arm the right one and run again), `GATE: ERROR` (the checks could not run) or `GATE: OFF`. It holds two checker WARNs as FAILs, a hero without its entrance (`hero-enter`) and a page with no dealt moment (`moment-none`), lints the copy of the page as the browser built it when the file is an app shell, and opens only local pages. A FAIL judged false is waived in `.taste-skill/waivers.json` with its reason (five at most), and the report starts with the `GATE:` line, the waivers and every open FAIL. In Claude Code the skill's frontmatter adds the gate as a Stop hook (it looks for the gate in the skill's folder and the usual install folders, does nothing in a folder with no build, except to say once how to arm a tasteskill page changed in the last day, and says on stderr when a build there goes unchecked); Codex and Antigravity take the same command from their own hook files. The gate never traps an agent: blocks are counted and capped; a crash or a framework shell blocks once, then allows, and a crashed run is reused, not rerun on every stop; a stop before the first run by hand is no round, so a question mid-build costs none; after a block that lists FAILs the next stop on the unchanged page is allowed once, and other stops on it block twice, then count as a round. When the user ends the build, `gate.mjs --done "<what they said>"` closes it. A PASS, or the last round's OPEN, closes it too, so a later deal starts a build of its own. Where a `.taste-skill` folder exists, every hook call, an allowed one too, is recorded in `gate.json`'s history (`"mode": "hook"`, the decision and why), and a hook that cannot find the gate leaves `.taste-skill/gate-hook-missing.txt` with the time and the variables it read. Every script that opens Chrome keeps its default profile in the OS temp folder or the project's `.taste-skill`: a relative `--profile` that leads out of the project is replaced by `.taste-skill/chrome-<script>`, with a warning (an absolute one is used as given).
- **Motion on every page (`assets/motion`, `references/motion.md`, `references/moments.md`):** Every hero enters once on load (`data-enter` on the hero and one line in the head): the h1 rises line by line, the lead and buttons follow, the picture settles from 1.05, about a second in all, and without JavaScript, in print or under reduced motion the hero shows as written. Every page deals one moment, two on a long page: a cafe, shop, studio or service gets the photo- and type-led ones (`photo-chapters`, new: one photograph per chapter uncovered over the last; `picture-develops` in a new film look; `word-highlight`; `pinned-chapters` on product and service), still with zero WebGL. The short page (under 8 photographs) carries `picture-develops` on its section's photograph, or, with no photograph, `word-highlight` in a one-screen statement section. Shader effects and scenes stay tech only. `lines-rise`, `window-parallax` and `word-rotate` are retired (each repeated the hero's entrance or the word hero, or was a parallax), so no kind needs `--exclude`. The kit's own motion is calmer: one ease-out curve, no overshoot, hover moves only on a fine pointer, hover out at 120ms against 150ms in, the focus ring shown at once, the tab pill and switch moved by transform only. A link inside a moment's statement keeps its accessible name.
- **Photographs over renders (`scripts/photos.mjs search`, `references/photos.md`):** A physical product, hardware or a game without the client's pictures is shown in licensed stand-in photographs of the same kind of thing, each listed in the report. `photos.mjs search` asks Wikimedia Commons for large CC0, CC BY, CC BY-SA or public-domain JPEGs (no NC or ND licences, no paintings, maps, logos or renders), and `fetch` and `credits` take its results like library frames. A product render (`assets/objects/product-render`) is made only when the brief asks for 3D or a render; the product hero has no default hotspots. A game without art leads with its name set large over a stand-in of its setting (`deal.mjs --has setting`). Set `TASTE_SKILL_RECENT=<file>` to share recent frames and palettes across folders: frames another build used lately go behind fresh ones, so two cafes no longer start from the same photographs. Unset, nothing is written outside the project. A live search's file is fetched only from Commons' own file host.
- **Palettes by trade (`start.mjs deal --trade`):** Every palette names the trades it was made for, and `--trade cafe`, `--trade "yoga studio"` or `--trade "developer tool"` deals those first, away from the folder's last twelve built palettes (`start.mjs css` records them) and, with `TASTE_SKILL_RECENT`, the trade's last six across folders. Eight new light palettes give every place trade seven to thirteen to deal from, and a place only gets grounds that fit a place. The builder takes the first palette and writes one sentence on why it fits the trade; the place look no longer hard-codes its colours. The trade is read from the brief more carefully (an estate agent is an agency, a kitchen fitter a workshop, a delivery service no deli), and a deal in a folder under an open build joins that build instead of arming a second gate.
- **Copy layer (`references/copy-voice.md`, `copy-registers.md`, `copy-moves.md`, `copy-tells.md`, `scripts/copy-lint.mjs`):** Leon's copy-taste research and skill, rewritten for tasteskill: a fact sheet from the brief, a voice card on the plan, the page's shape from the reader's questions, one register, the moves for each part of the page and a budget for each device. `copy.md` routes to them and now allows one verb-led heading form and the page's one pun as its brand line. `copy-lint.mjs` counts the tells of model copy (old slop words, stock lines, two-fragment headings, contrast frames, virtue words, invented stat strips, default-cast names, footer sign-offs) in about 30 ms a page with no browser; a device used past twice its budget FAILs (two-fragment and number-pair headings, contrast frames, negation runs, virtue words; riders, puffery, reveals and repeated promises only WARN). It leaves UI mocks, menus, timetables and one wording repeated alone; literal trade and virtue words (a quiet room, leverage up to 1:30, a 2,000 W motor) and abbreviations (St. Ives) pass; a part with a missing end tag no longer hides the rest of the page; a `.md` draft lints like the page. The gate runs it and its FAILs block: one finding per slop word with its sentence, a tic waived only by its whole finding, and a word from the client's own copy waived hit by hit with the brief as the reason. A listed word inside the client's own name (Elevate Pilates), as the logo, the title before its ":" or `og:site_name` writes it, is not counted and WARNs once (`copy-brand-word`).
- **Accessibility, forms and metadata (`basics` in the checker, `references/system.md`):** The checker now reads accessible names, alt text, visible field labels, heading order, a Tab walk with a focus ring you can see at once and never under the header, the skip link, the menu button's state, `lang`, the title, description and share tags, one action at most twice a screen, map links that only send a street to directions, assumed hours that never drive an open-now line, and forms that only say thank you after a real send. The starter gains the head block, a skip link, `lang`, a `booking-form` template with each error under its own field, and `system.js` checks forms on submit.
- **Phones (`system.css`, `collide`, `tap-size`):** On a phone a section is at least its screens and grows with its content; it used to keep a fixed height and lay its last rows over the next section, in eight of the kit's own hero demos too. The hero grows on a short phone, and on one under 700px tall its photograph and facts give way so its button stays on the first screen. Links, the nav name, footer links, credits and small buttons are 44px targets; without script a phone's bar keeps the name and the main button; a page's own phone-only hiding of a section wins over the kit's phone rule. The phone action bar waits until the hero's button has scrolled away and steps aside while the screen shows one of its actions (without script it simply stays, covering nothing); the place hero's photograph grows into what its words leave of the first screen (up to 4:5, with the 4:5 phone crop `photos.mjs` now prints and its `data-phone-fill`), and the visit block's photograph into the rest of its screen, so neither ends in an empty band. A two-screen section's four open tiles stack into its two phone screens (180px panels, 32px between every tile, the second group clear of the header), and the centred hero keeps a three-line headline and its window's whole answer on the first screen. The checker gains `collide` (words over words, over another section's picture, button or card, a picture over a button, a fixed bar over content at the top or the end of the page), `tap-size` (a target under 32px tall or 24px wide FAILs when another target is under 8px away, else WARNs; under 44px WARNs) and a walk at 320 for collide, tap-size, the first screen's fold (WARN) and anything past the screen (`off-viewport [320]`); the gate checks 390 and 320 on every run, `--quick` too.
- **Checker fixes and `empty-area` (`scripts/consistency.mjs`, `tests/kit-clean.mjs`):** Six false positives that failed the kit's own heroes are fixed at their cause (a centred or boxed hero measured against its own frame, the product render as one box, a dealt moment's display type, drawn mocks in the stagger check), and no fix text reads as an invitation to invent a fact. New `empty-area` WARNs on a section over 35% empty inside its container and FAILs over 50%. Motion inside a dealt moment's section is accepted on every kind, the hero entrance is no reveal, and `hidden-content` also loads the page with scripts off. A hero or section with `overflow: clip` is not read as spilling, a panel with its own fill counts to its edges, the starter hero's facts at the foot of its column are no hole, and a mounted statement moment's own section is left out of `section-gaps` (and of the layout check's `sparse`). A live `photo-chapters` section may run to four whole screens, counted in viewports under any header, and any section holding `word-highlight` counts as a statement. A hero whose big scene is its entrance (`data-enter="off"`) passes `hero-enter`. `check.mjs`'s long-headline stress test adds a long compound word only on pages whose language builds them, so `hero-lines` can be fixed or waived.
- **The kit's page models pass the checker at 1440 and 390 (`tests/kit-clean.mjs`):** All 11 hero demos, the starter and two new pages that mount moments in a real page (`assets/moments/_demo/in-page.html` for a place, `in-page-service.html` for a service) report no FAIL at those widths; before, 30 of 31 kit pages had one. The hero demos link their logos to `#top`, set later splits text left and picture right, name what they sell in their titles and keep their phone panels whole. A product page is dealt only the product, photo and film heroes (`heroes.json`), since a split, word, statement or triptych hero cannot show the product at 80% of the content width; the split demo is now a service page. The starter's two-screen features section has all four tiles, the close's big button stays within half the container, and `pinned-chapters` sits on the page's 6/6 split. `kit-clean.mjs` checks the hero demos, the starter and the in-page pages with the checker and `check.mjs` at the gate's widths (the gate also holds `hero-enter` and `moment-none` and lints the copy); `--demos` adds the standalone moment demos, which show a moment alone and are not page models.
- **The stack:** An empty folder builds a static page from `assets/starter.html`; an existing project builds in its own stack with the kit served from its public folder; a brief that asks for Next.js builds a static export, checked on its built page with `--root`. The description no longer promises any stack.
### Thanks
To the community contributors whose pull requests went into `2.5.x`: @bougieL (each checked page gets its own report folder, [#181](https://github.com/Leonxlnx/taste-skill/pull/181)), @hummbl-dev (`--root` for the scripts that serve a page file, [#182](https://github.com/Leonxlnx/taste-skill/pull/182)), @ALANDVO (`font-load` warns for each web font that never loads, [#186](https://github.com/Leonxlnx/taste-skill/pull/186)) and @ICR-1 (the eyebrow fix in [#185](https://github.com/Leonxlnx/taste-skill/pull/185), written for the inspector that 2.5 replaced).
## 2.5.0 - 2026-11-05
A complete rebuild of `design-taste-frontend` (`tasteskill 2.5.0`) around professional company websites at the `figma.com` and `x.ai` bar, replacing the `2.1.x` direction-sheet and app-screen pipeline (`draw.mjs`, `assets.mjs`, `lint.mjs`, `inspect.mjs`, `scenes.mjs`).
+10 -9
View File
@@ -58,7 +58,7 @@
<p align="center"><sub><a href="https://github.com/sponsors/Leonxlnx">Become a sponsor</a></sub></p>
**Taste Skill** (`tasteskill 2.5.0`) is an Agent Skill that makes coding agents build professional company websites at the `figma.com` and `x.ai` level instead of assembling a generic landing page. Every build uses plain copy, one design system, one section per screen, equal left and right edges, real photographs, real brand icons and real product UI, with product renders when a product has no photographs, quiet micro-interactions, and shader effects, creative moments and scroll scenes kept strictly for tech, gaming and software pages, all verified in a real browser by a checker that enforces the rules.
**Taste Skill** (`tasteskill 2.5.1`) is an Agent Skill that makes coding agents build professional company websites at the `figma.com` and `x.ai` level instead of assembling a generic landing page. Every build uses plain copy, one design system, one section per screen, equal left and right edges, real photographs (licensed stand-ins when the client has none), real brand icons and real product UI, a hero entrance and one or two dealt moments on every page, quiet micro-interactions, and shader effects and scroll scenes kept strictly for tech, gaming and software pages, all verified in a real browser by a checker and a finish gate that enforce the rules.
Pages built from a single brief in earlier v2 runs:
@@ -124,11 +124,11 @@ Already have the skill? Run the install command again; the install name did not
Use it by asking your agent to build or redesign a company site; the agent loads the skill, copies `assets/` into the project, starts from `assets/starter.html` and runs the checker before it hands the page over. Every build starts by classifying the client's business (`software`, `gaming`, `tech`, `product`, `service` or `place`) and planning the page screen by screen before writing markup:
1. **Deal tokens and layout.** `scripts/start.mjs` deals a palette and Google Fonts pairing for the page kind (`library/palettes.json`, `library/type.json`), and `assets/layout/deal.mjs` deals a hero layout (`split`, `centred`, `product`, `photo`, `place`, `collage`, `editorial`, `word`, `film`, `statement` or `triptych`).
1. **Deal tokens and layout.** `scripts/start.mjs` deals a palette made for the trade and a Google Fonts pairing for the page kind (`library/palettes.json`, `library/type.json`), and `assets/layout/deal.mjs` deals a hero layout (`split`, `centred`, `product`, `photo`, `place`, `collage`, `editorial`, `word`, `film`, `statement` or `triptych`).
2. **Start from one system.** `assets/starter.html`, `assets/system.css` and `assets/layout/layout.css` enforce one section per screen at desktop (`1440x900`), equal left and right content edges, a fixed navigation bar and a single-screen closing footer (`footer.closing`).
3. **Show real product UI and real photographs.** Software and tech pages populate lit feature cards (`assets/ui/cards.css`) with rewritten HTML UI fragments (`assets/ui/`) and node-and-line diagrams (`assets/diagrams/`), with real brand and interface SVGs resolved locally by `scripts/icons.mjs`. Place, shop, hospitality and non-technical product pages lead with curated photographs (`scripts/photos.mjs`, `assets/photos/`), typography and flat colour, crediting sources in one expandable footer line with no captions under images.
4. **Keep effects for tech and gaming pages.** Shader grounds (`scripts/effects.mjs`), creative scroll or pointer moments (`scripts/moments.mjs`), scroll scenes (`assets/scenes/`) and procedural 3D prints (`assets/objects/`) are dealt only on `software`, `tech` and `gaming` pages within a four-context WebGL cap, with static fallbacks for phones and reduced motion. Cafes, hotels, shops, studios and services get zero WebGL or scroll effects.
5. **Check in a real browser over HTTP.** `scripts/consistency.mjs`, `assets/layout/check.mjs`, `scripts/perf.mjs` and `scripts/shoot.mjs` render the built page in headless Chrome across `1440`, `1024`, `768` and `390`, catching empty visuals, broken shaders, contrast drops, misaligned columns, section-height drift, placeholder text, invented street numbers and slow-network image gaps before the build ships.
4. **Keep effects for tech and gaming pages.** Shader grounds (`scripts/effects.mjs`), scroll scenes (`assets/scenes/`) and procedural 3D prints (`assets/objects/`) are dealt only on `software`, `tech` and `gaming` pages within a four-context WebGL cap, with static fallbacks for phones and reduced motion. Every page gets a hero entrance and one or two moments from `scripts/moments.mjs`; cafes, hotels, shops, studios and services get only the photo- and type-led ones, and zero WebGL.
5. **Check in a real browser over HTTP.** `scripts/consistency.mjs`, `assets/layout/check.mjs`, `scripts/perf.mjs` and `scripts/shoot.mjs` render the built page in headless Chrome across `1440`, `1024`, `768` and `390`, catching empty visuals, broken shaders, contrast drops, misaligned columns, section-height drift, placeholder text, invented street numbers and slow-network image gaps before the build ships. `scripts/gate.mjs` then holds the finish until no fixable FAIL is left, three rounds at most.
Details of what changed from `2.1.x` are in the [changelog](CHANGELOG.md).
@@ -142,11 +142,12 @@ Everything is a plain Node 22+ script in the skill folder; none installs anythin
| `assets/layout/deal.mjs` | Deals one of eleven tested hero layouts; `assets/layout/check.mjs` checks it with long-headline stress tests. |
| `scripts/photos.mjs` | Deals vetted photographs by trade, enforces image weight and writes the one footer credit line. |
| `scripts/icons.mjs` | Resolves real brand and interface icons into local SVG files. |
| `scripts/effects.mjs`, `scripts/moments.mjs` | Deal shader grounds and creative moments for tech, gaming and software pages. |
| `scripts/consistency.mjs` | The checker: renders the page in headless Chrome over HTTP at `1440`, `1024`, `768` and `390` and prints PASS, WARN or FAIL per rule. |
| `scripts/effects.mjs`, `scripts/moments.mjs` | Deal shader grounds for tech, gaming and software pages, and the moments for every kind. |
| `scripts/gate.mjs`, `scripts/copy-lint.mjs` | The finish gate (also a Stop hook) and the copy linter it runs. |
| `scripts/consistency.mjs` | The checker: renders the page in headless Chrome over HTTP at `1440`, `1024`, `820`, `768` and `390` and prints PASS, WARN or FAIL per rule. |
| `scripts/perf.mjs`, `scripts/shoot.mjs` | Page weight and speed, and full-page and per-section screenshots. |
First run: `node scripts/start.mjs deal --page software` (kinds: `software`, `gaming`, `tech`, `product`, `service`, `place`). Check a built page with `node scripts/consistency.mjs <url or file> --page <kind>`.
First run, from the project folder (a deal also arms the finish gate there): `node scripts/start.mjs deal --page software` (kinds: `software`, `gaming`, `tech`, `product`, `service`, `place`). Check a built page with `node scripts/consistency.mjs <url or file> --page <kind>`.
## Moved skills
@@ -174,10 +175,10 @@ Node 22 or newer and Chrome, Edge or Chromium; ffmpeg is optional (WebP photogra
From the skill's vetted photograph library (`assets/photos/photos.json`, including bundled Wikimedia Commons WebP sets and Unsplash photographs) dealt by `scripts/photos.mjs`. Library frames are used for trade and material details (or marked explicitly as draft stand-ins when the client has no photographs yet), never passed off as the client's own room, staff or product, and credited in one expandable footer line (`photos.mjs credits`).
**Does it work with React, Vue, Svelte, plain HTML?**
Yes. You can ship directly from `assets/starter.html` and the modular CSS/JS kits, or port the same tokens, section geometry, UI fragments and browser checks into React, Next.js, Vue, Svelte, Astro or Tailwind.
An empty folder gets a static page from `assets/starter.html`. An existing React, Vue, Svelte, Astro or Tailwind project is built in its own stack, with the kit served from its public folder; a Next.js brief is built as a static export. The browser checks run on the served or built page.
**When does a page get shaders or scroll effects?**
Only on `software`, `tech` (technical hardware) and `gaming` pages, and always within a four-context WebGL budget with static phone and reduced-motion fallbacks. Places, shops, cafes, hotels, studios, services and everyday physical products stay calm: photographs, sharp typography and flat colour.
Shaders only on `software`, `tech` (technical hardware) and `gaming` pages, and always within a four-context WebGL budget with static phone and reduced-motion fallbacks. Places, shops, cafes, hotels, studios, services and everyday physical products stay calm: photographs, sharp typography and flat colour, with a hero entrance and one or two photo- or type-led moments in their own sections.
## Support the project
+191 -86
View File
@@ -1,14 +1,22 @@
---
name: design-taste-frontend
description: >-
tasteskill 2.5.0, the anti-slop frontend skill. Builds professional company websites at the level
of figma.com and x.ai: plain copy, one system, one section per screen, equal edges, real
photographs and real product UI (product renders when there are no photographs), with shader
effects, scenes and moments only on tech, gaming and software pages, quiet micro-interactions,
and a checker that enforces it in a real browser. Use this whenever you build, design, redesign,
restyle, polish or review a landing page, company, product, shop, cafe, hotel, studio, SaaS, AI,
developer-tool or game site, in any stack (HTML/CSS, React, Next.js, Vue, Svelte, Astro,
Tailwind), or when the user says a page looks generic, templated or "AI-made".
tasteskill 2.5.1, the anti-slop frontend skill. Builds professional company websites at the level
of figma.com and x.ai: plain copy in a voice of its own, one system, one section per screen,
equal edges, real photographs and real product UI (licensed stand-in photographs when the client
has none; a product render only when the brief asks for 3D), a hero entrance and one or two dealt
moments on every page, shader effects and scenes only on tech, gaming and software pages, quiet
micro-interactions, and a checker and finish gate that enforce it in a real browser. Use this
whenever you build, design, redesign, restyle, polish or review a landing page, company,
product, shop, cafe, hotel, studio, SaaS, AI, developer-tool or game site, as static HTML/CSS or
inside an existing React, Next.js, Vue, Svelte, Astro or Tailwind project, or when the user says
a page looks generic, templated or "AI-made".
hooks:
Stop:
- hooks:
- type: command
command: 'for f in ${CLAUDE_SKILL_DIR:+"$CLAUDE_SKILL_DIR/scripts/gate.mjs"} ${CLAUDE_PLUGIN_ROOT:+"$CLAUDE_PLUGIN_ROOT/scripts/gate.mjs" "$CLAUDE_PLUGIN_ROOT/skills/taste-skill/scripts/gate.mjs"} ${CLAUDE_PROJECT_DIR:+"$CLAUDE_PROJECT_DIR/.claude/skills/design-taste-frontend/scripts/gate.mjs" "$CLAUDE_PROJECT_DIR/.agents/skills/design-taste-frontend/scripts/gate.mjs"} ${HOME:+"$HOME/.claude/skills/design-taste-frontend/scripts/gate.mjs" "$HOME/.agents/skills/design-taste-frontend/scripts/gate.mjs"}; do if [ -f "$f" ]; then exec node "$f" --hook claude; fi; done; if [ -d .taste-skill ]; then printf "%s gate.mjs not found; CLAUDE_PLUGIN_ROOT=%s CLAUDE_PROJECT_DIR=%s HOME=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${CLAUDE_PLUGIN_ROOT-}" "${CLAUDE_PROJECT_DIR-}" "${HOME-}" > .taste-skill/gate-hook-missing.txt 2>/dev/null; fi; for d in .taste-skill */.taste-skill; do if [ -f "$d/build.json" ]; then echo "tasteskill gate: gate.mjs not found (CLAUDE_PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT-}); the build in $d is not checked" >&2; break; fi; done; exit 0'
timeout: 600
---
# tasteskill 2.5
@@ -17,8 +25,7 @@ Build the website a good company would ship: it says plainly what is sold, shows
every screen, and looks made for this client, not from a template and not like an art piece.
figma.com and x.ai are the bar (in words: `layout-v4.md` section 1).
`<skill>` is this folder. It needs Node 22+ and Chrome, Edge or Chromium (ffmpeg optional). Copy `<skill>/assets` into the project's
`assets` and start from `assets/starter.html`.
`<skill>` is this folder. It needs Node 22+ and Chrome, Edge or Chromium (ffmpeg optional).
## House rules
@@ -28,20 +35,23 @@ These nine win over everything else in this skill.
footer line.
2. **Every visual renders, never an empty ground,** at 1440, 1024, 768 and 390.
3. **One section per screen:** the hero alone on the first screen, every section exactly one or
two screens tall at 1440x900, no second section heading peeking in.
two screens tall at 1440x900, no second section heading peeking in. The one exception is a
live `photo-chapters` section, three or four screens, set by the moment.
4. **Equal left and right content edges.**
5. **Hover micro-animations:** a linked card lifts 2px, a photo zooms 1.02-1.03 inside its frame,
buttons step on hover and press on click, underlines draw, card loops cycle in 2.5-4 s, repeat
and restart on hover. The motion layer does it; never strip or override it. No tilt, carousels
or timed tabs.
or timed tabs. Every hero enters once on load (`data-enter`), and every page carries one dealt
moment, two on a long page (the gate holds both).
6. **More and better real photographs where they fit;** nicer backgrounds; better heroes, layouts
and fonts.
7. **Real full-colour icons** (a known product's real icon, never a drawn one); small diagram
visuals in cards (a hub with branches; one input, a main processor, three parallel lanes;
pulses running along the lines).
8. **The big award-site scenes and creative moments are part of the skill,** but shader effects
only where the trade has a technical subject (tech, AI, developer tools, gaming, fintech,
hardware), never on a local cafe, bakery, guesthouse or shop: those get photographs.
8. **The big award-site scenes and creative moments are part of the skill:** every page deals its
moments (photo- and type-led off tech), but shader effects and scenes only where the trade has a
technical subject (tech, AI, developer tools, gaming, fintech, hardware), never on a local cafe,
bakery, guesthouse or shop: those get photographs.
9. **Addresses:** street and district only (no street given: the district), no house number, postcode or coordinates; no register
or VAT numbers; no phone in a real format; contacts on `.example`.
@@ -66,28 +76,32 @@ A maker that also opens its door or teaches (a ceramics studio, a bakery with a
**place**; its web shop becomes the goods section. It is a product only when nothing happens at the door.
"Tech" below means site-kind software, gaming or tech; "other" is everything else. Effects,
scenes, moments, loops, counts, CSS grounds, dark pages and dark heroes are tech only. An other
page is photographs, flat colour, type, real icons and the hover layer.
scenes, loops, counts, CSS grounds, dark pages and dark heroes are tech only. An other page is
photographs, flat colour, type, real icons, the hero's entrance, its dealt moment (photo- or
type-led, `moments.md`) and the hover layer.
## 2. The page plan by kind
The full plans, deal lists and section order: `references/sections.md`.
- **Place** (a cafe, bakery, guesthouse, shop). Photographs lead: 12 to 20 on the full page, 8 to
12 compact, under 8 frames the 3-screen short page, whatever the trade; no client photographs: the draft path
(`photos.md`), then count. The hero a photograph whenever a frame fits (a remote place: its region's
landscape), the visit block on screen two, the offer, the place, orders or booking.
12 compact, under 8 frames the 3-screen short page, whatever the trade; no client photographs:
the draft path and a live search (`photos.md`), then count; the palette dealt by trade. The hero
a photograph whenever a frame fits (a remote place: its region's landscape), the visit block on
screen two, the offer, the place, orders or booking.
**Variety without effects** (`sections.md` section 4): the offer and the place deal different
compositions, never two sections round one picture; one type moment (the address or name set large); one main action a screen; list text 16px. Flat grounds, square
photographs, one button style, the phone bar; no effect, ground, moment, loop, fragment, diagram
or tab.
photographs, one button style, the phone bar, the dealt moments; no effect, ground, loop,
fragment, diagram or tab.
- **Product and service** (site-kind other). Photographs lead: the product, the people, the rooms,
the work, two or more a screen on a product page. Real icons for the tools a studio uses; one
still diagram at most; flat grounds and one brand band. No shader effect, print, grain, gradient,
moment, scene or loop; WebGL only for the product render. **A physical product with no
photographs is shown:** its render (`assets/objects/product-render`, stills) leads the hero,
then the full product page, never a drawing or mood stock (`sections.md`).
- **Every product page** (photographed or rendered, hardware too; the bar is Figure and 1X;
scene or loop (the dealt moments only); WebGL only for a product render the brief asks for. **A
physical product with no photographs is shown in photographs:** stand-ins of the same kind of
thing from `photos.mjs search`, each listed in the report, on the full product page; a render
(`assets/objects/product-render`, stills) only when the brief asks for 3D or a render; never a
drawing, mood stock or a set of props (`sections.md`, Product).
- **Every product page** (photographed, in stand-ins or rendered on a 3D ask, hardware too; the bar is Figure and 1X;
`sections.md`, Product): the product **big** where it stands
whole (most of its frame, or on the ground at anchor scale), never mid-size in a grey rounded
panel on a grey ground; **a different picture each section**, never one view twice; **every headline promise pictured** (a family bike shows the child bench); dealt
@@ -96,21 +110,22 @@ The full plans, deal lists and section order: `references/sections.md`.
- **Tech** (software, gaming, hardware). The client's own subject leads at true size. Software: its
own UI as live HTML showing what it works on (a code-review tool: a red and green diff with its
Accept action) in one `app-frame`, never a generic dashboard or only fragments on panels; cards:
fragments on lit panels, pulsing diagrams, real icons. Hardware: its photographs, else its
render (as above), never a split hero of only an order form or a drawn SVG. A game: its name in
the h1, a launch line, its world as full-bleed key art (else its render), an effect never in its
place (`sections.md`, Gaming). **60 to 80 words a screen** outside UI mocks (`copy.md`). By
fragments on lit panels, pulsing diagrams, real icons. Hardware: its photographs, else stand-in
photographs of its kind (a render only when the brief asks for 3D), never a split hero of only
an order form or a drawn SVG. A game: its name in the h1, a launch line, its world as full-bleed
key art (else its name set large over stand-in photographs of its setting), an effect never in
its place (`sections.md`, Gaming). **60 to 80 words a screen** outside UI mocks (`copy.md`). By
deal: **one effect** (a shader ground in the hero or the one `[data-visual]` frame, a WebGL
part, an object or one big scene) and **one or two moments** in their own sections; four live
WebGL contexts at most; no card moves with the scroll (`effects-usage.md`).
- **One object never carries the page.** The product or hero object whole leads the hero and **two more sections at
most**; another kind of visual the brief supports carries the rest (`sections.md` section 1,
its table): a game's world, crew and map; hardware's use at the bench, an exploded or parts
view; a still diagram card, a type moment, a context photograph where the trade allows.
its table): a game's world and crew; hardware's use at the bench, its parts close up; a still
diagram card, a type moment, a context photograph where the trade allows.
- **The client's own assets get slots.** A trailer, key art, screenshots, CAD renders or a motion
video the brief names each get a frame that reads finished now (a real `<video>` with a kit
poster, a play control only when there is a page or file to play, three to five world
captures from the kit, a CAD frame), named in a comment for the client's file, never empty
video the brief names each get a frame that reads finished now (a real `<video>` with a stand-in
poster, a play control only when there is a page or file to play, three to five stand-in
photographs of the world, a CAD frame), named in a comment for the client's file, never empty
(`sections-library.md`, client-slots).
## 3. What serves which page
@@ -122,63 +137,100 @@ Open the reference of every asset family the page uses first. A dash means never
| Heights, edges, heroes, type | `system.css`, `assets/layout` | dealt hero | dealt hero | dealt hero | place hero | `layout-v4.md` |
| Sections and recipes | `starter.html` | its plan | product plan | its plan | place plan | `sections.md`, `sections-library.md` |
| Tokens, grid, buttons, nav, footer, colour | `system.css`, `system.js` | yes | yes | yes | place tokens | `system.md` |
| Copy, contacts, credits | | yes | yes | yes | yes | `copy.md` |
| Hover, press, focus, nav, tabs | `assets/motion` | yes | yes | yes | yes | `motion.md` |
| Forms, keyboard, head and share tags | `starter.html`, `system.css`, `system.js` | yes | yes | yes | yes | `system.md` |
| Copy, contacts, credits | `scripts/copy-lint.mjs` | yes | yes | yes | yes | `copy.md`, `copy-voice.md` |
| Hero entrance, hover, press, focus, nav, tabs | `assets/motion` | yes | yes | yes | yes | `motion.md` |
| Card loops, a count | `assets/motion`, `assets/ui` | yes | yes | - | - | `motion.md`, `cards.md` |
| Photographs | `scripts/photos.mjs` | people, customers; a game's art | lead | lead | lead, 12-20 | `photos.md` |
| Photographs | `scripts/photos.mjs` | people, customers; a game's art, else stand-ins of its setting | lead | lead | lead, 12-20 | `photos.md` |
| Real tool, app and file icons | `scripts/icons.mjs` | yes | yes | tools really used | - | `icons.md` |
| Feature cards, UI fragments | `assets/ui` | lead (gaming: none) | its app | an app the client runs | - | `cards.md` |
| Diagram cards (hub, lanes, pulses) | `assets/diagrams` | 0-2, pulsing | 0-2, pulsing | 0-1, still | - | `diagrams.md` |
| CSS grounds | `assets/backgrounds` | 0-2 dealt | 0-2 dealt | flat tokens | flat tokens | `backgrounds.md` |
| Shader grounds | `assets/effects` | the one effect | the one effect | - | - | `effects-usage.md` |
| Creative parts (`fx-*`) | `assets/components` | yes | yes | `fx-photo` plain, `fx-map` still | `fx-map` still | `effects-creative.md` |
| Objects and prints | `assets/objects` | one, as the effect; a game without art: its render | its render without photographs | its render without photographs | - | `effects-objects.md` |
| Award-site moments | `assets/moments` | 0-2 | 0-2 | - | - | `moments.md` |
| Objects and prints | `assets/objects` | one, as the effect; a render when the brief asks for 3D | its render when the brief asks for 3D | its render when the brief asks for 3D | - | `effects-objects.md` |
| Award-site moments | `assets/moments` | 1-2 | 1-2 | 1-2 (photo, type) | 1-2 (photo, type) | `moments.md` |
| Big scenes | `assets/scenes` | 0-1 | 0-1 | - | - | `scenes.md` |
| Speed and weight | `scripts/perf.mjs` | yes | yes | yes | yes | `perf.md` |
Read `copy.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the first page.
Read `copy.md`, `copy-voice.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the first page.
## 4. The run
0. **The stack.** An empty folder builds a static page: copy `<skill>/assets` into `assets` and
start from `assets/starter.html`. An existing project builds in its own stack, its components
taking the starter's markup and classes, with the kit copied into its public folder
(`public/assets`) and loaded from there as plain files. A brief that asks for Next.js builds a
static export (`output: 'export'`), checked on its built page (`--root out`).
1. **Read the brief.** Write down the kind (section 1), what is sold, who buys it, the one main
action (the transaction: Book a room, Order a cake, Start a free trial; a place with neither:
Get directions) and the real material: logo, screens, photographs, prices, address, hours.
Then arm the finish gate, before any page exists:
2. **Write all the copy first,** as `copy.md` says: the h1 names the category
and the buyer or the place (a game: its name and genre), one hard fact on the first screen, a noun in every heading, buttons
as a verb and an object. Nothing is invented but a coined brand name (searched first, the result
kept in `names.json` beside the page) and, on a place, plausible hours and prices.
```bash
node <skill>/scripts/gate.mjs --start index.html --kind <kind> --category "<what is sold>" --primary "<main action>"
```
3. **Plan the page as screens.** Line 1: the kind. Line 2: the deal (step 4). Then one line per
A framework build names its dev server's URL (`--start http://localhost:5173/`) or its built page
(`--start dist/index.html --root dist`; a Next.js export: `out`) in place of `index.html`, and
rebuilds before every gate run.
2. **Write all the copy first,** with the copy layer (`copy-voice.md`: the fact sheet, the voice
card on the plan, the page's shape, the device budget; a register from `copy-registers.md`, the
moves of `copy-moves.md`) within the rules of `copy.md`: the h1 names the category and the buyer or the place (a game: its name and genre),
one hard fact on the first screen, a noun in every heading, buttons as a verb and an object.
Nothing is invented but a coined brand name (searched first, the result kept in `names.json`
beside the page) and, on a place, plausible hours and prices, each marked `data-assumed`
(`copy.md`, What may be assumed). A draft kept in a `.md` file lints the same way as the page
(the voice card and part labels are skipped).
3. **Plan the page as screens.** Line 1: the kind. Line 2: the deal (step 4), with one sentence on
why the palette fits the trade. Then one line per
section: its id, its one idea, its picture and its kind (object, use, parts, world, slot, diagram, type; three object lines at most), its
screens (1 or 2, from its content height, `layout-v4.md` section 2), and on tech which section
holds the effect, the scene or a moment. Pick the photographs now (`photos.md`); their count,
screens (1 or 2, from its content height, `layout-v4.md` section 2), which section holds a
moment and why it moves (on tech also the effect or the scene). Pick the photographs now (`photos.md`); their count,
stand-ins included, sets a place's length (section 2). Then read `cards.md` and the recipe of
every id you use in `sections-library.md`.
4. **Deal, never choose by taste.** Each command keeps a history, so pages differ:
```bash
node <skill>/assets/layout/deal.mjs --type <page type> --has <photo,ui,object,film> --history .taste-skill/layout-history.json # the hero; a game adds --kind gaming, hardware --kind tech
node <skill>/scripts/start.mjs deal --page <kind> --history .taste-skill/history.json # three palettes, three pairings; add --axis after a word or editorial hero
node <skill>/assets/layout/deal.mjs --type <page type> --has <photo,ui,film,object,setting> --history .taste-skill/layout-history.json # the hero; a game adds --kind gaming, hardware --kind tech
node <skill>/scripts/start.mjs deal --page <kind> --trade <trade> --history .taste-skill/history.json # three palettes made for the trade, three pairings: take the first; add --axis after a word or editorial hero
node <skill>/scripts/start.mjs css --page <kind> --palette <id> --type <id> --radius soft --history .taste-skill/history.json
node <skill>/scripts/start.mjs brand --page place --bg "#.." --ink "#.." --accent "#.." [--surface "#.."] [--muted "#.."] --type <id> --radius soft # a place: its own colours, in place of css
node -e "for(const l of process.argv.slice(1)){const o=l.split('|');console.log(o[Math.floor(Math.random()*o.length)])}" <the kind's lists in sections.md>
node <skill>/scripts/start.mjs brand --page place --bg "#.." --ink "#.." --accent "#.." [--surface "#.."] [--muted "#.."] --type <id> --radius soft # the client's own colours (a logo, a painted shopfront), in place of css
node <skill>/scripts/moments.mjs pick --page <kind> --count 1|2 --fits section,statement --photo --history .taste-skill/history.json # every kind
node -e "for(const l of process.argv.slice(1)){const o=l.split('|');console.log(o[Math.floor(Math.random()*o.length)])}" <the kind's lists, below>
```
A product, hardware or a game with no photographs or art deals with `--has object`: always the
`product` hero, its render's widest view across the wrap, no box (a game always passes `--has`). A place's hero is printed, never dealt (`--type
place`). A dealt part that needs a photograph the page lacks is dealt again (`sections.md` section 2). Take the palette and pairing that suit
the client (its own colours: `start.mjs brand`, always on a place, with `sections.md` section 4,
Look); never violet: deal again. Keep the `:root` block as printed. Radius: `sharp` for fintech and developer tools, `soft` by default,
Run every command from the project folder (the deal arms the gate in the folder it runs in, or
joins the build already open one or two folders up). The
lists, in order nav, footer, close, rhythm, buttons, mark (`sections.md` sections 2 and 3):
- software: `"nav-mega|nav-cards|nav-centre" "footer-dark|footer-statement|footer-columns" "close-type|close-word" "dark-band|surface-pairs|space" "solid|pill|tonal|arrow" "wordmark|initial|arc|ring|stack|dots"`
- gaming: `"nav-mega|nav-centre|nav-cards" "footer-dark|footer-statement" "close-word|close-type" "dark-band|media-band|space" "solid|pill|block" "wordmark|initial|arc|ring|stack|dots"`
- product and hardware: `"nav-cards|nav-split|nav-mega" "footer-columns|footer-dark" "close-photo|close-type|close-word" "dark-band|brand-band" "arrow|solid|block|pill" "wordmark|initial|arc|ring|stack|dots"`
- service: `"nav-mega|nav-split|nav-centre" "footer-visit|footer-dark|footer-columns" "close-type|close-photo" "surface-pairs|dark-band|space" "solid|tonal|block|arrow" "wordmark|initial|arc|ring|stack|dots"`
- place: the whole line in `sections.md` section 4, Place (The deal), read in its order.
A product or hardware with no photographs deals with `--has photo` for its stand-ins;
`--has object` only for a cut-out or a render the brief asks for (always the `product` hero, its
widest view across the wrap, no box); a game without art files passes `--has setting` (the
`photo` hero, its name over a stand-in of its setting; a game always passes `--has`). A product
page is dealt only the product, photo and film heroes. A place's hero is printed, never dealt (`--type
place`). A dealt part that needs a photograph the page lacks is dealt again (`sections.md` section 2). Take the
first palette and the first pairing printed (the next only when the first clashes with the
client's own logo or photographs, or the pairing fails `hero-lines`, said on plan line 2); the client's own colours go through
`start.mjs brand` instead (`sections.md` section 4, Look); never violet: deal again. Keep the `:root` block as printed. Radius: `sharp` for fintech and developer tools, `soft` by default,
`round` for consumer apps, square on a place. On tech only: the lit ground (`<html data-ground>`:
`sand|sky|sage` through the one-liner), the CSS grounds (`backgrounds.md`), the one effect
(`effects-usage.md`) and the moments (`moments.md`). Write every result on plan line 2 and in
`sand|sky|sage` through the one-liner), the CSS grounds (`backgrounds.md`) and the one effect
(`effects-usage.md`). Write every result on plan line 2 and in
the comment at the top of `<body>`, and build exactly that (a short page drops what it has no section for: `sections.md` section 4).
5. **Build.** In `starter.html`: set `<html data-type data-radius data-buttons data-motion>` and the
three metas, the nav class; copy only the `<section class="hero …">` of
5. **Build.** In `starter.html`: set `<html lang data-type data-radius data-buttons data-motion>`
(lang: the brief's language), the three metas, the head block (title, description, og, icon,
theme-color: `system.md`, Head) and the nav class; keep the skip link and the `m-enter` head
line; copy only the `<section class="hero …" data-enter>` of
`assets/layout/hero-<id>.html` (never its nav, other sections, footer or credit line; every word
and picture becomes the client's); move the dealt close and footer out of their `<template>`s
and delete the rest. Every section is `main > section` with
@@ -188,14 +240,24 @@ Read `copy.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the
name, number and logo to the client's (kit icons become `data-icon`, `cards.md`); a diagram card is `data-diagram="<preset>"`
with the client's labels; run `icons.mjs scan index.html --out assets/icons --colour` last, so every icon
the page names is a local file. Mount an effect, object, moment or scene exactly as its
reference says, never from your own script. Credits: the one footer line from
`photos.mjs credits`, once the markup uses the photographs (it reads the page).
reference says, never from your own script. A moment's section is copied from
`assets/moments/_demo/in-page.html` (place: photo-chapters, word-highlight) or
`in-page-service.html` (pinned-chapters, picture-develops film); a moment's own `demo.html` shows
it alone and is not a page model. A form is the starter's `booking-form` (`system.md`,
Forms). Credits: the one footer line from `photos.mjs credits`, once the markup uses the
photographs (it reads the page). Copy the hero's tile from `shoot.mjs` beside the page as `share.jpg`.
6. **Check and look.** Only the skill's scripts, each running one headless Chrome with WebGL on the
CPU, closed by its own PID, one at a time; never a windowed browser or a browser tool.
Each serves the page file itself over http on 127.0.0.1 (never `file://`), so no server is needed. Then:
CPU, closed by its own PID, one at a time in the foreground with a 10-minute timeout; never in
the background, a windowed browser or a browser tool.
Each serves the page file itself over http on 127.0.0.1 (never `file://`), so no server is needed;
for a built page whose links start at the site root (`/_astro/page.css`), pass `--root` with the build folder (`dist`).
`--profile <dir>` is the Chrome profile: a folder of its own in the OS temp folder or in the
project's `.taste-skill` folder (`.taste-skill/chrome-check`), never outside the project (no
`../`); a relative one that leads out of it is replaced by `.taste-skill/chrome-<script>`. Then:
```bash
node <skill>/scripts/copy-lint.mjs index.html --kind <kind>
node <skill>/scripts/consistency.mjs index.html --widths 1440,1024,820,768,390 --rhythm-widths 1440,820,768,390 --profile <dir> --page <kind> --category "<what is sold>" --primary "<main action>"
node <skill>/assets/layout/check.mjs --url index.html --profile <dir> --sizes 1440x900,1024x768,820x1180,768x1024,390x844 --shots shots/layout
node <skill>/scripts/shoot.mjs --url index.html --w 1440 --out shots/1440.png --profile <dir>
@@ -203,13 +265,35 @@ Read `copy.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the
```
Shoot 1024, 820x1180, 768x1024 and 390 as well. Every `check.mjs` finding fails the page (no `--no-stress`).
Fix every FAIL (one you judge false goes in the report with its reason), shoot again, and read
every screenshot against the checklist.
copy-lint opens no browser and takes under a second. Fix every FAIL (a copy-lint WARN too, or say
why it stays) and read the copy aloud (`copy-voice.md` section 6); shoot again and read every
screenshot against the checklist. In the fix rounds run `consistency.mjs --quick --widths
1440,390`. Then the gate, which runs the checker at 1440 and 390 every time, walks the page at 320
for overflow, collide and tap-size, and runs copy-lint, whose FAILs block like the checker's:
7. **Report:** the kind, the deal (palette, pairing, hero, grounds, effect, moments, scene); the
checker, layout and perf results; what you could not check; every assumed price, hour or
address and the coined name's search; the photographs and art the client must supply, every
stand-in frame among them; every client slot, its file and its stand-in.
```bash
node <skill>/scripts/gate.mjs index.html
```
Fix what it lists and run it again until its last line is `GATE: PASS` or `GATE: OPEN`, three
rounds at most (in Claude Code it also runs when you finish; a stop before your first gate run is
no round, and to ask the user something after a block, just stop again). When the user tells you
to stop, or to ship the page as it is, run `gate.mjs --done "<what they said>"` (`GATE: DONE`)
and report the open FAILs.
`GATE: SETUP` (no page yet, a framework's source shell, a page that is not local): arm what a
browser can open (step 1) and run it again. `GATE: ERROR` (the checks could not run) or
`GATE: OFF`: put the line in the report and finish; do not rerun. Never edit
the kit's files or hide anything from the checker. A FAIL you judge false is waived in
`.taste-skill/waivers.json` with its reason, never fixed by gaming the check (`references/gate.md`);
a word from the client's own copy that copy-lint FAILs is waived hit by hit, the brief as the reason
(a word inside the client's name, as the logo or the title writes it, is not counted: one WARN).
7. **Report:** the `GATE:` line first, then every waiver with its reason and every open FAIL; the
kind, the deal (palette and why it fits, pairing, hero, grounds, effect, moments, scene); the
voice card in one line and every copy-lint WARN kept, with why; the checker, layout and perf
results; what you could not check; every assumed fact (the checker's `assumed` list) and the
coined name's search; the form's `.example` stand-in and the canonical domain still to come; the photographs and art the client must supply, every
stand-in frame among them (library and live search); every client slot, its file and its stand-in.
## 5. Checklist
@@ -220,8 +304,8 @@ Each line names its checks: `consistency.mjs` ids in brackets, `check.mjs` findi
copy-fact]; the hero fills it alone at every width, its main button inside it [hero-screen,
hero-fill, fold-action, fold-cut] {hero-fold}.
- **One section per screen:** every section 1 or 2 screens, its h2 first and top-aligned, no
neighbour's heading or picture in its tile, nothing sparse [section-height, one-section,
one-heading, section-air, one-screen, section-gaps] {screen-short, screen-height, sparse,
neighbour's heading or picture in its tile, nothing sparse, no section half empty [section-height, one-section,
one-heading, section-air, one-screen, section-gaps, empty-area] {screen-short, screen-height, sparse,
neighbour, neighbour-ground}.
- **Equal edges:** every block on the `.wrap` lines or the grid's columns, nothing out to one side,
no sideways scroll [container-edges, row-lines, split-pair, off-grid, right-edge, col-edge,
@@ -232,8 +316,9 @@ Each line names its checks: `consistency.mjs` ids in brackets, `check.mjs` findi
- **No caption** explains, apologises for or labels a picture; credits in one footer line
[visual-caption, caption, credit-wall, photo-credit]; no placeholder, sample note or printed
`.example` address [placeholder, sample-label].
- **The right things for the kind:** no effect ground, print, scene or moment outside tech
(WebGL only for a product render) [effect-kind, effects-fit, photo-print]; the subject itself
- **The right things for the kind:** outside tech no effect ground, print or scene, only the
dealt photo- or type-led moments (WebGL only for a product render the brief asked for)
[effect-kind, effects-fit, photo-print]; the subject itself
shows: the product big, whole in three sections at most, a new picture each [product-shown,
same-object, photo-repeat]; each asset the brief names in its slot; a game's name and its world as key art,
the diff of a code-review tool, software's `app-frame`;
@@ -241,29 +326,49 @@ Each line names its checks: `consistency.mjs` ids in brackets, `check.mjs` findi
colours; flat grounds on other [grounds]; a place deals two different compositions and one type
moment.
- **Motion:** every button, link, linked card and linked photograph visibly answers the pointer,
nothing tilts [motion-layer, hover, hover-move, tilt]; card loops cycle in 2.5-4 s [loop-cycle]; nothing moves because the page scrolled except a dealt moment or the big
scene inside its own section on tech [scroll-linked, scroll-reveal, pinned: expected only there]; anything
nothing tilts [motion-layer, hover, hover-move, tilt]; the hero enters once on load
[hero-enter]; one or two dealt moments [moment-none]; card loops cycle in 2.5-4 s [loop-cycle]; nothing moves because the page scrolled
except a dealt moment inside its own section (every kind) or the big scene (tech)
[scroll-linked, scroll-reveal, pinned: expected only there]; anything
moving past 5 s has a pause button [loop-pause]; under reduced motion everything rests and nothing
stays hidden [reduced-motion, hidden-content]; at most four live contexts [webgl].
- **Contacts** as rule 9 [street-number, postcode, coordinates, company-number, phone-number].
- **Contacts** as rule 9 [street-number, postcode, coordinates, company-number, phone-number];
Get directions only with a street, "See the area" with a district, no map link with a town only [map-link].
- **Keyboard and head:** every control named, alt on pictures, labels you can see, headings one
level at a time, no tabindex above 0; Tab reaches the skip link first, its ring shows at once and
never under the header; the menu button says its state; lang, title, description and share tags
[a11y-name, img-alt, field-label, heading-order, tabindex, skip-link, focus-ring, focus-visible,
focus-hidden, menu-toggle, lang, page-meta, share-meta].
- **One system:** one button pair, one card style, one radius scale, one link look [button-styles,
button-shape, card-family, card-layout, link-styles]; two boxes deep at most [nesting]; one
heading scale [heading-scale]; text contrast [text-contrast, contrast].
heading scale, its faces loaded [heading-scale, font-load]; text contrast [text-contrast,
contrast].
- **Copy and links:** headings name things, buttons a verb and an object, at most two hero actions
[copy-headings, copy-buttons, copy-actions, action-count]; one fact, one wording [fact-agree];
every link lands, forms send [links, forms].
[copy-headings, copy-buttons, copy-actions, action-count]; one action twice a screen at most
[action-repeat]; a heading's grey half is its own sentence [run-on]; one fact, one wording
[fact-agree]; assumed facts marked, never an open-now line from them [assumed, open-now]; every
link lands, forms send, a confirmation only after a real send, each error under its own field
[links, forms, form-send, form-fields]; no slop word, stock line, leftover or device used as a
tic, a voice of its own (copy-lint's ids: copy-residue, copy-slop-word, copy-stock-phrase,
copy-two-fragment, copy-virtue, ...; `copy-voice.md`).
- **Fast:** no FAIL row in `perf.mjs` (image weight, contexts, layout shift).
- **820, 768 and 390:** sections on their screens [section-height], the first screen ending whole
or on a clear bleed [fold-card] (`layout-v4.md`); splits stack text first, no row half empty; one column at 390, buttons full
- **820, 768, 390 and 320:** sections on their screens [section-height]; on a phone a section is at
least its screens and grows with what it holds, so nothing lies on anything else or under a fixed
bar [collide]; every link and control outside running text 44px tall [tap-size] (a FAIL when
another target is under 8px away); nothing past the screen at 320 [off-viewport], and nothing
lies on anything else there either [collide]; the first screen ending whole or on a clear bleed [fold-card]
(`layout-v4.md`); splits stack text first, no row half empty; one column at 390, buttons full
width, a full-screen menu; the place action bar.
- **Your eyes:** beside the figma.com and x.ai frames it reads as a professional company site;
nothing tilted, staggered, cut or overlapping; the hero is the work, not a mood.
## 6. Never
- A card, row or tile that animates on scroll; a reveal, fade-in or parallax; random blinking.
- Stock photographs passed off as the client's product, people or place; a product page that
never shows the product.
- A card, row or tile that fades in on scroll; a fade-in on every section; parallax on text;
scroll-jacking; random blinking.
- Stock photographs passed off as the client's product, people or place (a stand-in is listed in
the report as one, never shown with another maker's name or logo); a product page that never
shows the product.
- A drawn look-alike of a real logo; invented reviews, quotes, customers or figures.
- Violet or lavender in the page's own design: accent, ground, gradient, effect colour or tint (a
real third-party logo keeps its own colours, `icons.md`).
+29 -8
View File
@@ -4,14 +4,20 @@
the CPU), at each width, and prints every finding. Closes Chrome by its own PID.
node <skill>/assets/layout/check.mjs --url <url or file> --profile <own chrome dir>
[--sizes 1440x900,1024x768,820x1180,768x1024,390x844] [--shots <dir>] [--json]
[--sizes 1440x900,1024x768,820x1180,768x1024,390x844] [--shots <dir>] [--root <dir>] [--json]
--shots writes, per size, the first screen (first-<w>.png) and every section framed as the check
frames it (screen-<w>-<n>.png): the shot a reviewer takes of one section, to look at.
Then, once per size, it lengthens the hero's h1 to its type's longest headline (heroes.json
h1.maxChars, with words from the lead) and checks again: the hero must still be one screen
(hero-maxchars) and the h1 within its lines at 1440 and 390 (hero-lines). --no-stress skips it.
A file (or its folder, for index.html) is served from its folder over http on 127.0.0.1 (5770-5779).
(hero-maxchars) and the h1 within its lines at 1440 and 390 (hero-lines). On a page in a language that
builds long compounds (lang de, nl, da, sv, nb, nn, no, fi) the added words start with one
(Wochenendöffnungszeiten), which must break inside the column. --no-stress skips it.
A file (or its folder, for index.html) is served from its folder over http on 127.0.0.1 (5770-5779),
or from --root, a built site's folder, so its root-relative links (/style.css) resolve.
--profile: the Chrome profile, a folder of its own in the OS temp folder or the project's .taste-skill folder, never
outside the project; a relative one that leads out of the current folder (../prof) is replaced, with a warning, by
.taste-skill/chrome-layout-check.
Exit code 1 when there is a finding.
*/
import fs from 'node:fs';
@@ -26,15 +32,25 @@ const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i < 0 ? d : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true); };
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'layout-check-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-layout-check-' + process.pid);
const target = opt('url'), profileArg = (p => (p && p !== true ? p : defaultProfile()))(opt('profile')), shots = opt('shots');
const target = opt('url'), profileArg = (p => (p && p !== true ? p : defaultProfile()))(opt('profile')), shots = opt('shots'), rootArg = opt('root');
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
const sizes = String(opt('sizes', '1440x900,1024x768,820x1180,768x1024,390x844')).split(',').map(s => s.split('x').map(Number));
if (!target || target === true) { console.error('usage: check.mjs --url <url or file> [--profile <dir>] [--sizes 1440x900,1024x768,820x1180,768x1024,390x844] [--shots dir] [--json]'); process.exit(2); }
if (!target || target === true || rootArg === true) { console.error('usage: check.mjs --url <url or file> [--profile <dir in the OS temp folder or .taste-skill>] [--sizes 1440x900,1024x768,820x1180,768x1024,390x844] [--shots dir] [--root dir] [--json]'); process.exit(2); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
const chrome = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium'].find(p => p && fs.existsSync(p));
if (!chrome) { console.error('no Chrome found (set CHROME_PATH)'); process.exit(2); }
const src = fs.readFileSync(path.join(here, 'layout-check.js'), 'utf8');
// a page file is served from its folder over http on 127.0.0.1 (5770-5779), never opened from file://
// a page file is served from its folder (or --root) over http on 127.0.0.1 (5770-5779), never opened from file://
let url = target, server = null;
if (!/^https?:\/\//i.test(target)) {
let file = path.resolve(/^file:/i.test(target) ? fileURLToPath(target) : target);
@@ -43,6 +59,7 @@ if (!/^https?:\/\//i.test(target)) {
// serve from high enough that the page's ../ references resolve
const ups = Math.max(0, ...[...fs.readFileSync(file, 'utf8').matchAll(/(?:(?:src|href)\s*=\s*|\bfrom\s*|\bimport\s*\(?\s*|url\(\s*)["']?((?:\.\.\/)+)/g)].map(m => m[1].length / 3));
let root = path.dirname(file); for (let i = 0; i < ups; i++) root = path.dirname(root);
if (rootArg) { root = path.resolve(rootArg); const rel = path.relative(root, file); if (rel.split(path.sep)[0] === '..' || path.isAbsolute(rel)) { console.error(`check: the page is not inside --root ${root}: ${file}`); process.exit(2); } }
const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.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', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf',
'.mp4': 'video/mp4', '.webm': 'video/webm', '.glb': 'model/gltf-binary', '.txt': 'text/plain', '.ico': 'image/x-icon' };
@@ -73,6 +90,10 @@ const STRESS = String.raw`(async (maxChars) => {
const tw = doc.createTreeWalker(src, NodeFilter.SHOW_TEXT); let last = null;
for (let n = tw.nextNode(); n; n = tw.nextNode()) if (n.textContent.trim() && !n.parentElement.closest('.ly-word, .ly-pic, .ly-mark')) last = n;
if (!last || !words.length) return null;
// one long compound first on a page whose language builds them (a German heading holds words of 20 letters and
// more): it must break with a hyphen inside its column at 390 (system.css hyphens: auto, in the page's lang).
// An English page never holds one, and hyphens: auto in English does not break it, so it is left out there.
if (/^(de|nl|da|sv|nb|nn|no|fi)\b/i.test(document.documentElement.lang || '')) words.unshift('Wochenendöffnungszeiten');
for (let i = 0; len() < maxChars && i < 80; i++) {
const w = words[i % words.length], room = maxChars - len() - 1;
if (room < 2) break;
@@ -84,7 +105,7 @@ const STRESS = String.raw`(async (maxChars) => {
return { chars: len(), text: h1.textContent.replace(/\s+/g, ' ').trim() };
})`;
const profile = path.resolve(profileArg);
const profile = inProject(profileArg, 'layout-check');
fs.mkdirSync(profile, { recursive: true });
const portFile = path.join(profile, 'DevToolsActivePort');
try { fs.rmSync(portFile); } catch { /* none */ }
@@ -146,7 +167,7 @@ try {
const lines = again.hero?.h1?.lines ?? 0, allowed = W >= 1400 ? h.h1.lines[0] : W <= 420 ? h.h1.lines[1] : null;
res.stress = { hero: id, maxChars: h.h1.maxChars, chars: st.chars, lines, height: again.hero?.height };
for (const f of again.findings.filter(f => f.rule === 'hero-fold')) res.findings.push({ rule: 'hero-maxchars', where: f.where, detail: `with a ${st.chars}-character h1 (heroes.json maxChars): ${f.detail}` });
if (allowed && lines > allowed) res.findings.push({ rule: 'hero-lines', where: 'h1', detail: `a ${st.chars}-character h1 takes ${lines} lines; heroes.json allows ${allowed} at ${W}: lower maxChars` });
if (allowed && lines > allowed) res.findings.push({ rule: 'hero-lines', where: 'section.hero h1', detail: `the check fills the h1 to ${st.chars} characters (this hero's maxChars in heroes.json), not the page's own: it takes ${lines} lines at ${W}, ${allowed} at most` });
if (await ev('document.documentElement.scrollWidth > document.documentElement.clientWidth')) res.findings.push({ rule: 'hero-maxchars', where: 'page', detail: `with a ${st.chars}-character h1 the page scrolls sideways` });
}
}
+17 -7
View File
@@ -13,12 +13,18 @@
It deals from heroes.json's gaming weights (photo, film, and
statement or word when no art is supplied), never ui, and
--type gaming means the same. Also read as a game:
--type software with film or photo in --has and no ui
[--has photo,ui,film,object,ground] the material the brief really has (default by type:
software ui; product object,photo; service photo; a game
--type software with film, photo or setting in --has and no ui
[--has photo,ui,film,object,setting,ground] the material the brief really has (default by type:
software ui; product photo (the client's photographs, else
stand-ins from photos.mjs search); service photo; a game
ground, so a game with art passes --has film,photo).
word and statement are dealt only without photo or film (heroes.json avoid)
object with no photo or film always prints product (the full-width stage)
object (a cut-out, the client's render, or a product render when
the brief asks for 3D) with no photo or film always prints product
(the full-width stage)
setting (a game without art files: stand-in photographs of its
setting, photos.mjs search) always prints photo: the game's name
set large on the photograph's calm side
[--hero <id>] print that one instead of dealing it
[--history .taste-skill/layout-history.json] [--json] [--stats 1000]
@@ -39,12 +45,14 @@ const TYPES = ['software', 'product', 'place', 'service'];
let type = opt('type');
if (type === 'gaming') type = 'software'; // a game is page type software, kind gaming
if (!TYPES.includes(type) && !opt('hero')) { console.error('deal: --type software|product|place|service (a game: --kind gaming)'); process.exit(2); }
const HAS = { software: ['ui', 'ground'], product: ['object', 'photo', 'ground'], place: ['photo', 'ground'], service: ['photo', 'ground'] };
// a product's default is photographs (its own, else stand-ins): a render only when the brief asks for 3D (--has object)
const HAS = { software: ['ui', 'ground'], product: ['photo', 'ground'], place: ['photo', 'ground'], service: ['photo', 'ground'] };
const given = opt('has') && opt('has') !== true ? String(opt('has')).split(',').map(s => s.trim()).filter(Boolean) : null;
const gaming = type === 'software' && (opt('kind') === 'gaming' || opt('type') === 'gaming'
|| (given && !given.includes('ui') && (given.includes('film') || given.includes('photo'))));
|| (given && !given.includes('ui') && (given.includes('film') || given.includes('photo') || given.includes('setting'))));
const has = new Set(given ? given.concat('ground') : (gaming ? ['ground'] : (HAS[type] || [])));
if (gaming) has.delete('ui'); // never a UI hero on a game
if (!gaming && has.delete('setting')) has.add('photo'); // only a game has a setting: on any other page it is a photograph
const weightKey = gaming ? 'gaming' : type; // the types weights to read
const histFile = opt('history');
let hist = { heroes: [] };
@@ -75,6 +83,8 @@ function dealOnce(recentHeroes) {
// the product shown by its render or a cut-out (object, no photograph or film): always the full-width
// product stage, never the split's six columns (in testing: a render at 44% of the stage read as small)
if (!hero && has.has('object') && !has.has('photo') && !has.has('film')) hero = heroes.find(h => h.id === 'product');
// a game without art files: its name set large over a stand-in photograph of its setting (the photo hero's calm side)
if (!hero && gaming && has.has('setting') && !has.has('photo') && !has.has('film') && !has.has('object')) hero = heroes.find(h => h.id === 'photo');
if (!hero) {
const ok = heroes.filter(h => h.id !== 'place' && (h.types[weightKey] || 0) > 0 && needsMet(h));
if (!ok.length) { console.error(`deal: no hero for ${gaming ? 'a game' : type} with ${[...has].join(', ')}`); process.exit(1); }
@@ -112,7 +122,7 @@ if (opt('json')) {
grid:\n${s(hero.grid)}
at 1440:\n${s(hero.at1440)}
at 390:\n${s(hero.at390)}
moment: ${hero.moment}${tech ? '' : '\n grounds, moments, effects: none (kind other: flat colour, photographs, type; --kind tech for hardware)'}${ids(hero.moments).length ? `\n moments: ${ids(hero.moments).join(', ')}` : ''}${ids(hero.grounds).length ? `\n grounds: ${ids(hero.grounds).join(', ')}` : ''}${darkIds(hero.grounds).length ? `\n grounds on a dark page: ${darkIds(hero.grounds).join(', ')}` : ''}${hero.effects && hero.effects.length && tech ? `\n effects: ${hero.effects.map(e => e.id).join(', ')}` : ''}
moment: ${hero.moment}${tech ? '' : '\n grounds and effects: none (kind other: flat colour, photographs, type; --kind tech for hardware); moments: one or two photo- and type-led ones (moments.mjs pick --page <kind>)'}${ids(hero.moments).length ? `\n moments: ${ids(hero.moments).join(', ')}` : ''}${ids(hero.grounds).length ? `\n grounds: ${ids(hero.grounds).join(', ')}` : ''}${darkIds(hero.grounds).length ? `\n grounds on a dark page: ${darkIds(hero.grounds).join(', ')}` : ''}${hero.effects && hero.effects.length && tech ? `\n effects: ${hero.effects.map(e => e.id).join(', ')}` : ''}
mistakes: ${hero.mistakes.join('; ')}
type: the pairing start.mjs dealt (VI-02); keep its :root lines as printed (headlines 400; 400 to 500 on tech).${axis}`);
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="site-kind" content="software">
<title>Relay: an AI teammate for your support inbox</title>
<title>Relay: AI answers for your support inbox</title>
<meta name="description" content="Relay answers the support questions it can, drafts the rest for your team and learns from every reply. From $29 a seat.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wdth,wght@75..100,400..700&display=swap" rel="stylesheet">
@@ -79,12 +79,15 @@
}
@media (max-width: 639px) { .stat-cards .panel { --p-h: 148px; } .stage > .at-rise { --ay-sm: 20px; } .stat-cards > .card > .panel { margin-top: var(--s-5); } }
@media (max-width: 639px) { .d-win--dark .d-win__body { grid-template-columns: 1fr; } .inbox { display: none; } .hero__window .d-chat { font-size: 13px; padding: 12px 14px; } }
/* a phone keeps the exchange inside the frame (its draft note and the tool line go, so the whole answer ends
above the fade) and two of the four times in the 148px panel */
@media (max-width: 639px) { .hero__window .d-msg--bot small, .hero__window .d-tool { display: none; } .stat-cards .d-times span:nth-child(n+3) { display: none; } }
</style>
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="ring" aria-hidden="true"></span>Relay</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Relay</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#numbers">Product <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -107,8 +110,8 @@
</div>
</header>
<main>
<section class="hero hero--centred" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--centred" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text">
<div class="hero__c">
<a class="pill" href="#numbers"><span>Relay 2 is here</span><span class="muted">Read the launch post</span></a>
@@ -118,21 +121,19 @@
</div>
<div class="hero__window" data-fx="painterly-landscape" data-fx-seed="7" role="img" aria-label="Relay drafting a reply to a customer about a late order">
<div class="ly-piece d-win d-win--dark" aria-hidden="true">
<div class="d-win__bar"><span class="d-win__dots"><i></i><i></i><i></i></span><span><b>Maya Alvarez</b> · Order 4468</span><span class="sp"></span><span>Shopify · DHL</span></div>
<div class="d-win__bar"><span class="d-win__dots"><i></i><i></i><i></i></span><span><b>Heike Brandt</b> · Order 4468</span><span class="sp"></span><span>Shopify · DHL</span></div>
<div class="d-win__body">
<div class="inbox">
<div class="q">Search</div>
<div class="t is-on"><i style="background:#f3c98b">MA</i><b>Maya Alvarez</b><time>10:42</time><span>My order hasn't arrived and it's been…</span></div>
<div class="t is-on"><i style="background:#f3c98b">HB</i><b>Heike Brandt</b><time>10:42</time><span>My order hasn't arrived and it's been…</span></div>
<div class="t"><i style="background:#9fd3b5">JT</i><b>Jonas Thiel</b><time>10:31</time><span>Can I change the size on order 4471?</span></div>
<div class="t"><i style="background:#a9c4f0">PK</i><b>Priya Kaur</b><time>10:12</time><span>Answered by Relay · refund sent</span></div>
<div class="t"><i style="background:#e9b4a4">LB</i><b>Luca Bianchi</b><time>9:58</time><span>Invoice for March, please</span></div>
<div class="t"><i style="background:#d8d2a6">SO</i><b>Sam Okafor</b><time>9:40</time><span>Answered by Relay · tracking link</span></div>
<div class="t"><i style="background:#a9c4f0">TL</i><b>Tobias Lang</b><time>10:12</time><span>Answered by Relay · refund sent</span></div>
</div>
<div class="convo">
<div class="d-chat" data-ly-steps="900">
<div class="d-msg d-msg--me">My order hasn't arrived after eight days. Can you check?<small>10:42</small></div>
<div class="d-tool" data-step>Reading order 4468 and the DHL tracking</div>
<div class="d-msg d-msg--bot" data-step>Hi Maya, it's at the DHL depot in Leipzig after a missed delivery. I've booked it for tomorrow before 12:00 and refunded the €4.90 shipping.<small>Draft · 3 sources</small></div>
<div class="d-msg d-msg--bot" data-step>Hi Heike, it's at the DHL depot in Leipzig after a missed delivery. I've booked it for tomorrow before 12:00 and refunded the €4.90 shipping.<small>Draft · 3 sources</small></div>
<div class="acts" data-step><span>Send</span><span>Edit</span></div>
</div>
</div>
@@ -165,7 +166,7 @@
<div class="panel panel--inset" role="img" aria-label="First replies this morning, between 48 seconds and 2 minutes 5">
<div class="stage"><div class="at-rise fit"><div class="d-pane" aria-hidden="true">
<h5>First reply, this morning</h5>
<div class="d-times"><span>Priya Kaur<b>0:48</b></span><span>Maya Alvarez<b>1:12</b></span><span>Sam Okafor<b>1:40</b></span><span>Jonas Thiel<b>2:05</b></span></div>
<div class="d-times"><span>Tobias Lang<b>0:48</b></span><span>Heike Brandt<b>1:12</b></span><span>Gerd Kühn<b>1:40</b></span><span>Jonas Thiel<b>2:05</b></span></div>
</div></div></div>
</div>
</article>
@@ -192,7 +193,7 @@
</div>
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5"><a class="footer__mark" href="#"><span class="mark" data-mark="ring" aria-hidden="true"></span>Relay</a></div>
<div class="span-6 stack-5"><a class="footer__mark" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Relay</a></div>
<div class="span-3 start-7 footer__col"><h4>Product</h4><ul><li><a href="#numbers">Inbox</a></li><li><a href="#numbers">Integrations</a></li><li><a href="#numbers">Pricing</a></li></ul></div>
<div class="span-3 footer__col"><h4>Company</h4><ul><li><a href="#numbers">About</a></li><li><a href="#numbers">Security</a></li><li><a href="#numbers">Contact</a></li></ul></div>
</div>
@@ -62,12 +62,14 @@
.open-cards > .card > .panel { grid-column: 2; grid-row: 1 / span 4; --p-h: 208px; margin: calc(var(--c-gap) - var(--c-pad)) calc(var(--c-gap) - var(--c-pad)) calc(var(--c-gap) - var(--c-pad)) 0; border-radius: calc(var(--c-r) - var(--c-gap)); }
}
@media (max-width: 639px) { .open-cards .panel { --p-h: 148px; } .stage > .at-rise { --ay-sm: 20px; } .open-cards > .card > .panel { margin-top: var(--s-5); } }
/* a phone's 148px panel holds the receipt's first two sales */
@media (max-width: 639px) { .d-receipt .ln:nth-of-type(n+4) { display: none; } }
</style>
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="leaf" aria-hidden="true"></span>Pickwell</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="leaf" aria-hidden="true"></span>Pickwell</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#how">Product <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -90,8 +92,8 @@
</div>
</header>
<main>
<section class="hero hero--collage" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--collage" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text grid hero__grid">
<div class="hero__text span-5">
<h1 id="hero-h">Stock and orders for independent grocers</h1>
@@ -187,7 +189,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="leaf" aria-hidden="true"></span>Pickwell</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="leaf" aria-hidden="true"></span>Pickwell</a>
<p class="footer__lede">Stock and orders for independent grocers.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Product</h4><ul><li><a href="#how">How it works</a></li><li><a href="#how">Pricing</a></li><li><a href="#how">Integrations</a></li></ul></div>
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="site-kind" content="other">
<title>Halvorsen Arkitekter: architects for schools and libraries in Oslo</title>
<title>Halvorsen Arkitekter: schools and libraries in Oslo</title>
<meta name="description" content="A practice of 24 architects in Oslo. 31 schools and 9 libraries in timber and brick since 2004.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Mona+Sans:wdth,wght@75..125,200..900&display=swap" rel="stylesheet">
@@ -14,7 +14,7 @@
<link rel="stylesheet" href="layout.css">
<style>
:root {
--p-bg: #f2f1ee; --p-surface: #e6e5e1; --p-ink: #121212; --p-muted: #5b5a56;
--p-bg: #f2f1ee; --p-surface: #e6e5e1; --p-ink: #121212; --p-muted: #4f4e4a;
--p-accent: #c4441f; --p-accent-ink: #ffffff; --p-action: #121212; --p-action-ink: #f2f1ee;
--p-inv-bg: #121212; --p-inv-surface: #1d1d1c; --p-inv-ink: #f2f1ee; --p-inv-muted: #a9a8a3;
--p-inv-accent: #ee7a52; --p-inv-accent-ink: #121212; --p-inv-action: #f2f1ee; --p-inv-action-ink: #121212;
@@ -28,12 +28,13 @@
.steps-n .rows__value { grid-column: 3; color: var(--muted); }
.steps-n > li > p { grid-column: 2; }
@media (max-width: 1023px) { .model__photo { height: auto; aspect-ratio: 4 / 3; margin-top: var(--s-7); } }
@media (max-width: 479px) { .steps-n > li { grid-template-columns: 32px minmax(0, 1fr) auto; } }
</style>
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="stack" aria-hidden="true"></span>Halvorsen</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="stack" aria-hidden="true"></span>Halvorsen</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#model">Projects <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -56,8 +57,8 @@
</div>
</header>
<main>
<section class="hero hero--editorial" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--editorial" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text hero__in">
<h1 id="hero-h" data-ly-letters="wdth" aria-label="We design schools and libraries">We design <span class="ly-pic" style="--w: 2.1em; --ly-pos: 50% 70%" aria-hidden="true"><img src="img/photo-1081.jpg" alt="" fetchpriority="high"></span> schools and <span class="ly-pic" style="--w: 1.7em; --d: .55s; --ly-pos: 50% 78%" aria-hidden="true"><img src="img/photo-949.jpg" alt=""></span> libraries</h1>
<div class="grid hero__foot">
@@ -96,10 +97,10 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="stack" aria-hidden="true"></span>Halvorsen</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="stack" aria-hidden="true"></span>Halvorsen</a>
<p class="footer__lede">On Tøyengata in Oslo. Write to us; we reply the same week.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Contact</h4><ul><li><a href="mailto:post@halvorsen.example">Write to us</a></li><li><a href="#work">New projects</a></li></ul></div>
<div class="span-3 start-7 footer__col"><h4>Contact</h4><ul><li><a href="mailto:post@halvorsen.example">Write to us</a></li><li><a href="mailto:post@halvorsen.example?subject=New%20project">Start a project</a></li></ul></div>
<div class="span-3 footer__col"><h4>Practice</h4><ul><li><a href="#model">Projects</a></li><li><a href="#model">How we work</a></li><li><a href="#talk">Jobs</a></li></ul></div>
</div>
<div class="footer__legal">
@@ -24,13 +24,13 @@
.tours__photo { position: relative; overflow: hidden; border-radius: var(--r-lg); height: min(580px, var(--ly-fit)); }
.tours__photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; }
.tour-rows .rows__name small { display: block; font-weight: 400; color: var(--muted); font-size: var(--t-small); }
@media (max-width: 1023px) { .tours__photo { height: auto; aspect-ratio: 4 / 5; margin-bottom: var(--s-7); } }
@media (max-width: 1023px) { .tours__photo { height: auto; aspect-ratio: 4 / 5; margin-top: var(--s-7); } }
</style>
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="arc" aria-hidden="true"></span>Waldweg</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="arc" aria-hidden="true"></span>Waldweg</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#tours">Tours <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -53,8 +53,8 @@
</div>
</header>
<main>
<section class="hero hero--film" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--film" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text hero__in">
<div class="hero__media hero__fill">
<img src="img/photo-d540.jpg" alt="An orange gravel road bending through autumn forest under a stormy grey sky" style="--ly-pos: 50% 58%; --ly-pos-phone: 58% 50%" fetchpriority="high">
@@ -75,10 +75,7 @@
<section class="section" id="tours" aria-labelledby="tours-h">
<div class="wrap grid grid--middle">
<div class="span-5">
<div class="tours__photo"><img src="img/photo-d534.jpg" alt="A forest lane under an arch of golden autumn beeches, with soft mist and leaves on the road" loading="lazy"></div>
</div>
<div class="span-6 start-7">
<div class="span-6">
<div class="section-head">
<h2 id="tours-h">Four tours this season. <span class="muted">Eight riders at most, one guide and one driver.</span></h2>
</div>
@@ -89,6 +86,9 @@
<li><span class="rows__name">Autumn weekend<small>2 days, 140 km, October only</small></span><span class="rows__value">€450</span></li>
</ul>
</div>
<div class="span-5 start-8">
<div class="tours__photo"><img src="img/photo-d534.jpg" alt="A forest lane under an arch of golden autumn beeches, with soft mist and leaves on the road" loading="lazy"></div>
</div>
</div>
</section>
</main>
@@ -102,7 +102,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="footer__mark" href="#"><span class="mark" data-mark="arc" aria-hidden="true"></span>Waldweg</a>
<a class="footer__mark" href="#top"><span class="mark" data-mark="arc" aria-hidden="true"></span>Waldweg</a>
</div>
<div class="span-3 start-7 footer__col"><h4>Tours</h4><ul><li><a href="#tours">Guided</a></li><li><a href="#tours">Self-guided</a></li><li><a href="#tours">Bike hire</a></li></ul></div>
<div class="span-3 footer__col"><h4>Contact</h4><ul><li><a href="mailto:ride@waldweg.example">Write to us</a></li><li><a href="#top">Group rides</a></li></ul></div>
@@ -28,13 +28,13 @@
.legs > li::before { counter-increment: leg; content: counter(leg); grid-row: 1 / span 2; font: 400 var(--t-h3)/1 var(--font-display); color: var(--accent); }
.legs .rows__value { grid-column: 3; }
.legs > li > p { grid-column: 2; }
@media (max-width: 1023px) { .way__photo { height: auto; aspect-ratio: 4 / 3; margin-bottom: var(--s-7); } }
@media (max-width: 1023px) { .way__photo { height: auto; aspect-ratio: 4 / 3; margin-top: var(--s-7); } }
</style>
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="arc" aria-hidden="true"></span>Alva Guides</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="arc" aria-hidden="true"></span>Alva Guides</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#way">Routes <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -55,8 +55,8 @@
</div>
</header>
<main>
<section class="hero hero--photo" data-calm="left" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--photo" data-enter data-calm="left" aria-labelledby="hero-h">
<img class="hero__bg" src="img/photo-465.jpg" alt="A dark rocky peak rising out of thick white cloud" style="--ly-pos: 50% 50%; --ly-pos-phone: 80% 50%" fetchpriority="high">
<div class="wrap"><div class="hero-text">
<div class="hero__text">
@@ -75,10 +75,7 @@
<section class="section" id="way" aria-labelledby="way-h">
<div class="wrap grid grid--middle">
<div class="span-5">
<div class="way__photo"><img src="img/photo-c400.jpg" alt="A waterfall spreading over dark basalt steps above a mossy slope" loading="lazy"></div>
</div>
<div class="span-6 start-7">
<div class="span-6">
<div class="section-head">
<h2 id="way-h">The way up. <span class="muted">A marked path, no glacier, about 1,250 metres of climb.</span></h2>
</div>
@@ -89,6 +86,9 @@
<li><span class="rows__name">Sasc Furä hut</span><span class="rows__value">2,340 m</span><p>The night before the ridge; dinner at 18:30, away at 4:00.</p></li>
</ol>
</div>
<div class="span-5 start-8">
<div class="way__photo"><img src="img/photo-c400.jpg" alt="A waterfall spreading over dark basalt steps above a mossy slope" loading="lazy"></div>
</div>
</div>
</section>
</main>
@@ -102,7 +102,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="arc" aria-hidden="true"></span>Alva Guides</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="arc" aria-hidden="true"></span>Alva Guides</a>
<p class="footer__lede">Book by email from 1 March. We answer within two days.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Book</h4><ul><li><a href="mailto:book@alva-guides.example">Write to us</a></li><li><a href="#book">Prices</a></li></ul></div>
@@ -43,8 +43,8 @@
</header>
<main id="top">
<section class="hero hero--place" aria-labelledby="hero-h">
<img class="hero__bg" src="img/photo-c8.jpg" alt="Milk poured into a cup of latte art, held in one hand, in warm light" style="--ly-pos: 50% 52%" fetchpriority="high">
<section class="hero hero--place" data-enter aria-labelledby="hero-h">
<img class="hero__bg" data-phone-fill src="img/photo-c8.jpg" alt="Milk poured into a cup of latte art, held in one hand, in warm light" style="--ly-pos: 50% 52%" fetchpriority="high">
<div class="wrap"><div class="hero-text">
<div class="hero__text">
<h1 id="hero-h">Sourdough, buns and coffee in Leith</h1>
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="site-kind" content="other">
<title>Northfield Roastery: three roasts, sent every two weeks</title>
<title>Northfield Roastery: three roasts of coffee beans</title>
<meta name="description" content="Coffee beans by subscription: three roasts, roasted on Monday and posted on Tuesday. 250 g from €9.50.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Instrument+Sans:wdth,wght@75..100,400..700&display=swap" rel="stylesheet">
@@ -32,7 +32,7 @@
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northfield</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northfield</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#use">Shop <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -55,8 +55,8 @@
</div>
</header>
<main>
<section class="hero hero--product" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--product" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text hero__in">
<div class="hero__head">
<div>
@@ -67,18 +67,6 @@
</div>
<div class="hero__stage">
<img src="img/photo-c70.jpg" alt="Three white bowls of coffee beans in a row, dark, medium and light roast, seen from above" style="--ly-pos: 50% 50%" fetchpriority="high">
<div class="ly-spot" style="--x: 21.5%; --y: 50%">
<button type="button" aria-label="About the dark roast"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M6 1.5v9M1.5 6h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
<div class="ly-spot__card" role="note"><strong>Dark: Sumatra</strong>Cocoa and cedar. For espresso and milk.</div>
</div>
<div class="ly-spot" style="--x: 50%; --y: 50%">
<button type="button" aria-label="About the medium roast"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M6 1.5v9M1.5 6h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
<div class="ly-spot__card" role="note"><strong>Medium: Cerrado, Brazil</strong>Hazelnut and caramel. For a cafetière or a moka pot.</div>
</div>
<div class="ly-spot ly-spot--left" style="--x: 77.5%; --y: 50%">
<button type="button" aria-label="About the light roast"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M6 1.5v9M1.5 6h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
<div class="ly-spot__card" role="note"><strong>Light: Huila, Colombia</strong>Orange and honey. For filter and pour-over.</div>
</div>
</div>
</div></div>
</section>
@@ -112,8 +100,8 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northfield</a>
<p class="footer__lede">Roasted in small batches in Leeds since 2019.</p>
<a class="nav__logo" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northfield</a>
<p class="footer__lede">Kirkstall, Leeds.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Shop</h4><ul><li><a href="#use">Subscriptions</a></li><li><a href="#use">Single bags</a></li><li><a href="#use">Gifts</a></li></ul></div>
<div class="span-3 footer__col"><h4>Help</h4><ul><li><a href="#use">Delivery</a></li><li><a href="#use">Brew guides</a></li></ul></div>
@@ -1,12 +1,12 @@
<!doctype html>
<!-- layout v4 demo: hero-split (references/layout-v4.md, heroes.json "split"). type: product. fonts: fraunces. -->
<html lang="en" data-type="product" data-radius="soft" data-ground="sand" data-buttons="solid">
<!-- layout v4 demo: hero-split (references/layout-v4.md, heroes.json "split"). type: service. fonts: fraunces. -->
<html lang="en" data-type="service" data-radius="soft" data-ground="sand" data-buttons="solid">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="site-kind" content="other">
<title>Saltmarsh Bakery: sourdough, buns and coffee in Leith</title>
<meta name="description" content="A bakery and coffee bar on Henderson Street, Leith. Bread from 7:30, coffee from our own roast, cakes to order.">
<title>Saltmarsh: coffee bar hire for events in Edinburgh</title>
<meta name="description" content="Two baristas, an espresso machine and our own roast at your wedding, party or office in Edinburgh. Half a day from £480.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..700,0..100,0..1&family=Figtree:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../system.css">
@@ -21,7 +21,7 @@
--font-display: "Fraunces", Georgia, serif; --font-text: "Figtree", system-ui, sans-serif;
--display-weight: 400; --display-tracking: -0.02em; --display-tracking-xl: -0.03em; --display-axes: 'SOFT' 70, 'WONK' 0; --ly-scale: 1;
}
#counter .hero__media, #counter .counter__photo { border-radius: var(--r-lg); }
#packages .counter__photo { border-radius: var(--r-lg); }
.counter__photo { position: relative; overflow: hidden; height: min(560px, var(--ly-fit)); background: var(--surface); }
.counter__photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 40% 50%; }
.rows .rows__name small { display: block; font-weight: 400; color: var(--muted); font-size: var(--t-small); }
@@ -31,86 +31,85 @@
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="petal" aria-hidden="true"></span>Saltmarsh</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="petal" aria-hidden="true"></span>Saltmarsh</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#counter">Bread and buns <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
<a class="nav__link" href="#packages">Packages <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
<div class="nav__menu">
<div class="wrap grid">
<div class="menu-col span-3"><p class="t-label">Every day</p><a href="#counter">Sourdough<span>Country loaf, rye, seeded</span></a><a href="#counter">Buns<span>Cardamom, cinnamon, fig</span></a></div>
<div class="menu-col span-3"><p class="t-label">To order</p><a href="#cakes">Cakes<span>Two days' notice</span></a><a href="#cakes">Wholesale<span>For cafes in Leith</span></a></div>
<div class="menu-col span-3"><p class="t-label">Events</p><a href="#packages">Weddings<span>Coffee after the meal</span></a><a href="#packages">Parties<span>Half or full day</span></a></div>
<div class="menu-col span-3"><p class="t-label">Work</p><a href="#packages">Office mornings<span>Weekly, two hours</span></a><a href="#book">Ask for a date<span>Two weeks' notice</span></a></div>
</div>
</div>
</div>
<div class="nav__item"><a class="nav__link" href="#counter">Coffee</a></div>
<div class="nav__item"><a class="nav__link" href="#cakes">Cakes</a></div>
<div class="nav__item"><a class="nav__link" href="#visit">Visit</a></div>
<div class="nav__item"><a class="nav__link" href="#packages">Weddings</a></div>
<div class="nav__item"><a class="nav__link" href="#packages">Offices</a></div>
<div class="nav__item"><a class="nav__link" href="#book">Bookings</a></div>
</nav>
<div class="nav__actions">
<a class="nav__link" href="#visit">Henderson Street</a>
<a class="btn btn--primary" href="#cakes">Order a cake</a>
<a class="nav__link" href="#packages">Packages</a>
<a class="btn btn--primary" href="#book">Ask for a date</a>
</div>
<button class="nav__toggle" type="button" aria-expanded="false" aria-label="Menu"><svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2 5h14M2 13h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
</div>
</header>
<main>
<section class="hero hero--split" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--split" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text grid hero__grid">
<div class="hero__text span-5">
<p class="hero__kicker"><span class="dot" aria-hidden="true"></span>Open today, 7:30 to 16:00</p>
<h1 id="hero-h">Sourdough, buns and coffee in Leith</h1>
<p class="hero__lead">We bake through the night on Henderson Street. Bread from 7:30 until it runs out, coffee from our own roast, cakes to order with two days' notice.</p>
<div class="btn-row"><a class="btn btn--primary" href="#counter">See today's bake</a><a class="btn btn--secondary" href="#cakes">Order a cake</a></div>
<p class="hero__kicker"><span class="dot" aria-hidden="true"></span>Taking bookings for the spring</p>
<h1 id="hero-h">Coffee bar hire for weddings and events</h1>
<p class="hero__lead">Two baristas, an espresso machine and our own roast, set up in an hour anywhere in Edinburgh. Half a day from £480, up to 100 cups.</p>
<div class="btn-row"><a class="btn btn--primary" href="#book">Ask for a date</a><a class="btn btn--secondary" href="#packages">See the packages</a></div>
</div>
<div class="hero__media span-6 start-7">
<img src="img/photo-c166.jpg" alt="A cappuccino dusted with cinnamon beside a croissant, in window light" style="--ly-pos: 50% 58%" fetchpriority="high">
<div class="ly-float">
<div class="ly-float__row"><span class="ly-live">Out of the oven</span><span>10:30</span></div>
<div class="ly-float__title">Cardamom buns, 48 today</div>
<div class="ly-float__row"><span class="ly-live">Saturday</span><span>14:00</span></div>
<div class="ly-float__title">Wedding in Portobello, 140 cups</div>
</div>
</div>
</div></div>
</section>
<section class="section" id="counter" aria-labelledby="counter-h">
<section class="section" id="packages" aria-labelledby="packages-h">
<div class="wrap grid grid--middle">
<div class="span-6">
<div class="section-head">
<h2 id="counter-h">At the counter today. <span class="muted">Baked overnight, sold from 7:30.</span></h2>
<h2 id="packages-h">Four packages. <span class="muted">Two baristas, the machine, cups and milk in each.</span></h2>
</div>
<ul class="rows">
<li><span class="rows__name">Country sourdough<small>Wholemeal, 36-hour ferment</small></span><span class="rows__value">£5.20</span></li>
<li><span class="rows__name">Cardamom bun<small>Out of the oven at 10:30</small></span><span class="rows__value">£3.10</span></li>
<li><span class="rows__name">Rye with sunflower seeds<small>Thursdays and Saturdays</small></span><span class="rows__value">£4.80</span></li>
<li><span class="rows__name">Filter coffee<small>This week: Huila, Colombia</small></span><span class="rows__value">£3.20</span></li>
<li><span class="rows__name">Flat white<small>Oat milk at no extra cost</small></span><span class="rows__value">£3.40</span></li>
<li><span class="rows__name">Half day<small>Four hours, up to 100 cups</small></span><span class="rows__value">£480</span></li>
<li><span class="rows__name">Full day<small>Eight hours, up to 250 cups</small></span><span class="rows__value">£860</span></li>
<li><span class="rows__name">Wedding<small>From the meal until midnight</small></span><span class="rows__value">£720</span></li>
<li><span class="rows__name">Office mornings<small>Weekly, two hours, 40 cups</small></span><span class="rows__value">£190</span></li>
</ul>
</div>
<div class="span-5 start-8">
<div class="span-6 start-7">
<div class="counter__photo"><img src="img/photo-c96.jpg" alt="Hot water poured from a copper kettle into a pour-over dripper on a pale table" loading="lazy"></div>
</div>
</div>
</section>
</main>
<footer class="footer closing" id="visit" aria-labelledby="close-h">
<footer class="footer closing" id="book" aria-labelledby="close-h">
<div class="wrap">
<div class="closing__cta close--type">
<div class="section-head"><h2 id="close-h">See today's bake</h2></div>
<a class="btn btn--primary btn--xl" href="#counter">See today's bake<svg class="m-arrow" viewBox="0 0 12 12" aria-hidden="true"><path class="m-arrow__stem" d="M1 6h8"/><path class="m-arrow__head" d="M4.5 2.5 8 6l-3.5 3.5"/></svg></a>
<div class="section-head"><h2 id="close-h">Ask for a date</h2></div>
<a class="btn btn--primary btn--xl" href="mailto:book@saltmarsh.example">Ask for a date<svg class="m-arrow" viewBox="0 0 12 12" aria-hidden="true"><path class="m-arrow__stem" d="M1 6h8"/><path class="m-arrow__head" d="M4.5 2.5 8 6l-3.5 3.5"/></svg></a>
</div>
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="petal" aria-hidden="true"></span>Saltmarsh</a>
<p class="footer__lede">Henderson Street, Leith</p>
<a class="nav__logo" href="#top"><span class="mark" data-mark="petal" aria-hidden="true"></span>Saltmarsh</a>
<p class="footer__lede">From Leith, anywhere in Edinburgh and the Lothians.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Hours</h4><ul><li>Tuesday to Friday 7:30 to 16:00</li><li>Saturday and Sunday 8:00 to 15:00</li><li>Closed on Mondays</li></ul></div>
<div class="span-3 footer__col" id="cakes"><h4>Cakes to order</h4><ul><li><a href="mailto:cakes@saltmarsh.example">Write to us</a></li><li>Two days' notice</li></ul></div>
<div class="span-3 start-7 footer__col"><h4>Packages</h4><ul><li><a href="#packages">Weddings</a></li><li><a href="#packages">Parties</a></li><li><a href="#packages">Office mornings</a></li></ul></div>
<div class="span-3 footer__col"><h4>Bookings</h4><ul><li><a href="mailto:book@saltmarsh.example">Write to us</a></li><li>Two weeks' notice</li></ul></div>
</div>
<div class="footer__legal">
<span>&copy; 2026 Saltmarsh Bakery</span>
<span>&copy; 2026 Saltmarsh Coffee</span>
<details class="credits"><summary>Photographs: Katerina Pavlickova and Kim Sanso, via Wikimedia Commons</summary><p>Cappuccino and croissant by Katerina Pavlickova (CC0); pour-over by Kim Sanso (CC0); both via Wikimedia Commons.</p></details>
</div>
</div>
@@ -38,7 +38,7 @@
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="dots" aria-hidden="true"></span>Fundbook</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="dots" aria-hidden="true"></span>Fundbook</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#funds">Product <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -61,8 +61,8 @@
</div>
</header>
<main>
<section class="hero hero--statement bg-horizon" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--statement bg-horizon" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text hero__in">
<div>
<a class="pill" href="#funds"><span>New</span><span class="muted">Gift Aid claims in two clicks</span></a>
@@ -115,7 +115,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="dots" aria-hidden="true"></span>Fundbook</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="dots" aria-hidden="true"></span>Fundbook</a>
<p class="footer__lede">Accounting for charities and foundations.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Product</h4><ul><li><a href="#funds">Funds</a></li><li><a href="#funds">Gift Aid</a></li><li><a href="#funds">Pricing</a></li></ul></div>
@@ -55,6 +55,8 @@
.pc-sched b { font-weight: 600; font-size: max(13px, 2.2cqw); }
.pc-sched .ln { display: flex; justify-content: space-between; gap: 2cqw; padding-top: 1.4cqw; border-top: 1px solid #ececea; }
.pc-sched .ln em { font-style: normal; color: #1f7a4d; font-weight: 500; }
/* a phone: the menu keeps three dishes and no notes (its 13px floor would run it past the collage, over the button) */
@media (max-width: 639px) { .pc-menu .it:nth-of-type(n+4), .pc-menu .it small { display: none; } }
.made-row { display: flex; flex-wrap: wrap; gap: var(--s-5) var(--s-7); align-items: baseline; }
.made-row .t-stat { font: var(--display-weight) var(--t-stat)/1 var(--font-display); letter-spacing: var(--display-tracking); }
.tiles3 .tile__media img { object-position: 50% 55%; }
@@ -64,7 +66,7 @@
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="petal" aria-hidden="true"></span>Carte</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="petal" aria-hidden="true"></span>Carte</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#made">Product <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -87,12 +89,12 @@
</div>
</header>
<main>
<section class="hero hero--triptych" aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--triptych" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text grid hero__grid">
<div class="span-3">
<h1 id="hero-h">Menus and posters for restaurants</h1>
<p class="hero__lead">Change a price once and every menu, poster, table card and screen follows.</p>
<p class="hero__lead">Change a price once and every menu, poster, table card and screen follows. From €19 a month.</p>
</div>
<div class="ly-collage span-6 start-4" role="img" aria-label="A lunch menu, a poster for a coffee week and a table card, each made in Carte from the same price list">
<div class="ly-collage__set is-on">
@@ -135,8 +137,8 @@
</div>
<div class="grid tiles3">
<a class="tile span-4" href="#made"><div class="tile__media"><img src="img/photo-c96.jpg" alt="Pour-over coffee being made on a pale table" loading="lazy"></div><div class="tile__head"><h3>Filter menu, Dalston</h3><span class="tile__price">A5</span></div></a>
<a class="tile span-4" href="#made"><div class="tile__media"><img src="img/photo-m61757972.jpg" alt="A woven basket of asparagus, avocados and peppers" loading="lazy"></div><div class="tile__head"><h3>Market board, Utrecht</h3><span class="tile__price">Screen</span></div></a>
<a class="tile span-4" href="#made"><div class="tile__media"><img src="img/photo-c70.jpg" alt="Three bowls of coffee beans, dark to light" loading="lazy"></div><div class="tile__head"><h3>Beans to take home, Porto</h3><span class="tile__price">Table card</span></div></a>
<a class="tile span-4" href="#made"><div class="tile__media"><img src="img/photo-m61757972.jpg" alt="A woven basket of asparagus, avocados and peppers" loading="lazy"></div><div class="tile__head"><h3>Market screen, Utrecht</h3><span class="tile__price">16:9</span></div></a>
<a class="tile span-4" href="#made"><div class="tile__media"><img src="img/photo-c70.jpg" alt="Three bowls of coffee beans, dark to light" loading="lazy"></div><div class="tile__head"><h3>Bean table card, Porto</h3><span class="tile__price">A6</span></div></a>
</div>
</div>
</section>
@@ -151,7 +153,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="petal" aria-hidden="true"></span>Carte</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="petal" aria-hidden="true"></span>Carte</a>
<p class="footer__lede">Menus, posters and table cards for restaurants.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Product</h4><ul><li><a href="#made">Menus</a></li><li><a href="#made">Screens</a></li><li><a href="#made">Pricing</a></li></ul></div>
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="../ui/cards.css">
<link rel="stylesheet" href="../backgrounds/backgrounds.css">
<link rel="stylesheet" href="layout.css">
<link rel="stylesheet" href="demo.css">
<style>
:root {
--p-bg: #fbf8f1; --p-surface: #f1ece0; --p-ink: #141311; --p-muted: #5e5a52;
@@ -28,6 +29,13 @@
.quote-card blockquote { font: var(--display-weight) var(--t-h3)/1.25 var(--font-display); font-stretch: 100%; letter-spacing: -0.015em; }
.quote-card figcaption { font-size: var(--t-small); color: var(--muted); }
.stars { display: flex; gap: 3px; color: var(--accent); } .stars svg { width: 18px; height: 18px; }
/* the fixed price the customer was sent, drawn as the quote they get after the call */
.offer .d-card { padding: 16px 18px; }
.offer .d-card > p { margin-bottom: 10px; }
.offer__ln { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid rgb(0 0 0 / .07); font-size: 13.5px; color: #3d4247; }
.offer__ln b { font-weight: 500; color: #16181a; font-variant-numeric: tabular-nums; }
.offer__sum { font-weight: 500; color: #16181a; }
.price-rows + .btn-row { margin-top: var(--s-6); }
@media (max-width: 1023px) { .quote-card { margin-top: var(--s-7); } }
</style>
<script src="../backgrounds/backgrounds.js"></script>
@@ -35,7 +43,7 @@
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="nav__logo" href="#"><span class="mark" data-mark="stack" aria-hidden="true"></span>Elbe Umzüge</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="stack" aria-hidden="true"></span>Elbe Umzüge</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
<a class="nav__link" href="#prices">Moves <svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
@@ -57,8 +65,8 @@
</div>
</header>
<main>
<section class="hero hero--word"aria-labelledby="hero-h">
<main id="top">
<section class="hero hero--word" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text hero__in">
<h1 id="hero-h" data-ly-letters="wdth">Hamburg movers for <span class="ly-word" data-ly-word='["offices.","pianos.","studios."]'>flats.</span></h1>
<div class="grid hero__foot">
@@ -88,8 +96,19 @@
<li><span class="rows__name">Upright piano<small>Up or down any staircase</small></span><span class="rows__value">€290</span></li>
<li><span class="rows__name">Office up to 20 desks<small>Over a weekend, IT packed by us</small></span><span class="rows__value">from €2,400</span></li>
</ul>
<div class="btn-row"><a class="btn btn--primary" href="mailto:moves@elbe-umzuege.example?subject=Video%20call">Book the video call</a></div>
</div>
<figure class="quote-card span-5 start-8">
<div class="offer" role="img" aria-label="The fixed price for a two-bedroom move with an upright piano: three movers for a day, €780, the piano €290, €1,070 in all">
<div class="d-card" aria-hidden="true">
<div class="d-card__row"><h5>Fixed price</h5><span class="d-chip">Agreed</span></div>
<p>Two bedrooms, Eimsbüttel to Altona</p>
<div class="offer__ln"><span>Three movers, one day</span><b>€780</b></div>
<div class="offer__ln"><span>Blankets and boxes</span><b>included</b></div>
<div class="offer__ln"><span>Upright piano, fourth floor</span><b>€290</b></div>
<div class="offer__ln offer__sum"><span>In all</span><b>€1,070</b></div>
</div>
</div>
<div class="stars" aria-label="5 out of 5" role="img"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 1.8l2.5 5.2 5.7.8-4.1 4 1 5.6L10 14.8l-5.1 2.6 1-5.6-4.1-4 5.7-.8z"/></svg><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 1.8l2.5 5.2 5.7.8-4.1 4 1 5.6L10 14.8l-5.1 2.6 1-5.6-4.1-4 5.7-.8z"/></svg><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 1.8l2.5 5.2 5.7.8-4.1 4 1 5.6L10 14.8l-5.1 2.6 1-5.6-4.1-4 5.7-.8z"/></svg><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 1.8l2.5 5.2 5.7.8-4.1 4 1 5.6L10 14.8l-5.1 2.6 1-5.6-4.1-4 5.7-.8z"/></svg><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 1.8l2.5 5.2 5.7.8-4.1 4 1 5.6L10 14.8l-5.1 2.6 1-5.6-4.1-4 5.7-.8z"/></svg></div>
<blockquote>“They carried a Bechstein down four floors in Eimsbüttel and the price was the one on the quote.”</blockquote>
<figcaption>Clara Nowak, moved in March</figcaption>
@@ -107,7 +126,7 @@
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#"><span class="mark" data-mark="stack" aria-hidden="true"></span>Elbe Umzüge</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="stack" aria-hidden="true"></span>Elbe Umzüge</a>
<p class="footer__lede">Stresemannstraße, Altona.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Ask</h4><ul><li><a href="mailto:moves@elbe-umzuege.example">Write to us</a></li><li>Monday to Saturday, 7:00 to 20:00</li></ul></div>
+13 -17
View File
@@ -1,13 +1,13 @@
{
"about": "The hero types of layout v4 (references/layout-v4.md section 3), each built in this folder as hero-<id>.html and shot at 1440x900, 1024x768, 820x1180 and 390x844. A builder deals one at random by the brief's type and the material it really has: node <skill>/assets/layout/deal.mjs --type <software|product|service> --has <photo,ui,film,object,ground>. A place's hero is never dealt: it is always 'place' (local-business Recipe 3, hero-place.html), and no other hero lists place. Every hero is exactly one screen (height 100svh) with the fixed header (72px, 56 on a phone) over its top: its words start 48px under the header (y 120 at 1440x900, y 96 at 390x844; 40 and 24 under a place's photograph), its content ends 64px above the fold (24 on a phone, plus the action bar), and nothing of the next section shows. Its blocks stand on the container's two lines (x 80 and 1360 at 1440, 24 and 366 at 390) or run edge to edge; never out to one viewport edge only. Nothing is laid over a photograph to make words readable (no scrim, gradient or veil: PH-31). Numbers are px, measured on the demos (check.mjs prints them): [1440x900, 390x844]. h1 sizes in 'size' are before --ly-scale; the measured ones in at1440/at390 are after the demo's scale.",
"fields": {
"types": "business types it may be dealt to, with weights; a type missing is never dealt. place is never dealt: deal.mjs always prints the 'place' hero for it. gaming is a kind, not a page type (a game is page type software): its weights are read by deal.mjs for a game (--kind gaming, or --type software --has film,photo without ui) and never for a software brief; no hero for a game lists ui. A game without art files passes --has object (its key art is rendered with assets/objects/product-render) and is dealt the product stage; a game never deals statement or word: an effect never replaces its key art",
"needs": "the material it cannot do without (a|b means either): photo (a strong photograph of the real thing, at least 1920 wide for a full-bleed one), ui (the client's screens or kit fragments from assets/ui rewritten to its names), film (a real film; without one the control links to the film's page, never a dead button), object (a product photograph on a plain ground, a cut-out, the client's render, or a product render composed with assets/objects/product-render: a physical product, hardware or a game's hero object or world piece without photographs or art), ground (nothing: a CSS ground from assets/backgrounds)",
"types": "business types it may be dealt to, with weights; a type missing is never dealt. place is never dealt: deal.mjs always prints the 'place' hero for it. gaming is a kind, not a page type (a game is page type software): its weights are read by deal.mjs for a game (--kind gaming, or --type software --has film,photo without ui) and never for a software brief; no hero for a game lists ui. A game without art files passes --has setting (stand-in photographs of its setting, found with photos.mjs search) and is printed the photo hero, its name set large on the photograph's calm side; --has object (its key art rendered with assets/objects/product-render) only when the brief asks for 3D. A game never deals statement or word: an effect never replaces its key art",
"needs": "the material it cannot do without (a|b means either): photo (a strong photograph of the real thing, at least 1920 wide for a full-bleed one: the client's own, else a stand-in found with photos.mjs search and listed in the report), ui (the client's screens or kit fragments from assets/ui rewritten to its names), film (a real film; without one the control links to the film's page, never a dead button), object (a product photograph on a plain ground, a cut-out, the client's render, or a product render composed with assets/objects/product-render when the brief asks for 3D or a render), setting (a game without art files: stand-in photographs of the place its world is set in), ground (nothing: a CSS ground from assets/backgrounds)",
"h1": "the layout.css token, its size at 1440 and 390 before --ly-scale, the leading, the most lines at each width and the longest headline that fits",
"grid": "where each block stands on the 12 columns at 1440 (from 1024 up); below 1024 everything stacks in the order given",
"at1440": "measured on the demo at 1440x900 (the fixed header 72 over the hero's top, the hero exactly 900, words from y 120, content ending by y 836, container x 80 to 1360)",
"at390": "measured on the demo at 390x844 (header 56, the hero exactly 844, words from y 96, content ending by y 820, container x 24 to 366)",
"moment": "the hero's own small motion (layout.js), how it rests, and what reduced motion and a phone show. On kind other (site-kind other) and on a place nothing moves by itself: the live word, the collage sets and the steps rest on their first or finished picture (pointer-driven letters and hover stay)",
"moment": "the hero's own small motion (layout.js) after the entrance every hero makes once on load (data-enter, motion.md), how it rests, and what reduced motion and a phone show; 'none' means nothing more than that entrance. The page's dealt moments are apart (moments.mjs pick). On kind other (site-kind other) and on a place nothing moves by itself: the live word, the collage sets and the steps rest on their first or finished picture (pointer-driven letters and hover stay)",
"moments": "assets/moments/index.json ids that may be dealt onto this hero (one at most; moments.mjs pick --fits hero decides), as { id, only? }: only \"tech\" means software, AI and gaming briefs only",
"grounds": "assets/backgrounds/index.json ids the hero may stand on, as { id, only?, dark? }: only \"tech\" for software, AI and gaming briefs only; dark true for a dark page only",
"effects": "assets/effects ids the hero's visual may carry, as { id, as? }: every effect is for tech, software, AI and gaming briefs only, never a place or a local business; 'as' says where it goes when that is not the visual itself",
@@ -58,7 +58,7 @@
"text": "--hgap 24: h1 32/37 on at most 3 lines from y 373, sub 18/28 (the opening days and time), one full-width 48px button; ends by 844 - 24 - the bar",
"measured": "hero 844/844, h1 32px on 2 lines from y 373"
},
"moment": "none: the hero carries no move, nothing moves by itself (a place is kind other)",
"moment": "none: the entrance only, then nothing moves by itself (a place is kind other); the page's moments are dealt apart",
"moments": [],
"grounds": [
{
@@ -83,7 +83,6 @@
"demo": "hero-split.html",
"job": "Text left, the real thing right, both whole on the first screen: a photograph of the product in use, the place or the people; a phone or one window for software.",
"types": {
"product": 3,
"service": 3,
"software": 2
},
@@ -152,7 +151,7 @@
"name": "Full-bleed photograph, the words on its calm side",
"from": "Aman, Ace Hotel, Le Labo, Cowboy, x.ai's film section",
"demo": "hero-photo.html",
"job": "A place or a product with one strong photograph that has a calm area for the words: sky, a wall, cloud, shadow. Never for a software product (its own UI leads); a game with key art is dealt it.",
"job": "A place or a product with one strong photograph that has a calm area for the words: sky, a wall, cloud, shadow. Never for a software product (its own UI leads); a game with key art is dealt it, and a game without art files is printed it (--has setting): the game's name as the h1, set large in the box or straight on a dark calm part of a stand-in photograph of its setting (photos.mjs search), never an effect in its place.",
"types": {
"product": 2,
"service": 2,
@@ -220,7 +219,7 @@
"name": "Product stage with one object",
"from": "Figma's hero row, Apple product pages, Cowboy, Teenage Engineering",
"demo": "hero-product.html",
"job": "The product itself, big, under one head row: the headline left, the lead and the buttons right. A render or a cut-out stands on the page's own ground across the wrap (no box, no gradient, no radius), its widest view (a bike's side) spanning 80% or more of the wrap at 1440; a product photograph runs edge to edge down to the fold, cropped so the product spans 80% or more of the wrap. Hotspots name its parts. A product, hardware or a game without photographs or art: its product render (assets/objects/product-render), a still; a game puts its name in the h1 (or its wordmark above it), the launch date or status in the lead, and the stage holds its hero object or a scene of its world composed from its own objects (a print or scene keeps the lit stage), an effect allowed only behind it.",
"job": "The product itself, big, under one head row: the headline left, the lead and the buttons right. A render or a cut-out stands on the page's own ground across the wrap (no box, no gradient, no radius), its widest view (a bike's side) spanning 80% or more of the wrap at 1440; a product photograph runs edge to edge down to the fold, cropped so the product spans 80% or more of the wrap. Nothing on the product: no hotspots by default (the parts are named in the sections below). A product or hardware without photographs of its own: a stand-in photograph of the same kind of thing (photos.mjs search), listed in the report; its product render (assets/objects/product-render), a still, only when the brief asks for 3D or a render. A game puts its name in the h1 (or its wordmark above it), the launch date or status in the lead, and the stage holds its key art (a rendered hero object or world only when the brief asks for 3D; a print or scene keeps the lit stage), an effect allowed only behind it.",
"types": {
"product": 3,
"software": 1,
@@ -250,16 +249,16 @@
"hero": "1440x900",
"head": "h1 72 on 2 lines from y 131; lead 20/30 on 3 lines, 411 wide; buttons 44; the head ends at y 278",
"stage": "1280x566 at y 310, the product whole on the page ground: the city bike's side still 1046x554 (81.7% of the wrap), its three-quarter 818x554 (63.9%, under 8 columns: the hero takes the widest view)",
"spots": "one to three 32px discs on the object, each opening a 230px spec card on hover, focus or tap",
"spots": "none by default; only when the brief asks for the parts to be pointed out on the product: one to three 32px discs (layout.js .ly-spot), each opening a 230px spec card on hover, focus or tap",
"measured": "the city bike's side still: hero 900/900, stage 1280x566 at y 310, product 1046x554 at x 197 (81.7%); 1024x768 744x394 of 864 (86.1%); 768x1024 578 of 672 (86.0%); 390x844 294 of 342 (86.0%)"
},
"at390": {
"hero": "844: one screen",
"stage": "a render: 342 wide and what the head leaves (394 at 390x844), the product 86% of the wrap (294 wide), centred in it; a photograph: the screen width at its own ratio (390x293)",
"spots": "a tap opens the card under its disc, centred on it and kept 16px inside the stage",
"spots": "none by default; when asked for, a tap opens the card under its disc, centred on it and kept 16px inside the stage",
"measured": "hero 844/844, h1 40px on 2 lines from y 96, the stage 342x394 at y 426, the bike 294x156"
},
"moment": "the hotspots (layout.js .ly-spot); a render is a still once make-posters.mjs has made it (live only with data-product-mode=\"live\", with its pause button)",
"moment": "none: the product is the hero (hotspots only when the brief asks for them); a render is a still once make-posters.mjs has made it (live only with data-product-mode=\"live\", with its pause button)",
"moments": [
{
"id": "cursor-spotlight",
@@ -471,7 +470,6 @@
"types": {
"service": 2,
"software": 2,
"product": 1,
"gaming": 0
},
"needs": [
@@ -641,8 +639,7 @@
"types": {
"service": 3,
"software": 2,
"gaming": 0,
"product": 2
"gaming": 0
},
"needs": [
"ground"
@@ -755,8 +752,7 @@
"demo": "hero-triptych.html",
"job": "Three columns: the headline on the left, a collage of three to five pieces in the middle that changes set every 2.8 s, one large button on the right line. For a product with many outputs: a design tool, a menu or print tool, a shop with several lines.",
"types": {
"software": 2,
"product": 1
"software": 2
},
"needs": [
"ui|photo"
@@ -858,8 +854,8 @@
},
"at390": {
"hero": "844: exactly one screen",
"frame": "342 x what the words leave (313 here, at least 280), fading from 82%",
"window": "the frame's width less 32, 16 in, its list column and the bar's right label hidden; the exchange ends about 270px in",
"frame": "342 x what the words leave (313 here, at least 240, so a three-line headline still ends on the first screen), fading over its last 28px",
"window": "the frame's width less 32, 16 in, its list column, the bar's right label and the tool line hidden; the question and the answer end about 230px in, above the fade (its last 28px)",
"measured": "hero 844/844, h1 42px on 2 lines from y 144, the visual 342x313 at y 507"
},
"moment": "a loop inside the window: the question stays, the tool line, the answer and the actions arrive about 0.9 s apart, hold 2.4 s, play again; the ground (painterly-landscape or the lit ground) is drawn once and held",
@@ -18,8 +18,10 @@
line, and the composition is not centred.
screen-short a section is shorter than one screen (the fixed header lies over its top).
screen-height a section is not exactly one or two screens tall (at 1280 and wider, and on a
phone; from 640 to 1279 a section may grow, but never past two screens).
sparse a section's content fills under 50% of it (the aim is 55%).
phone; from 640 to 1279 a section may grow, but never past two screens). A live
photo-chapters section (.is-live) is a whole number of screens, up to four.
sparse a section's content fills under 50% of it (the aim is 55%); a dealt statement
moment's own section (.statement holding a data-moment, or any section holding word-highlight) is type by design.
neighbour with a section framed as the judge frames it (its top at the top of the
screen, and again one screen down when it is two screens tall), ink of
another section shows.
@@ -171,16 +173,29 @@ window.lyCheck = function lyCheck(opts = {}) {
const r = doc(s.getBoundingClientRect());
const ink = inkOf(s);
const cTop = ink.length ? Math.min(...ink.map(i => i.top)) : r.top, cBot = ink.length ? Math.max(...ink.map(i => i.bottom)) : r.bottom;
return { el: s, r, ink, cTop, cBot, bg: bgOf(s), free: s.matches('[data-screen="free"]') };
// a live photo-chapters section is (hold + 1) screens by design, three or four (moments.md): whole screens, up to four
const chapters = s.matches('[data-moment="photo-chapters"].is-live') || !!s.querySelector('[data-moment="photo-chapters"].is-live');
// a dealt statement moment's own section (one statement alone in its screen, sections.md) is type on its ground by
// design, as the checker reads it: never sparse
const stmt = (s.matches('.statement, [class*="section--statement"], [data-statement]') && (s.matches('[data-moment]') || !!s.querySelector('[data-moment]')))
|| s.matches('[data-moment="word-highlight"]') || !!s.querySelector('[data-moment="word-highlight"]');
return { el: s, r, ink, cTop, cBot, bg: bgOf(s), free: s.matches('[data-screen="free"]'), most: chapters ? 4 : 2, stmt };
});
const pageBg = getComputedStyle(document.body).backgroundColor;
const footer = document.querySelector('body > footer, footer.footer');
info.forEach((s, i) => {
const h = s.r.bottom - s.r.top;
if (!s.free && h < screen - tol) add('screen-short', name(s.el), `${Math.round(h)}px tall; one screen under the nav is ${Math.round(screen)}px`);
const exact = vw >= 1280 || vw < 640, off = Math.min(Math.abs(h - screen), Math.abs(h - 2 * screen));
if (!s.free && i > 0 && ((exact && off > tol) || h > 2 * screen + tol)) add('screen-height', name(s.el), `${Math.round(h)}px, ${(h / screen).toFixed(2)} screens; a section is exactly one or two (--h)`);
if (!s.free && i > 0 && s.ink.length && (s.cBot - s.cTop) / h < 0.5) add('sparse', name(s.el), `its content fills ${Math.round(100 * (s.cBot - s.cTop) / h)}% of its screen; merge it or give it its visual (0.55 is the aim)`);
// a phone section is at least its screens and grows with what it holds (system.css): taller than a screen and
// filled by its content (0.7 of it or more, the rest is its padding) it is not off its count; one that grows
// past two screens still is (--hm: 2 and two groups, or less content)
const grown = vw < 640 && h > screen + tol && h <= 2 * screen + tol && (s.cBot - s.cTop) / h >= 0.7;
// a live photo-chapters section is sized in whole viewports by its own CSS, whatever the nav, so it is counted in
// viewports too (under a sticky nav a screen is the viewport less the nav)
const u = s.most > 2 ? vh : screen;
const exact = (vw >= 1280 || vw < 640) && !grown, off = Math.min(...Array.from({ length: s.most }, (_, k) => Math.abs(h - (k + 1) * u)));
if (!s.free && i > 0 && ((exact && off > tol) || h > s.most * u + tol)) add('screen-height', name(s.el), `${Math.round(h)}px, ${(h / u).toFixed(2)} screens; ${s.most > 2 ? 'a live photo-chapters section is a whole number of screens, four at most' : `a section is exactly one or two (--h)${vw < 640 ? ', or on a phone at least one that its content fills' : ''}`}`);
if (!s.free && !s.stmt && i > 0 && s.ink.length && (s.cBot - s.cTop) / h < 0.5) add('sparse', name(s.el), `its content fills ${Math.round(100 * (s.cBot - s.cTop) / h)}% of its screen; merge it or give it its visual (0.55 is the aim)`);
const ch = s.cBot - s.cTop;
// frame the section as the judge does: its top at the top of the screen (the hero at scroll 0), and a
// second frame one screen down when it is two screens tall; with a header that is not fixed, its top
+34 -5
View File
@@ -177,7 +177,8 @@ html:is([data-buttons="arrow"], [data-buttons="block"]) .hero--photo[data-box="n
/* 0. hero--place: the only place hero, never dealt (local-business Recipe 3; ST-53, PH-47, PH-81).
The client's photograph full-bleed under the header (--photo: 480 at 1440x900, y 72-552, at most
53% of the screen; a phone's 4:3 crop, 390x292), nothing on it. On the ground below, --hgap
53% of the screen; on a phone a 4:3 crop, 390x292, or with data-phone-fill grown to what the words leave, up to
4:5), nothing on it. On the ground below, --hgap
(40, 24 on a phone) under it: the h1 on columns 1-6 (48/53, 32/37 on a phone), and from the
split line the sub-line (the kind of place, where, today's hours) and one filled 48px button.
html[data-type="place"] .hero--photo is drawn the same way (the older markup).
@@ -229,6 +230,19 @@ html:is([data-buttons="arrow"], [data-buttons="block"]) .hero--photo[data-box="n
:is(.hero--place, :root[data-type="place"] .hero--photo) .hero__text > h1 { line-height: 1.156; }
:is(.hero--place, :root[data-type="place"] .hero--photo) .hero__lead { font-size: 18px; line-height: 1.556; }
:is(.hero--place, :root[data-type="place"] .hero--photo) .hero__text > .btn-row { margin-top: 0; }
/* a photograph whose phone source is tall enough (data-phone-fill: photos.mjs prints it with its 4:5 phone crop,
or with a phone sizes term that covers a 4:5 box) takes what the words leave of the first screen, from --photo
(390x292 at 390) to 4:5 (390x488), so the screen ends on the button, never on an empty band under it (a 390x844
screen: about 390x480). Without the attribute it keeps --photo: a 4:3 crop shown taller is blown up. The section's
grid has two rows, the photograph's (it is placed in that row, still out of the flow: both its lines named, as an
absolute box's auto line is the section's edge) and the words' */
main > :is(.hero--place, :root[data-type="place"] .hero--photo):has(> .hero__bg[data-phone-fill]):has(> .wrap) {
padding-top: var(--nav); grid-template-rows: minmax(calc(var(--photo) + var(--hgap)), 1fr) auto;
}
main > :is(.hero--place, :root[data-type="place"] .hero--photo):has(> .wrap) > .hero__bg[data-phone-fill] {
grid-row: 1 / 2; grid-column: 1 / -1; top: 0; bottom: auto; height: min(calc(100% - var(--hgap)), 125vw);
}
main > :is(.hero--place, :root[data-type="place"] .hero--photo):has(> .hero__bg[data-phone-fill]):has(> .wrap) > .wrap { grid-row: 2; }
}
/* 3. hero--product: the head row (h1 and line left, the buttons right on the line's baseline), then
@@ -254,7 +268,7 @@ html:is([data-buttons="arrow"], [data-buttons="block"]) .hero--photo[data-box="n
- a product photograph (any other img): edge to edge across the screen, square corners, no ground;
crop it (--ly-pos) so the product spans 80% or more of the wrap.
<div class="hero__stage"><div class="frame" data-product="…/specs/<id>.json" data-product-place="stage"
data-product-view="side"></div> one to three .ly-spot </div> */
data-product-view="side"></div></div> (.ly-spot hotspots only when the brief asks for them) */
.hero--product .hero__stage:is(:has(> [data-product]), :has(> img.is-cutout)) {
margin-top: var(--s-6); margin-bottom: calc(var(--s-5) - var(--hpadb, 64px));
border-radius: 0; background: none; overflow: visible;
@@ -468,7 +482,10 @@ h1.ly-locked { white-space: nowrap; text-wrap: nowrap; }
.hero--triptych .hero__cta .btn { height: 56px; font-size: var(--t-body); }
}
.ly-collage { position: relative; height: 100%; min-height: 0; }
.ly-collage__set { position: absolute; inset: 0; transition: opacity .7s var(--ease, ease); }
/* the crossfade only once the sets start to cycle (.is-cycling, layout.js): a set that changes as the
page's styles arrive takes its place at once, never fades across the first paint */
.ly-collage__set { position: absolute; inset: 0; }
.ly-collage.is-cycling > .ly-collage__set { transition: opacity .7s var(--ease, ease); }
.ly-collage__set:not(.is-on) { opacity: 0; pointer-events: none; }
/* a copy of the markup that lost its .is-on: the first set shows rather than an empty hole */
.ly-collage:not(:has(> .ly-collage__set.is-on)) > .ly-collage__set:first-child { opacity: 1; pointer-events: auto; }
@@ -503,7 +520,9 @@ h1.ly-locked { white-space: nowrap; text-wrap: nowrap; }
.ly-mark::before { left: 31%; } .ly-mark::after { left: 56%; }
@media (max-width: 639px) {
.hero--centred .btn-row { width: 100%; }
.hero--centred .hero__window { margin-top: var(--s-6); min-height: 280px; }
/* the frame can be 240 to 300px tall here, so its fade is the last 28px, not the last 18% */
.hero--centred .hero__window { margin-top: var(--s-6); min-height: 240px;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent); }
.hero--centred .hero__window > .ly-piece { top: var(--s-4); width: calc(100% - 2 * var(--s-4)); left: var(--s-4); transform: none; }
}
@@ -521,5 +540,15 @@ h1.ly-locked { white-space: nowrap; text-wrap: nowrap; }
the hero is set on its first frame on every kind: no entrance fade, rise or opening (MO-62) */
@media (prefers-reduced-motion: reduce) {
.ly-live::before { animation: none; }
.ly-word > span, .ly-collage__set, .ly-letters .ly-l { transition: none; }
.ly-word > span, .ly-collage__set, .ly-collage.is-cycling > .ly-collage__set, .ly-letters .ly-l { transition: none; }
}
/* a short phone (320x640: under 700px tall): the first screen still ends whole with the hero's button on it.
The photograph over the words takes less of it, the facts under the words go (the lead carries the hard
fact), and a collage keeps less height */
@media (max-width: 639px) and (max-height: 700px) {
:root { --hgap: 24px; }
.hero--photo, .hero--place, :root[data-type="place"] .hero--photo { --photo: min(26svh, 48vw); }
main > .hero .hero__facts { display: none; }
.hero--triptych .ly-collage { min-height: 180px; margin-block: var(--s-5); }
}
+1 -1
View File
@@ -192,7 +192,7 @@ function collage(box) {
if (sets.length < 2 || phone() || other()) return;
box.setAttribute('data-ui-pause-host', '');
ui.loop(box, async api => {
for (let n = 1; n <= sets.length; n++) { await api.wait(2800); show(n % sets.length); await api.wait(700); }
for (let n = 1; n <= sets.length; n++) { await api.wait(2800); box.classList.add('is-cycling'); show(n % sets.length); await api.wait(700); }
}, { still: () => show(0), rest: 600 });
}
@@ -3,7 +3,7 @@
.demo-nav { position: sticky; top: 0; z-index: 40; height: var(--nav-h); border-bottom: 1px solid var(--line);
background: color-mix(in oklab, var(--bg) 96%, transparent); }
.demo-nav .container { height: 100%; display: flex; align-items: center; gap: var(--s-6); }
.demo-nav__logo { font: 500 var(--t-lead)/1 var(--font-display); letter-spacing: -0.02em; flex: none; }
.demo-nav__logo { display: inline-flex; align-items: center; min-height: 44px; font: 500 var(--t-lead)/1 var(--font-display); letter-spacing: -0.02em; flex: none; }
.demo-nav__links { display: flex; gap: var(--s-1); flex: 1; }
.demo-nav__links a { font-size: var(--t-small); font-weight: 500; padding: 0 var(--s-3); height: 36px; display: inline-flex; align-items: center; border-radius: var(--r-sm); transition: background-color var(--dur) var(--ease); }
.demo-nav__links a:hover { background: color-mix(in oklab, var(--ink) 6%, transparent); }
@@ -51,6 +51,19 @@ main > .section--hero > .container.demo-split { align-content: center; }
.demo-split > .demo-photo { aspect-ratio: auto !important; width: 100%; height: 100%; max-height: min(100%, 62svh); min-height: 0; }
.demo-split > .frame { max-height: 100%; min-height: 0; }
.demo-statement { align-content: start; }
/* a phone: the section grows with its split (system.css), so the split keeps its content height as its least */
@media (max-width: 639px) { main > :is(.section, .section--hero) > .container.demo-split { min-height: auto; } }
/* a phone hero: its picture or window takes what the first screen leaves under the words (size containment keeps
its own height out of the hero's, so it never pushes the hero past the screen), and at least 220px, so on a short
phone the hero grows by what the words need and no more */
@media (max-width: 639px) {
main > .section--hero > .container.demo-split { grid-template-rows: auto minmax(0, 1fr); align-content: stretch; }
main > .section--hero > .container.demo-split > .demo-photo { height: 100%; max-height: none; min-height: 220px; contain: size; }
main > .section--hero > .container:not(.demo-split) > :is(.hero-photo, .frame) { flex: 1 1 0; aspect-ratio: auto; min-height: 220px; contain: size; }
main > .section--hero > .container:not(.demo-split) > .frame { align-items: flex-start; }
/* the window keeps the rows that fit whole: one, under its title (a row cut by the frame reads as a fault) */
main > .section--hero > .container:not(.demo-split) > .frame .dw__row:nth-of-type(n+2) { display: none; }
}
/* a drawn window in a hero frame beside the text (1024-1279): the side list goes, so the rows keep one line
each and the window stays shorter than its frame (it was cut at the frame's top and foot at 1024x768) */
@@ -0,0 +1,151 @@
<!doctype html>
<!-- moments in a page: pinned-chapters and picture-develops (its film look, the look of a place, product or service
page) dealt onto a service page built like assets/starter.html (the nav, the dealt split hero, sections on
.wrap, footer.closing), the way a builder mounts them. A moment's own demo.html shows it alone; copy the section
from here. in-page.html does the same for a place with photo-chapters and word-highlight. type: service. -->
<html lang="en" data-type="service" data-radius="soft" data-buttons="solid" data-motion>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Northbound: guided bike rides from Whitby</title>
<meta name="description" content="Three-day guided bike rides over the moor and along the coast, May to September. Eight riders, a guide, a mechanic and a van for the bags. From £640 a person.">
<meta name="site-kind" content="other"><meta name="page-type" content="service">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,400..500,0..100,0..1&family=Figtree:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../system.css">
<link rel="stylesheet" href="../../ui/cards.css">
<link rel="stylesheet" href="../../motion/motion.css">
<link rel="stylesheet" href="../../layout/layout.css">
<style>
:root {
--p-bg: #f4f3ee; --p-surface: #e8e6dd; --p-ink: #1b1d1c; --p-muted: #575a55;
--p-accent: #31594a; --p-accent-ink: #ffffff; --p-action: #1b1d1c; --p-action-ink: #f4f3ee;
--p-inv-bg: #1b1d1c; --p-inv-surface: #262927; --p-inv-ink: #f4f3ee; --p-inv-muted: #b1b3ac;
--p-inv-accent: #8cc0a8; --p-inv-accent-ink: #1b1d1c; --p-inv-action: #f4f3ee; --p-inv-action-ink: #1b1d1c;
--font-display: "Fraunces", Georgia, serif; --font-text: "Figtree", system-ui, sans-serif;
--display-weight: 400; --display-tracking: -0.02em; --display-tracking-xl: -0.03em; --display-axes: 'SOFT' 70, 'WONK' 0; --ly-scale: 1;
}
.rows .rows__name small { display: block; font-weight: 400; color: var(--muted); font-size: var(--t-small); }
/* picture-develops: the section's one photograph, on the split's right column, as tall as the text beside it */
.develop { margin: 0; border-radius: var(--r-lg); overflow: hidden; height: min(560px, var(--ly-fit)); }
.develop > img { display: block; width: 100%; height: 100%; object-fit: cover; }
@media (max-width: 1023px) { .develop { height: auto; aspect-ratio: 4 / 3; margin-top: var(--s-7); } }
</style>
<script>document.documentElement.classList.add('m-enter');setTimeout(()=>document.documentElement.classList.add('m-enter-done'),700)</script>
<script>document.documentElement.classList.add('mo-js');setTimeout(()=>document.documentElement.classList.add('mo-safe'),2500)</script>
<link rel="modulepreload" href="../moments.js"><link rel="modulepreload" href="../lib.js">
<link rel="modulepreload" href="../pinned-chapters/moment.js"><link rel="modulepreload" href="../picture-develops/moment.js">
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="skip-link" href="#top">Skip to content</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northbound</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item"><a class="nav__link" href="#ride">The ride</a></div>
<div class="nav__item"><a class="nav__link" href="#includes">Dates and prices</a></div>
<div class="nav__item"><a class="nav__link" href="#contact">Contact</a></div>
</nav>
<div class="nav__actions">
<a class="btn btn--primary" href="mailto:ride@northbound.example">Book a place</a>
</div>
<button class="nav__toggle" type="button" aria-expanded="false" aria-label="Menu"><svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2 5h14M2 13h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
</div>
</header>
<main id="top">
<section class="hero hero--split" data-enter aria-labelledby="hero-h">
<div class="wrap"><div class="hero-text grid hero__grid">
<div class="hero__text span-5">
<p class="hero__kicker"><span class="dot" aria-hidden="true"></span>Eight rides, May to September</p>
<h1 id="hero-h">Guided bike rides over the moor and the coast</h1>
<p class="hero__lead">Three days from Whitby, 140 km, eight riders, a guide and a mechanic. A van carries the bags. From £640 a person.</p>
<div class="btn-row"><a class="btn btn--primary" href="mailto:ride@northbound.example">Book a place</a><a class="btn btn--secondary" href="#ride">See the three days</a></div>
</div>
<div class="hero__media span-6 start-7">
<img src="../../layout/img/photo-d534.jpg" alt="A forest lane under an arch of golden autumn beeches, with mist and leaves on the road" style="--ly-pos: 50% 60%" fetchpriority="high">
<div class="ly-float">
<div class="ly-float__row"><span>Next ride</span><span>3 places left</span></div>
<div class="ly-float__title">Friday 15 May, from Whitby</div>
</div>
</div>
</div></div>
</section>
<!-- pinned-chapters (references/moments.md): the section is the host, two screens tall (--h: 2); 2 to 4 steps,
a title, two lines and one picture each; moment.js holds it while the steps open one at a time -->
<section class="section" id="ride" style="--h: 2" data-moment="pinned-chapters" aria-labelledby="ride-h">
<div class="wrap mo-pc__grid">
<div class="mo-pc__text">
<h2 id="ride-h">How the three days go</h2>
<ol class="mo-pc__steps">
<li class="mo-pc__step">
<h3>Day one, 48 km: the moor</h3>
<p>The van takes the bags. You ride over the moor road with the guide ahead and coffee at the top.</p>
<figure class="mo-pc__visual"><img src="photo-702.jpg" alt="The moor road above the tree line" width="1800" height="1200"></figure>
</li>
<li class="mo-pc__step">
<h3>Day two, 62 km: the coast</h3>
<p>Along the water on a flat road, lunch at the harbour and the night in a guesthouse by the pier.</p>
<figure class="mo-pc__visual"><img src="photo-1049.jpg" alt="A rock off the coast road at low tide" width="1800" height="1200"></figure>
</li>
<li class="mo-pc__step">
<h3>Day three, 30 km: the dunes</h3>
<p>A short ride to the dunes, a swim if the weather holds, and the train back from the last station.</p>
<figure class="mo-pc__visual"><img src="photo-640.jpg" alt="Waves on the sand at the dunes" width="1800" height="1200"></figure>
</li>
</ol>
</div>
<div class="mo-pc__visuals frame"></div>
</div>
</section>
<section class="section" id="includes" aria-labelledby="includes-h">
<div class="wrap grid grid--top">
<div class="span-6">
<div class="section-head">
<h2 id="includes-h">What a ride includes</h2>
<p>Two nights in guesthouses, every breakfast and lunch, the van and a mechanic.</p>
</div>
<ul class="rows">
<li><span class="rows__name">Three days, two nights<small>Guesthouse rooms for two, or one at £90 more</small></span><span class="rows__value">£640</span></li>
<li><span class="rows__name">Bike hire<small>A steel touring bike with bags, fitted on the first morning</small></span><span class="rows__value">£120</span></li>
<li><span class="rows__name">Electric bike<small>Two in each group, ask when you book</small></span><span class="rows__value">£180</span></li>
<li><span class="rows__name">Group<small>Eight riders, one guide, one mechanic</small></span><span class="rows__value">8 places</span></li>
</ul>
<div class="btn-row"><a class="btn btn--primary" href="mailto:ride@northbound.example">Book a place</a></div>
</div>
<!-- picture-develops: one photograph with a clear subject, half the container wide or more; the film look
comes by itself on a place, product or service page (site-kind other) -->
<figure class="develop span-6 start-7" data-moment="picture-develops"><img src="../../layout/img/photo-d540.jpg" alt="A gravel road bending through an autumn forest under a grey sky" width="1280" height="880" loading="lazy"></figure>
</div>
</section>
</main>
<footer class="footer closing" id="contact" aria-labelledby="close-h">
<div class="wrap">
<div class="closing__cta close--type">
<div class="section-head"><h2 id="close-h">Book a place</h2><p class="t-lead">Eight rides, May to September. Write to us and we hold your place for a week.</p></div>
<a class="btn btn--primary btn--xl" href="mailto:ride@northbound.example">Book a place<svg class="m-arrow" viewBox="0 0 12 12" aria-hidden="true"><path class="m-arrow__stem" d="M1 6h8"/><path class="m-arrow__head" d="M4.5 2.5 8 6l-3.5 3.5"/></svg></a>
</div>
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-5">
<a class="nav__logo" href="#top"><span class="mark" data-mark="ring" aria-hidden="true"></span>Northbound</a>
<p class="footer__lede">Guided rides from Whitby, May to September.</p>
</div>
<div class="span-3 start-7 footer__col"><h4>Rides</h4><ul><li><a href="#ride">The three days</a></li><li><a href="#includes">Dates and prices</a></li></ul></div>
<div class="span-3 footer__col"><h4>Contact</h4><ul><li><a href="mailto:ride@northbound.example">Write to us</a></li><li>Replies within a day</li></ul></div>
</div>
<div class="footer__legal">
<span>&copy; 2026 Northbound Rides</span>
<details class="credits"><summary>Photographs by Dominik Schröder, Rosan Harmens and Sarah Bürvenich (Unsplash), Dietmar Rabich and Tony Webster (Wikimedia Commons)</summary><p>The moor road, the coast and the dunes on Unsplash (Unsplash License); the beech lane by Dietmar Rabich (CC BY-SA 4.0) and the forest road by Tony Webster (CC BY 2.0), via Wikimedia Commons.</p></details>
</div>
</div>
</div>
</footer>
<script type="module" src="../../system.js"></script>
<script type="module" src="../../motion/motion.js"></script>
<script type="module" src="../../layout/layout.js"></script>
</body>
</html>
@@ -0,0 +1,149 @@
<!doctype html>
<!-- moments in a page: the place moments photo-chapters and word-highlight dealt onto a page built like
assets/starter.html (the nav, the place hero, sections on .wrap, footer.closing), the way a builder mounts them.
A moment's own demo.html shows it alone; copy the section from here. in-page-service.html does the same for
picture-develops (the film look) and pinned-chapters. type: place. fonts: fraunces. -->
<html lang="en" data-type="place" data-radius="sharp" data-buttons="block" data-motion>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Harbour House: a guesthouse in Staithes</title>
<meta name="description" content="Six rooms above the harbour in Staithes, dinner on Friday and Saturday and bikes to borrow. Rooms for two from £120 a night.">
<meta name="site-kind" content="other"><meta name="page-type" content="place"><meta name="page-kind" content="place">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,400..500,0..100,0..1&family=Figtree:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../system.css">
<link rel="stylesheet" href="../../ui/cards.css">
<link rel="stylesheet" href="../../motion/motion.css">
<link rel="stylesheet" href="../../layout/layout.css">
<style>
:root {
--p-bg: #f6f3ec; --p-surface: #ebe5d8; --p-ink: #1d1f1e; --p-muted: #5a5d58;
--p-accent: #2f5d50; --p-accent-ink: #ffffff; --p-action: #1d1f1e; --p-action-ink: #f6f3ec;
--p-inv-bg: #1d1f1e; --p-inv-surface: #282b29; --p-inv-ink: #f6f3ec; --p-inv-muted: #b3b5ae;
--p-inv-accent: #8cc0a8; --p-inv-accent-ink: #1d1f1e; --p-inv-action: #f6f3ec; --p-inv-action-ink: #1d1f1e;
--font-display: "Fraunces", Georgia, serif; --font-text: "Figtree", system-ui, sans-serif;
--display-weight: 400; --display-tracking: -0.015em; --display-axes: 'SOFT' 70, 'WONK' 0;
}
.rows .rows__name small { display: block; font-weight: 400; color: var(--muted); font-size: var(--t-small); }
</style>
<script>document.documentElement.classList.add('m-enter');setTimeout(()=>document.documentElement.classList.add('m-enter-done'),700)</script>
<script>document.documentElement.classList.add('mo-js');setTimeout(()=>document.documentElement.classList.add('mo-safe'),2500)</script>
<link rel="modulepreload" href="../moments.js"><link rel="modulepreload" href="../lib.js">
<link rel="modulepreload" href="../photo-chapters/moment.js"><link rel="modulepreload" href="../word-highlight/moment.js">
</head>
<body>
<header class="nav">
<div class="wrap nav__bar">
<a class="skip-link" href="#top">Skip to content</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="arc" aria-hidden="true"></span>Harbour House</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item"><a class="nav__link" href="#rooms">Rooms</a></div>
<div class="nav__item"><a class="nav__link" href="#weekend">A weekend</a></div>
<div class="nav__item"><a class="nav__link" href="#house">The house</a></div>
<div class="nav__item"><a class="nav__link" href="#contact">Find us</a></div>
</nav>
<div class="nav__actions">
<a class="btn btn--primary" href="mailto:stay@harbourhouse.example">Book a room</a>
</div>
<button class="nav__toggle" type="button" aria-expanded="false" aria-label="Menu"><svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2 5h14M2 13h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
</div>
</header>
<main id="top">
<section class="hero hero--place" data-enter aria-labelledby="hero-h">
<img class="hero__bg" data-phone-fill src="photo-1049.jpg" alt="A rock in the bay at low tide, seen from the harbour" style="--ly-pos: 50% 55%" fetchpriority="high">
<div class="wrap"><div class="hero-text">
<div class="hero__text">
<h1 id="hero-h">A guesthouse above the harbour in Staithes</h1>
<p class="hero__lead">Six rooms by the water, dinner at eight on Friday and Saturday. Rooms for two from £120 a night.</p>
<div class="btn-row"><a class="btn btn--primary" href="#rooms">See the rooms</a></div>
</div>
</div></div>
</section>
<section class="section" id="rooms" aria-labelledby="rooms-h">
<div class="wrap grid grid--top">
<div class="span-6">
<div class="section-head">
<h2 id="rooms-h">Six rooms, four on the water</h2>
<p>Breakfast until ten is in every price.</p>
</div>
<ul class="rows">
<li><span class="rows__name">Room one<small>Double, on the garden side</small></span><span class="rows__value">£120</span></li>
<li><span class="rows__name">Room four<small>Double, a window seat over the bay</small></span><span class="rows__value">£150</span></li>
<li><span class="rows__name">Room five<small>Twin or double, sea view</small></span><span class="rows__value">£150</span></li>
<li><span class="rows__name">The loft<small>Double and a sofa bed, two windows on the water</small></span><span class="rows__value">£170</span></li>
</ul>
</div>
<figure class="pl-photo pl-photo--end span-6 start-7" style="--pl-r: .8"><img src="../../layout/img/photo-d88.jpg" alt="A cream lamp on a side table in the low evening sun of room four" loading="lazy"></figure>
</div>
</section>
<!-- photo-chapters (references/moments.md): the section is the host; its h2 and lead, then 3 to 5 chapters,
one photograph each, none used elsewhere on the page. moment.js makes the section (hold + 1) screens tall -->
<section class="section" id="weekend" data-moment="photo-chapters" aria-labelledby="weekend-h">
<div class="wrap mo-ph">
<div class="mo-ph__head"><h2 id="weekend-h">A weekend from our door</h2><p>Three days that start and end at the house, with bikes, a packed lunch and dinner waiting.</p></div>
<ol class="mo-ph__list">
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="photo-702.jpg" alt="The moor road above the tree line" width="1800" height="1200"></figure>
<h3>Saturday: over the moor</h3>
<p>Borrow two bikes after breakfast and take the moor road to the top, 40 km with a tea room halfway.</p>
</li>
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="../../layout/img/photo-d540.jpg" alt="A gravel road bending through an autumn forest under a grey sky" width="1280" height="880"></figure>
<h3>Sunday: the forest loop</h3>
<p>A packed lunch from the kitchen and 25 km of gravel through the woods behind the village.</p>
</li>
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="photo-640.jpg" alt="Waves on the sand at the dunes" width="1800" height="1200"></figure>
<h3>Monday: the long beach</h3>
<p>A slow breakfast, the cliff path to the dunes and a swim if the sea allows. Checkout is at noon.</p>
</li>
</ol>
</div>
</section>
<!-- word-highlight: one statement of 12 to 40 words in its own section, one strong word in <strong> -->
<section class="section statement" id="house" data-moment="word-highlight" aria-label="The house">
<div class="wrap">
<p class="t-label">The house</p>
<p class="mo-wh__text">We are a family of four with six rooms, a kitchen and a shed of bikes. Dinner is one menu of what the boats bring in, and we will tell you where <strong>the tide</strong> is before you ask.</p>
</div>
</section>
</main>
<footer class="footer closing footer--visit" id="contact" aria-labelledby="close-h">
<div class="wrap">
<div class="closing__cta close--type">
<div class="section-head"><h2 id="close-h">Book a room</h2><p class="t-lead">Write to us; we answer the same day.</p></div>
<a class="btn btn--primary btn--xl" href="mailto:stay@harbourhouse.example">Book a room<svg class="m-arrow" viewBox="0 0 12 12" aria-hidden="true"><path class="m-arrow__stem" d="M1 6h8"/><path class="m-arrow__head" d="M4.5 2.5 8 6l-3.5 3.5"/></svg></a>
</div>
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-4">
<address class="footer__address">High Street, Staithes</address>
<p><a class="link" href="https://www.openstreetmap.org/search?query=High%20Street%20Staithes">Get directions</a></p>
</div>
<div class="span-6 start-7 stack-4">
<h4>Arriving</h4>
<dl class="hours"><dt>Check-in</dt><dd>15:00 to 20:00</dd><dt>Checkout</dt><dd>By 12:00</dd><dt>Dinner</dt><dd>Friday and Saturday at 20:00</dd></dl>
</div>
</div>
<div class="footer__legal">
<span>&copy; 2026 Harbour House</span>
<details class="credits"><summary>Photographs by Rosan Harmens, Dominik Schröder and Sarah Bürvenich (Unsplash), LoMit and Tony Webster (Wikimedia Commons)</summary><p>The bay, the moor road and the beach on Unsplash (Unsplash License); the lamp by LoMit (CC BY-SA 4.0) and the forest road by Tony Webster (CC BY 2.0), via Wikimedia Commons.</p></details>
</div>
</div>
</div>
</footer>
<nav class="actionbar" aria-label="Quick actions">
<a class="btn btn--secondary" href="https://www.openstreetmap.org/search?query=High%20Street%20Staithes">Directions</a>
<a class="btn btn--primary" href="mailto:stay@harbourhouse.example">Book a room</a>
</nav>
<script type="module" src="../../system.js"></script>
<script type="module" src="../../motion/motion.js"></script>
<script type="module" src="../../layout/layout.js"></script>
</body>
</html>
@@ -14,13 +14,7 @@
<style>
:root { --p-bg: #ffffff; --p-surface: #f2f4f6; --p-ink: #14181c; --p-muted: #5f6770; --p-accent: #1f6f8b; --p-accent-ink: #ffffff; --p-action: #14181c; --p-action-ink: #ffffff;
--font-display: "Schibsted Grotesk", system-ui, sans-serif; --font-text: "Hanken Grotesk", system-ui, sans-serif; --display-weight: 400; }
.faq { display: grid; gap: 0; max-width: 760px; }
.faq details { border-top: 1px solid var(--line); padding: var(--s-4) 0; }
.faq details:last-child { border-bottom: 1px solid var(--line); }
.faq summary { cursor: pointer; font-weight: 500; font-size: var(--t-lead); list-style: none; display: flex; justify-content: space-between; gap: var(--s-4); }
.faq summary::after { content: "+"; color: var(--muted); }
.faq details[open] summary::after { content: "–"; }
.faq details p { margin-top: var(--s-3); color: var(--ink); max-width: 60ch; }
.faq { max-width: 760px; }
.demo-footer--dark { background: #14181c; color: #9aa3ad; border: 0; }
</style>
</head>
@@ -42,10 +36,10 @@
<div class="container">
<div class="section-head"><h2>Questions before you book</h2></div>
<div class="faq">
<details open><summary>How fit do I need to be?</summary><p>You ride 30 to 62 km a day at a conversational pace, with one climb on day one. Anyone who rides a few hours a week will be fine.</p></details>
<details><summary>Can I bring my own bike?</summary><p>Yes. The van carries bags only, so the bike rides every day. We also hire road and gravel bikes for €45 a day.</p></details>
<details><summary>What if the weather turns?</summary><p>We ride in rain. In a storm the day's stage goes by van and the guesthouse keeps the room.</p></details>
<details><summary>How do I get to the start?</summary><p>The ride starts at the station in Bergen at 10:00 on the first day and ends at a station on the same line.</p></details>
<div class="faq__item"><h3>How fit do I need to be?</h3><p>You ride 30 to 62 km a day at a conversational pace, with one climb on day one. Anyone who rides a few hours a week will be fine.</p></div>
<div class="faq__item"><h3>Can I bring my own bike?</h3><p>Yes. The van carries bags only, so the bike rides every day. We also hire road and gravel bikes for €45 a day.</p></div>
<div class="faq__item"><h3>What if the weather turns?</h3><p>We ride in rain. In a storm the day's stage goes by van and the guesthouse keeps the room.</p></div>
<div class="faq__item"><h3>How do I get to the start?</h3><p>The ride starts at the station in Bergen at 10:00 on the first day and ends at a station on the same line.</p></div>
</div>
</div>
</section>
@@ -53,13 +53,13 @@
<div class="container grid">
<figure class="span-6">
<blockquote>“We stopped chasing payments. The invoice goes out with the card link and most are paid the same week.”</blockquote>
<figcaption><b>Mara Lindqvist</b><span class="muted">Founder, Studio Verde</span></figcaption>
<figcaption><b>Hedda Strøm</b><span class="muted">Founder, Studio Verde</span></figcaption>
</figure>
<div class="span-5 start-8 stats" data-moment="counter-rolls">
<div class="stat"><p class="t-stat">64%</p><p class="t-small muted">of invoices paid within 48 hours</p></div>
<div class="stat"><p class="t-stat">98%</p><p class="t-small muted">of card payments booked the same day</p></div>
<div class="stat"><p class="t-stat">€310M</p><p class="t-small muted">collected through card links this year</p></div>
<div class="stat"><p class="t-stat">11 min</p><p class="t-small muted">median time from sent to paid</p></div>
<div class="stat"><p class="t-stat">€0</p><p class="t-small muted">a month for up to five invoices</p></div>
<div class="stat"><p class="t-stat">€12</p><p class="t-small muted">a month for unlimited invoices and clients</p></div>
<div class="stat"><p class="t-stat">38</p><p class="t-small muted">currencies an invoice can be sent in</p></div>
<div class="stat"><p class="t-stat">2 days</p><p class="t-small muted">from a card payment to your bank account</p></div>
</div>
</div>
</section>
@@ -19,7 +19,7 @@
"maxPerPage": 1,
"weight": 1,
"needs": {
"words": "2 to 4 real figures with a short caption each"
"words": "2 to 4 figures the brief gives (a price, a count, a time), each with a short caption; never an invented outcome"
},
"avoidWith": [],
"states": {
@@ -1,12 +1,13 @@
/*
counter-rolls - the digits of one to four proof figures roll once, like an odometer, when the row
first comes on screen, and stop on the real figure. The figure is written in the HTML as its final
first comes on screen, and stop on the real figure (a price, a count, a time the brief gives; never an
invented outcome like "98% satisfied"). The figure is written in the HTML as its final
value (without script it stands); the script builds a strip for every digit that starts and ends on
that digit, so at rest (before the row enters, in a full-page shot, after the roll) the row reads as
written, never as zeros. The right-most column turns the most (one turn more per column).
<div class="grid" data-moment="counter-rolls">
<div class="span-3"><p class="t-stat">64%</p><p class="t-small muted">of the Fortune 500 use it</p></div>
<div class="span-3"><p class="t-stat">€12</p><p class="t-small muted">a month for unlimited invoices</p></div>
…
</div>
@@ -17,7 +17,7 @@
.hero-centre { text-align: center; max-width: 820px; margin-inline: auto; display: grid; gap: var(--s-5); justify-items: center; }
.hero-centre p { max-width: 54ch; font-size: var(--t-lead); }
.hero-window { margin-top: var(--s-6); --ratio: 0px; flex: 1 1 0; min-height: 0; overflow: hidden; }
@media (max-width: 639px) { .hero-window { --ratio: 0px; } }
@media (max-width: 639px) { .hero-window { --ratio: 0px; min-height: 200px; } } /* a short phone (320x640): the hero grows rather than leave the window a sliver */
</style>
</head>
<body>
@@ -1,17 +1,15 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Moments</title>
<style>body{margin:0;font:16px/1.5 system-ui,sans-serif;color:#111;background:#fff;padding:48px 24px}h1{font-size:28px;font-weight:500;margin:0 0 8px}p.lead{color:#555;margin:0 0 32px;max-width:60ch}ul{list-style:none;padding:0;margin:0;display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}li a{display:block;padding:16px 20px;border:1px solid #e5e5e5;border-radius:12px;color:inherit;text-decoration:none}li a:hover{border-color:#999}li b{font-weight:500}li span{display:block;color:#666;font-size:13px;margin:2px 0 6px}li p{margin:0;color:#333;font-size:14px}li p.ph{margin-top:6px;color:#666;font-size:13px}</style></head>
<body><h1>Moments</h1><p class="lead">15 creative moments for tech, gaming and software pages, one or two a page, never on a place page (references/moments.md). Each link is a full demo page; the moment is marked data-moment in its source.</p><ul><li><a href="counter-rolls/demo.html"><b>Counter rolls</b> <span>text · statement · enter · tech, gaming, software</span><p>The digits of two to four proof figures roll once like an odometer when the row comes on screen, the right-most digit turning most; the strips rest on the real figures before and after, so the row never reads as zeros.</p><p class="ph">Phone and reduced motion: The figures stand.</p></a></li>
<body><h1>Moments</h1><p class="lead">13 creative moments, one or two a page on every kind; a place, product or service page is dealt only the photo- and type-led ones that list it (references/moments.md). Each link is a full demo page; the moment is marked data-moment in its source.</p><ul><li><a href="counter-rolls/demo.html"><b>Counter rolls</b> <span>text · statement · enter · tech, gaming, software</span><p>The digits of two to four proof figures roll once like an odometer when the row comes on screen, the right-most digit turning most; the strips rest on the real figures before and after, so the row never reads as zeros.</p><p class="ph">Phone and reduced motion: The figures stand.</p></a></li>
<li><a href="cursor-spotlight/demo.html"><b>Cursor spotlight</b> <span>hover · hero · pointer · tech, gaming, software</span><p>A soft light on the hero's ground follows the pointer with a short lag and shows a fine dot grid around it; it drifts back to its resting spot when the pointer leaves.</p><p class="ph">Phone and reduced motion: The light rests at its spot; there is no pointer.</p></a></li>
<li><a href="giant-type-pass/demo.html"><b>Giant type, picture passing</b> <span>scroll · statement · scroll · tech, gaming, software</span><p>Two to four words set at 12vw with a photograph showing through the letters under a 35% ink tint; the photograph passes upward through the type as the section crosses the screen.</p><p class="ph">Phone and reduced motion: The photograph stands still in the letters, the type smaller (19vw).</p></a></li>
<li><a href="iris-opens/demo.html"><b>Iris opens</b> <span>transition · section · scroll · tech, gaming, software</span><p>A section that sits on a new ground keeps that ground edge to edge; inside it an inset panel in the old ground (even gutter, card corners, never touching the section's edges) opens a disc in the middle of what is visible, widening with a hairline accent ring at its edge. Each heading, paragraph, button and picture stays hidden until the disc holds all of it, then fades in whole, so no word is ever cut.</p><p class="ph">Phone and reduced motion: The section stands on its own ground, no panel, no ring.</p></a></li>
<li><a href="letters-assemble/demo.html"><b>Letters assemble</b> <span>scroll · statement · scroll · tech, gaming, software</span><p>A statement of two to five words, set very large: its letters start scattered, shifted and turned, and fly into the line one after the other as the statement rises through the screen, whole before it reaches the middle.</p><p class="ph">Phone and reduced motion: The statement stands as written, in a smaller size.</p></a></li>
<li><a href="line-carries-colour/demo.html"><b>Line carries the colour</b> <span>transition · section · scroll · tech, gaming, software</span><p>A section that sits on a new ground keeps that ground edge to edge; inside it an inset panel in the old ground (even gutter, card corners, never touching the section's edges) is cleared by a one-pixel accent line that climbs through it from its foot to its top: below the line the section's own ground and words, above it the old ground. Each heading, paragraph, button and picture stays hidden until the line has passed above all of it, then fades in whole, so no word is ever cut.</p><p class="ph">Phone and reduced motion: The section stands on its own ground, no panel, no line.</p></a></li>
<li><a href="lines-rise/demo.html"><b>Lines rise</b> <span>text · section, statement · enter · tech, gaming, software</span><p>One section's heading rises line by line out of masks the first time it comes on screen, 90 ms between lines; its lead, link and visual fade up behind it. Once.</p><p class="ph">Phone and reduced motion: The heading and the lines after it stand as written.</p></a></li>
<li><a href="liquid-hover/demo.html"><b>Liquid hover</b> <span>liquid · hero, section · hover · WebGL · tech, gaming, software</span><p>The pointer pushes a photograph like the surface of water: the picture bends along the pointer's path with a wet shine and a hair of colour split, and the ripple diffuses away in a second or two, back to the plain picture.</p><p class="ph">Phone and reduced motion: The plain photograph, no canvas, no WebGL context.</p></a></li>
<li><a href="liquid-type/demo.html"><b>Liquid type</b> <span>liquid · hero, statement · pointer · tech, gaming, software</span><p>A very large headline ripples and stretches like type seen through water wherever the pointer crosses it, and settles back to crisp type within about a second; one SVG filter, only while it moves.</p><p class="ph">Phone and reduced motion: The headline stands as written; there is no pointer.</p></a></li>
<li><a href="marquee-row/demo.html"><b>Marquee row</b> <span>scroll · section · loop · tech, gaming, software</span><p>One row of client words, trades, cities or formats moves slowly sideways in a loop, fading at both edges; it pauses under the pointer and has a pause button.</p><p class="ph">Phone and reduced motion: The words stand in a wrapped row; nothing runs.</p></a></li>
<li><a href="picture-develops/demo.html"><b>Picture develops</b> <span>transition · section, hero · enter · tech, gaming, software</span><p>One photograph develops once as it comes on screen: a two-tone halftone print in the page's ground and ink, darkest cells first, that gives way to the photograph settling from a slight zoom.</p><p class="ph">Phone and reduced motion: The photograph stands.</p></a></li>
<li><a href="pinned-chapters/demo.html"><b>Pinned chapters</b> <span>scroll · section · scroll · tech, gaming, software</span><p>A feature section holds on screen for two screens of scroll while its three steps open one at a time at the left and one frame at the right changes to each step's picture with a short crossfade.</p><p class="ph">Phone and reduced motion: Nothing is pinned: each step stands with its title, two lines and its visual under it.</p></a></li>
<li><a href="window-parallax/demo.html"><b>Window parallax</b> <span>scroll · section · scroll · tech, gaming, software</span><p>One wide photograph seen through a frame moves a little slower than the page, so the frame reads as a window onto a larger picture.</p><p class="ph">Phone and reduced motion: The photograph stands in its frame.</p></a></li>
<li><a href="word-highlight/demo.html"><b>Statement lights up</b> <span>scroll · statement · scroll · tech, gaming, software</span><p>One large statement rests at half ink (readable, about 4 to 1) and turns to ink word by word as it crosses the middle of the screen, a strong word to the accent; it runs back on the way up.</p><p class="ph">Phone and reduced motion: The whole statement in ink, the accent word in the accent.</p></a></li>
<li><a href="word-rotate/demo.html"><b>Word rotates</b> <span>text · hero · loop · tech, gaming, software</span><p>One word of the headline changes every two seconds, sliding up out of its mask as the next rises in, underlined in the accent; three rounds, then it rests.</p><p class="ph">Phone and reduced motion: The written word stands; the other words are not shown.</p></a></li></ul></body></html>
<li><a href="photo-chapters/demo.html"><b>Photo chapters</b> <span>scroll · section · scroll · place, product, service</span><p>A short story in three to five photographs, one large frame at a time: the section holds on screen while, under its h2, one photograph the width of the page is uncovered by the next from its foot upward, settling from a slight zoom, and a panel in the page's ground on the photograph's lower left changes to that chapter (its number, a title and two lines). Each chapter has about two thirds of a screen of scroll: the wipe takes under half of it and the photograph holds for the rest. Live at every width on a screen 600px or taller, phones included.</p><p class="ph">Phone and reduced motion: It runs on a phone held upright (one photograph at a time, the panel across its foot); a phone turned sideways, under 600px tall, gets the chapters stacked, each photograph over its words.</p></a></li>
<li><a href="picture-develops/demo.html"><b>Picture develops</b> <span>transition · section, hero · enter · place, product, service</span><p>One large photograph develops once as it comes on screen. On a place, product or service page (look "film") it comes up out of the paper like a print in the developer tray: a faint grey print at first, its darks arriving before its light tones, then its colour, while it settles from a slight zoom, about 1.6 s. The halftone look ("print", a two-tone dot print in the page's ground and ink giving way to the photograph) is not dealt: tech leaves this moment out, so it is kept only as opts look "print".</p><p class="ph">Phone and reduced motion: The film look runs on a phone too (one picture, a filter and a transform, once); the print look shows the photograph.</p></a></li>
<li><a href="pinned-chapters/demo.html"><b>Pinned chapters</b> <span>scroll · section · scroll · tech, gaming, software, product, service</span><p>A feature section holds on screen for two screens of scroll while its three steps open one at a time at the left and one frame at the right changes to each step's picture with a short crossfade.</p><p class="ph">Phone and reduced motion: Nothing is pinned: each step stands with its title, two lines and its visual under it.</p></a></li>
<li><a href="word-highlight/demo.html"><b>Statement lights up</b> <span>scroll · statement · scroll · tech, gaming, software, place, product, service</span><p>One large statement rests at half ink (readable, about 4 to 1) and turns to ink word by word as it crosses the middle of the screen, a strong word to the accent; it runs back on the way up.</p><p class="ph">Phone and reduced motion: It runs on a phone as on a desktop (one paragraph's colours); under reduced motion the whole statement is in ink, the accent word in the accent.</p></a></li></ul></body></html>
@@ -9,7 +9,7 @@
"cost": "low",
"webgl": 0,
"pages": [],
"retired": "MO-62: the headline never hides on load, and this moment holds it back until the script runs.",
"retired": "Not dealt: every hero now enters on load through the motion layer (references/motion.md, The hero's entrance: data-enter on the hero), with its text visible without script, under reduced motion and after a 700ms safety mark. A moment that also moves the headline would be a second entrance.",
"look": "The hero headline's words rise once out of their masks, 38 ms apart, and settle; the sub-line and the buttons fade up behind them.",
"reduced": "The headline stands as written; nothing moves.",
"phone": "The headline or button stands as written.",
+85 -37
View File
@@ -1,19 +1,22 @@
{
"version": 2,
"generated": "2026-11-03T17:12:06.364Z",
"count": 17,
"dealt": 15,
"generated": "2026-11-07T06:04:58.498Z",
"count": 18,
"dealt": 13,
"kinds": {
"transition": 4,
"text": 4,
"hover": 1,
"scroll": 6,
"scroll": 7,
"liquid": 2
},
"pages": [
"tech",
"gaming",
"software"
"software",
"place",
"product",
"service"
],
"budget": 4,
"moments": [
@@ -78,7 +81,7 @@
"maxPerPage": 1,
"weight": 1,
"needs": {
"words": "2 to 4 real figures with a short caption each"
"words": "2 to 4 figures the brief gives (a price, a count, a time), each with a short caption; never an invented outcome"
},
"avoidWith": [],
"states": {
@@ -215,7 +218,7 @@
"cost": "low",
"webgl": 0,
"pages": [],
"retired": "MO-62: the headline never hides on load, and this moment holds it back until the script runs.",
"retired": "Not dealt: every hero now enters on load through the motion layer (references/motion.md, The hero's entrance: data-enter on the hero), with its text visible without script, under reduced motion and after a 700ms safety mark. A moment that also moves the headline would be a second entrance.",
"look": "The hero headline's words rise once out of their masks, 38 ms apart, and settle; the sub-line and the buttons fade up behind them.",
"reduced": "The headline stands as written; nothing moves.",
"phone": "The headline or button stands as written.",
@@ -385,16 +388,13 @@
"trigger": "enter",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: every hero now rises line by line on load (references/motion.md, The hero's entrance), so a section heading that rises the same way repeats the entrance.",
"look": "One section's heading rises line by line out of masks the first time it comes on screen, 90 ms between lines; its lead, link and visual fade up behind it. Once.",
"reduced": "The section stands as written.",
"phone": "The heading and the lines after it stand as written.",
"maxPerPage": 1,
"weight": 0.9,
"weight": 0,
"needs": {
"words": "a heading of 2 to 3 lines"
},
@@ -593,6 +593,51 @@
}
}
},
{
"id": "photo-chapters",
"name": "Photo chapters",
"kind": "scroll",
"fits": [
"section"
],
"trigger": "scroll",
"cost": "low",
"webgl": 0,
"pages": [
"place",
"product",
"service"
],
"look": "A short story in three to five photographs, one large frame at a time: the section holds on screen while, under its h2, one photograph the width of the page is uncovered by the next from its foot upward, settling from a slight zoom, and a panel in the page's ground on the photograph's lower left changes to that chapter (its number, a title and two lines). Each chapter has about two thirds of a screen of scroll: the wipe takes under half of it and the photograph holds for the rest. Live at every width on a screen 600px or taller, phones included.",
"reduced": "Nothing is pinned: the chapters stand side by side, each photograph over its title and lines, and the section is as tall as they are.",
"phone": "It runs on a phone held upright (one photograph at a time, the panel across its foot); a phone turned sideways, under 600px tall, gets the chapters stacked, each photograph over its words.",
"maxPerPage": 1,
"weight": 1,
"needs": {
"photo": true,
"words": "3 to 5 chapters, a title and two lines each, under one h2 and a short lead",
"pictures": "one photograph per chapter, all of one place, product or job, none used elsewhere on the page"
},
"avoidWith": [
"pinned-chapters"
],
"states": {
"start": {
"p": 0.06,
"wait": 500
},
"middle": {
"p": 0.5,
"wait": 500
},
"end": {
"p": 0.96,
"wait": 500
}
},
"phoneRuns": true,
"height": "(hold + 1) screens live: hold 2 for three chapters, 3 for four or five; at rest as tall as its chapters. No --h on the section."
},
{
"id": "picture-develops",
"name": "Picture develops",
@@ -605,17 +650,18 @@
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
"place",
"product",
"service"
],
"look": "One photograph develops once as it comes on screen: a two-tone halftone print in the page's ground and ink, darkest cells first, that gives way to the photograph settling from a slight zoom.",
"look": "One large photograph develops once as it comes on screen. On a place, product or service page (look \"film\") it comes up out of the paper like a print in the developer tray: a faint grey print at first, its darks arriving before its light tones, then its colour, while it settles from a slight zoom, about 1.6 s. The halftone look (\"print\", a two-tone dot print in the page's ground and ink giving way to the photograph) is not dealt: tech leaves this moment out, so it is kept only as opts look \"print\".",
"reduced": "The photograph stands.",
"phone": "The photograph stands.",
"phone": "The film look runs on a phone too (one picture, a filter and a transform, once); the print look shows the photograph.",
"maxPerPage": 1,
"weight": 1,
"needs": {
"photo": true
"photo": true,
"picture": "one photograph with a clear subject, half the container wide or more: the section's subject, not a thumbnail"
},
"avoidWith": [
"liquid-hover",
@@ -634,7 +680,8 @@
"see": true,
"wait": 2600
}
}
},
"phoneRuns": true
},
{
"id": "pinned-chapters",
@@ -649,7 +696,9 @@
"pages": [
"tech",
"gaming",
"software"
"software",
"product",
"service"
],
"look": "A feature section holds on screen for two screens of scroll while its three steps open one at a time at the left and one frame at the right changes to each step's picture with a short crossfade.",
"reduced": "Nothing is pinned: from 640px each step is a static row, its text at the left and its visual at the right (at most 30svh tall); on a phone each step stands with its 16:9 visual under it. Two to four steps fit two screens at 1440x900, 1024x768, 820x1180 and 390x844.",
@@ -662,7 +711,8 @@
},
"avoidWith": [
"closing-lift",
"line-carries-colour"
"line-carries-colour",
"photo-chapters"
],
"states": {
"start": {
@@ -689,16 +739,13 @@
"trigger": "scroll",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: it moves a photograph against the scroll, a parallax that reads as a template trick on every kind; a wide photograph stands still in its frame.",
"look": "One wide photograph seen through a frame moves a little slower than the page, so the frame reads as a window onto a larger picture.",
"reduced": "The photograph stands.",
"phone": "The photograph stands in its frame.",
"maxPerPage": 1,
"weight": 0.9,
"weight": 0,
"needs": {
"photo": true
},
@@ -734,11 +781,14 @@
"pages": [
"tech",
"gaming",
"software"
"software",
"place",
"product",
"service"
],
"look": "One large statement rests at half ink (readable, about 4 to 1) and turns to ink word by word as it crosses the middle of the screen, a strong word to the accent; it runs back on the way up.",
"reduced": "The statement in ink.",
"phone": "The whole statement in ink, the accent word in the accent.",
"phone": "It runs on a phone as on a desktop (one paragraph's colours); under reduced motion the whole statement is in ink, the accent word in the accent.",
"maxPerPage": 1,
"weight": 1,
"needs": {
@@ -760,7 +810,8 @@
"view": 0.72,
"wait": 350
}
}
},
"phoneRuns": true
},
{
"id": "word-rotate",
@@ -772,16 +823,13 @@
"trigger": "loop",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: the word hero carries its own live word (layout-v4.md), and a second rotating word in a headline repeats it.",
"look": "One word of the headline changes every two seconds, sliding up out of its mask as the next rises in, underlined in the accent; three rounds, then it rests.",
"reduced": "The written word stands.",
"phone": "The written word stands; the other words are not shown.",
"maxPerPage": 1,
"weight": 1,
"weight": 0,
"needs": {
"words": "a headline with one word that can take 2 to 4 alternatives (what the product is for)"
},
+12 -5
View File
@@ -3,11 +3,14 @@
reduced() true under prefers-reduced-motion: a moment then shows its finished state only
phone() true on a phone: under 640px wide, or a touch screen in landscape under 500px tall
(a phone turned sideways). A moment shows its finished state there too.
(a phone turned sideways). A moment shows its finished state there too, unless
its module says `export const phone = true` (word-highlight, picture-develops,
photo-chapters: light enough to run there).
still() reduced() || phone(): the one question every moment asks. The loader (moments.js)
hands each moment a lib whose reduced() answers still(), so a moment written
against reduced() is already static on a phone, and sets data-moment-still on its
host ("reduced" or "phone"). The answer is taken once, when the page loads.
hands each moment a lib whose reduced() answers still() (only reduced() for a
moment that runs on a phone), so a moment written against reduced() is static
where it must be, and sets data-moment-still on its host ("reduced" or "phone").
The answer is taken once, when the page loads.
coarse() true on a touch screen (no hover moments there)
inCard(el) true when el is, or sits in, a card, a plan, a result or a bento cell: nothing
on a card ever moves on scroll, so the loader leaves such a moment still
@@ -122,7 +125,11 @@ export function split(el, unit = 'words') {
} else frag.append(wrap(part, 'mo-w'));
});
n.replaceWith(frag);
} else if (n.nodeType === 1 && !n.classList.contains('mo-w')) walk(n);
} else if (n.nodeType === 1 && !n.classList.contains('mo-w')) {
// a link or button inside keeps its name: its words are hidden with the rest, so it is named first
if (n.matches('a[href], button') && !n.getAttribute('aria-label') && n.textContent.trim()) n.setAttribute('aria-label', n.textContent.replace(/\s+/g, ' ').trim());
walk(n);
}
});
};
walk(el);
@@ -9,16 +9,13 @@
"trigger": "enter",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: every hero now rises line by line on load (references/motion.md, The hero's entrance), so a section heading that rises the same way repeats the entrance.",
"look": "One section's heading rises line by line out of masks the first time it comes on screen, 90 ms between lines; its lead, link and visual fade up behind it. Once.",
"reduced": "The section stands as written.",
"phone": "The heading and the lines after it stand as written.",
"maxPerPage": 1,
"weight": 0.9,
"weight": 0,
"needs": {
"words": "a heading of 2 to 3 lines"
},
+8 -3
View File
@@ -51,11 +51,16 @@ export function mount(el) {
el.__momentP = (async () => {
let opts = {};
try { opts = JSON.parse(el.dataset.momentOpts || '{}'); } catch { console.warn(`moments: bad data-moment-opts on ${id}`); }
const why = lib.stillWhy(), card = lib.inCard(el);
const card = lib.inCard(el);
const [, mod] = await Promise.all([css(new URL(`${id}/moment.css`, BASE).href), import(new URL(`${id}/moment.js`, BASE).href)]);
// a moment light enough for a phone says so in its module (export const phone = true: one picture
// or one line of words, transform, filter or colour, nothing pinned that a phone cannot hold): it runs
// there too, and only reduced motion and a card keep it still. Every other moment is still on a phone
const onPhone = mod.phone === true;
const why = onPhone ? (lib.reduced() ? 'reduced' : '') : lib.stillWhy();
if (card) { el.dataset.momentStill = 'card'; html.dataset.momentsCard = [html.dataset.momentsCard, id].filter(Boolean).join(' '); console.warn(`moments: ${id} sits in a card; it stays still`); }
else if (why) el.dataset.momentStill = why;
const mlib = { ...lib, reduced: () => card || lib.still() };
const [, mod] = await Promise.all([css(new URL(`${id}/moment.css`, BASE).href), import(new URL(`${id}/moment.js`, BASE).href)]);
const mlib = { ...lib, reduced: () => card || (onPhone ? lib.reduced() : lib.still()) };
const api = mod.default(el, opts, mlib) || {};
el.__moment = api;
el.dataset.momentMounted = '1';
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en" data-type="place" data-radius="soft">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Photo chapters · moment demo</title>
<meta name="site-kind" content="other">
<meta name="page-type" content="place">
<meta name="page-kind" content="place">
<script>document.documentElement.classList.add('mo-js');setTimeout(()=>document.documentElement.classList.add('mo-safe'),2500)</script>
<link rel="modulepreload" href="../moments.js"><link rel="modulepreload" href="../lib.js"><link rel="modulepreload" href="moment.js">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400&family=Hanken+Grotesk:wght@400;500&display=swap">
<link rel="stylesheet" href="../../system.css">
<link rel="stylesheet" href="../_demo/demo.css">
<link rel="stylesheet" href="moment.css">
<style>
:root { --p-bg: #f7f4ee; --p-surface: #ece6da; --p-ink: #1f1d1a; --p-muted: #5f5a52; --p-accent: #2f5d50; --p-accent-ink: #ffffff; --p-action: #1f1d1a; --p-action-ink: #f7f4ee;
--font-display: "Fraunces", Georgia, serif; --font-text: "Hanken Grotesk", system-ui, sans-serif; --display-weight: 400; }
.facts { display: grid; align-content: center; }
.facts div { display: grid; grid-template-columns: 120px 1fr; gap: var(--s-4); padding: var(--s-4) 0; border-top: 1px solid var(--line); }
.facts div:last-child { border-bottom: 1px solid var(--line); }
.facts span { color: var(--muted); }
.facts b { font-weight: 500; }
</style>
</head>
<body>
<header class="demo-nav"><div class="container">
<a class="demo-nav__logo" href="#">Harbour House</a>
<nav class="demo-nav__links"><a href="#">Rooms</a><a href="#">Dinner</a><a href="#">The coast</a><a href="#">Find us</a></nav>
<div class="demo-nav__actions"><a class="btn btn--secondary btn--sm" href="#">Call us</a><a class="btn btn--primary btn--sm" href="#">Book a room</a></div>
</div></header>
<main>
<section class="section section--hero">
<div class="container demo-split">
<div class="section-head"><h1>Six rooms above the harbour in Staithes</h1><p>A guesthouse by the water with dinner at eight, breakfast until ten and bikes to borrow. Rooms for two from £120 a night.</p><div class="btn-row"><a class="btn btn--primary" href="#">Book a room</a><a class="btn btn--secondary" href="#">See the rooms</a></div></div>
<img class="demo-photo" src="../../layout/img/photo-d88.jpg" alt="A cream lamp on a side table in the low evening sun of room four" width="1280" height="1757">
</div>
</section>
<section class="section" data-moment="photo-chapters">
<div class="container mo-ph">
<div class="mo-ph__head"><h2>A weekend from our door</h2><p>Three days that start and end at the house: the moor, the bay and the long beach, with bikes, a packed lunch and dinner waiting.</p></div>
<ol class="mo-ph__list">
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="../_demo/photo-702.jpg" alt="The moor road above the tree line" width="1800" height="1200"></figure>
<h3>Saturday: over the moor</h3>
<p>Borrow two bikes after breakfast and take the moor road to the pass, 40 km with a tea room at the top.</p>
</li>
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="../_demo/photo-1049-tall.jpg" alt="The rock off the bay at low tide" width="1200" height="1800"></figure>
<h3>Saturday night: the bay</h3>
<p>Back by five for low tide on the scar, then dinner at eight: what the boats brought in that morning.</p>
</li>
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="../_demo/photo-640.jpg" alt="Waves on the sand at the dunes" width="1800" height="1200"></figure>
<h3>Sunday: the long beach</h3>
<p>A slow breakfast, the cliff path to the dunes and a swim if the sea allows. Checkout is at noon, or two on request.</p>
</li>
</ol>
</div>
</section>
<section class="section">
<div class="container demo-split">
<div class="section-head"><h2>What a night includes. <span class="muted">From £120 for two.</span></h2><p>A room with a view of the water, dinner on Friday and Saturday, breakfast every day and two bikes to borrow. Dogs welcome in two of the rooms.</p><a class="link" href="#">See the rooms and prices</a></div>
<div class="facts">
<div><span>Rooms</span><b>Six, four with a sea view</b></div>
<div><span>Dinner</span><b>At eight, Friday and Saturday</b></div>
<div><span>Price</span><b>£120 to £170 a night for two</b></div>
<div><span>Parking</span><b>At the top of the village, free</b></div>
</div>
</div>
</section>
</main>
<footer class="container demo-footer"><span>Harbour House · demo page for the moment “Photo chapters”</span><span>Photographs by Dominik Schröder, Rosan Harmens and Sarah Bürvenich on Unsplash; LoMit on Wikimedia Commons (CC BY-SA 4.0)</span></footer>
<script type="module" src="../moments.js"></script>
</body>
</html>
@@ -0,0 +1,45 @@
{
"id": "photo-chapters",
"name": "Photo chapters",
"kind": "scroll",
"fits": [
"section"
],
"trigger": "scroll",
"cost": "low",
"webgl": 0,
"pages": [
"place",
"product",
"service"
],
"look": "A short story in three to five photographs, one large frame at a time: the section holds on screen while, under its h2, one photograph the width of the page is uncovered by the next from its foot upward, settling from a slight zoom, and a panel in the page's ground on the photograph's lower left changes to that chapter (its number, a title and two lines). Each chapter has about two thirds of a screen of scroll: the wipe takes under half of it and the photograph holds for the rest. Live at every width on a screen 600px or taller, phones included.",
"reduced": "Nothing is pinned: the chapters stand side by side, each photograph over its title and lines, and the section is as tall as they are.",
"phone": "It runs on a phone held upright (one photograph at a time, the panel across its foot); a phone turned sideways, under 600px tall, gets the chapters stacked, each photograph over its words.",
"maxPerPage": 1,
"weight": 1,
"needs": {
"photo": true,
"words": "3 to 5 chapters, a title and two lines each, under one h2 and a short lead",
"pictures": "one photograph per chapter, all of one place, product or job, none used elsewhere on the page"
},
"avoidWith": [
"pinned-chapters"
],
"states": {
"start": {
"p": 0.06,
"wait": 500
},
"middle": {
"p": 0.5,
"wait": 500
},
"end": {
"p": 0.96,
"wait": 500
}
},
"phoneRuns": true,
"height": "(hold + 1) screens live: hold 2 for three chapters, 3 for four or five; at rest as tall as its chapters. No --h and no data-screen on the section: the checks read it live as whole screens, four at most."
}
@@ -0,0 +1,67 @@
/* photo-chapters: at rest (reduced motion, no script, a failed module, a screen under 600px tall) the
chapters stand side by side under the h2, each photograph over its title and lines; a phone stacks
them. The section is then as tall as its chapters, one screen at least, at every width: a held
section's extra screens would stand empty. Live (.is-live, moment.js) the section is (hold + 1)
screens and its box sticks for the hold: the h2 and lead at the top, one stage of photographs the
width of the page's edges under them, each uncovered from its foot (--mo-c, the share still
covered) and settling (--mo-z), and on the stage's lower left a panel in the page's ground with the
chapter in hand. */
[data-moment="photo-chapters"] { --mo-hold: 2; }
main [data-moment="photo-chapters"][data-moment] > .mo-ph { display: grid; gap: var(--s-7); align-content: start; height: auto; }
main > [data-moment="photo-chapters"][data-moment]:not(.is-live) { height: auto; min-height: 100vh; min-height: 100svh; }
[data-moment="photo-chapters"] .mo-ph__head { display: grid; gap: var(--s-4); max-width: 46ch; }
[data-moment="photo-chapters"] .mo-ph__list { display: grid; gap: var(--s-6) var(--s-5); margin: 0; padding: 0; list-style: none; }
[data-moment="photo-chapters"] .mo-ph__ch { display: grid; gap: var(--s-2); align-content: start; }
[data-moment="photo-chapters"] .mo-ph__pic { margin: 0 0 var(--s-3); border-radius: var(--r-md); overflow: hidden; }
[data-moment="photo-chapters"] .mo-ph__pic > img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
[data-moment="photo-chapters"] .mo-ph__ch h3 { font-family: var(--font-text); font-weight: 500; font-size: var(--t-lead); line-height: 1.3; letter-spacing: -0.01em; color: var(--ink); }
[data-moment="photo-chapters"] .mo-ph__ch p { color: var(--ink); max-width: 44ch; }
[data-moment="photo-chapters"] .mo-ph__words { display: grid; gap: var(--s-2); }
@media (min-width: 640px) {
[data-moment="photo-chapters"]:not(.is-live) .mo-ph__list { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
[data-moment="photo-chapters"]:not(.is-live) .mo-ph__pic > img { aspect-ratio: 4 / 5; max-height: 44svh; }
}
@media (max-width: 639px) {
[data-moment="photo-chapters"]:not(.is-live) .mo-ph__pic > img { aspect-ratio: 16 / 10; }
}
/* live */
main > [data-moment="photo-chapters"][data-moment].is-live { height: calc((var(--mo-hold) + 1) * 100vh); height: calc((var(--mo-hold) + 1) * 100svh); min-height: 0; padding: 0; display: block; }
main [data-moment="photo-chapters"][data-moment].is-live > .mo-ph {
position: sticky; top: 0; height: 100vh; height: 100svh; box-sizing: border-box; overflow: clip;
padding-top: calc(var(--nav, var(--nav-h, 72px)) + var(--s-6)); padding-bottom: calc(var(--s-6) + var(--bar, 0px));
grid-template-rows: auto minmax(0, 1fr); gap: var(--s-6); align-content: stretch; }
[data-moment="photo-chapters"].is-live .mo-ph__head { grid-row: 1; }
/* the stage: the photographs stacked, each one above the one before, cut from its top while covered */
[data-moment="photo-chapters"].is-live .mo-ph__stage { grid-row: 2; position: relative; min-height: 0; border-radius: var(--r-lg); overflow: clip; background: var(--surface); }
[data-moment="photo-chapters"].is-live .mo-ph__stage > .mo-ph__pic { position: absolute; inset: 0; margin: 0; border-radius: 0;
clip-path: inset(var(--mo-c, 0%) 0 0 0); will-change: clip-path; }
[data-moment="photo-chapters"].is-live .mo-ph__stage > .mo-ph__pic > img { width: 100%; height: 100%; aspect-ratio: auto; max-height: none;
transform: scale(var(--mo-z, 1)); transform-origin: 50% 100%; will-change: transform; }
/* the panel: the page's ground on the photograph, an even inset from the stage's corner; the chapters
share one cell, so the panel is as tall as the longest and never jumps. The one in hand shows; the
others wait 8px lower at opacity 0, still read by a screen reader */
[data-moment="photo-chapters"].is-live .mo-ph__panel { position: absolute; z-index: 1; left: var(--s-5); bottom: var(--s-5);
width: min(440px, calc(100% - 2 * var(--s-5))); box-sizing: border-box; padding: var(--s-6);
display: grid; gap: var(--s-4); background: var(--bg); color: var(--ink); border-radius: var(--r-md); }
[data-moment="photo-chapters"].is-live .mo-ph__count { margin: 0; font: 500 var(--t-small)/1 var(--font-text); font-variant-numeric: tabular-nums; letter-spacing: .02em; color: var(--muted); }
[data-moment="photo-chapters"].is-live .mo-ph__list { display: grid; gap: 0; }
[data-moment="photo-chapters"].is-live .mo-ph__ch { grid-area: 1 / 1; align-content: start; }
[data-moment="photo-chapters"].is-live .mo-ph__words { opacity: 0; transform: translateY(8px); pointer-events: none;
transition: opacity 220ms ease, transform 220ms ease; }
[data-moment="photo-chapters"].is-live .mo-ph__ch.is-on .mo-ph__words { opacity: 1; transform: none; pointer-events: auto;
transition: opacity 420ms cubic-bezier(.22, 1, .36, 1) 60ms, transform 520ms cubic-bezier(.22, 1, .36, 1) 60ms; }
[data-moment="photo-chapters"].is-live .mo-ph__ch h3 { font-family: var(--font-display); font-weight: var(--display-weight, 400); font-size: var(--t-h3, 28px); line-height: 1.15; letter-spacing: var(--display-tracking, -0.02em); }
@media (max-width: 639px) {
[data-moment="photo-chapters"].is-live .mo-ph__panel { left: var(--s-3); right: var(--s-3); bottom: var(--s-3); width: auto; padding: var(--s-5); gap: var(--s-3); }
}
@media print {
main > [data-moment="photo-chapters"][data-moment].is-live { height: auto; }
main [data-moment="photo-chapters"][data-moment].is-live > .mo-ph { position: static; height: auto; display: block; overflow: visible; }
[data-moment="photo-chapters"].is-live .mo-ph__words { opacity: 1; transform: none; }
[data-moment="photo-chapters"].is-live .mo-ph__stage { display: grid; gap: var(--s-5); }
[data-moment="photo-chapters"].is-live .mo-ph__stage > .mo-ph__pic { position: static; clip-path: none; aspect-ratio: 4 / 3; }
[data-moment="photo-chapters"].is-live .mo-ph__stage > .mo-ph__pic > img { transform: none; }
[data-moment="photo-chapters"].is-live .mo-ph__panel { position: static; width: auto; }
[data-moment="photo-chapters"].is-live .mo-ph__ch { grid-area: auto; }
}
@@ -0,0 +1,126 @@
/*
photo-chapters - a short story told in three to five photographs, one large frame at a time. The
section holds on screen while the page scrolls: its h2 and lead at the top, below them one big
photograph the width of the page's edges, and on it, at its lower left, a panel in the page's ground
with the chapter in hand (its number, a title and two lines). As the reader scrolls, each next
photograph is uncovered over the one before from its foot upward, settling from a slight zoom, and
the panel changes to its chapter half way through the wipe. The wipe follows the scrollbar (it runs
back on the way up) and takes under half of a chapter's share of the scroll; the photograph then
holds for the rest, so there is time to read. Photo-led and calm: for a place, a product or a
service (the room, the kitchen, the workshop, a day of a tour). No WebGL, no canvas.
<section class="section" data-moment="photo-chapters">
<div class="container mo-ph">
<div class="mo-ph__head"><h2>A night at the harbour house</h2><p>One line.</p></div>
<ol class="mo-ph__list">
<li class="mo-ph__ch">
<figure class="mo-ph__pic"><img src="…" alt="…" width="1800" height="1200"></figure>
<h3>Arrive by the coast road</h3>
<p>Two lines.</p>
</li>
…three to five chapters, one photograph each
</ol>
</div>
</section>
Its height is its own: live the section is (hold + 1) screens, the hold growing with the chapters
(three chapters hold two screens, four or five hold three: about two thirds of a screen each), and
at rest it is as tall as its chapters, one screen at least. Write no --h and no data-screen on it:
the checker and the layout check read a live photo-chapters section (.is-live) as whole screens, four
at most, and still hold it to that.
At rest (reduced motion, no script, a failed module, a screen under 600px tall such as a phone
turned sideways) the chapters stand side by side, each photograph over its title and lines (a phone
stacks them): the same words and pictures, nothing hidden. Live at every width from 600px of
height, so a phone held upright gets it too (one picture at a time, a clip and a transform).
Every chapter's words stay in the page for a screen reader; only the one in hand is shown.
opts: zoom (the settle, 1.06; 1-1.1).
*/
export const phone = true;
export default function (root, opts, lib) {
const box = root.querySelector('.mo-ph') || root;
const list = root.querySelector('.mo-ph__list');
const chapters = [...root.querySelectorAll('.mo-ph__ch')];
const pics = chapters.map(c => c.querySelector('.mo-ph__pic'));
if (!list || chapters.length < 2 || pics.some(p => !p)) return {};
const homes = pics.map(p => p.parentElement);
const listHome = { parent: list.parentElement, next: list.nextSibling };
const zoom = lib.clamp(+opts.zoom || 1.06, 1, 1.1);
const n = chapters.length;
// about two thirds of a screen of scroll a chapter, in whole screens so the section stays on the grid
const hold = n <= 3 ? 2 : 3;
// each chapter's words in a box of their own, so the live panel can stack them in one place
chapters.forEach(c => {
if (c.querySelector(':scope > .mo-ph__words')) return;
const w = document.createElement('div');
w.className = 'mo-ph__words';
[...c.children].filter(el => !el.classList.contains('mo-ph__pic')).forEach(el => w.append(el));
c.append(w);
});
const stage = document.createElement('div');
stage.className = 'mo-ph__stage';
const panel = document.createElement('div');
panel.className = 'mo-ph__panel';
const count = document.createElement('p');
count.className = 'mo-ph__count';
count.setAttribute('aria-hidden', 'true');
panel.append(count);
const pad = v => String(v).padStart(2, '0');
let live = false, scrubber = null, on = -1;
const fits = () => !lib.reduced() && innerHeight >= 600;
const show = k => {
if (k === on) return;
on = k;
chapters.forEach((c, i) => c.classList.toggle('is-on', i === k));
count.textContent = `${pad(k + 1)} / ${pad(n)}`;
};
// p 0-1 over the held screens: chapter k owns [k/n, (k+1)/n). Its photograph is uncovered over the
// 45% of a share that straddles that boundary, then holds; the words change half way through the wipe.
// The last wipe ends at 0.2 of its share, so the last chapter holds to the section's end
const draw = p => {
const seg = 1 / n;
let k = 0;
pics.forEach((pic, i) => {
const u = i === 0 ? 1 : lib.clamp((p - (i - 0.25) * seg) / (0.45 * seg));
const e = lib.ease.out(u);
pic.style.setProperty('--mo-c', `${((1 - e) * 100).toFixed(2)}%`);
pic.style.setProperty('--mo-z', (zoom - (zoom - 1) * e).toFixed(4));
if (u >= 0.5) k = i;
});
show(k);
};
const go = () => {
if (live) return;
live = true;
root.style.setProperty('--mo-hold', String(hold));
pics.forEach(p => stage.append(p));
panel.append(list);
stage.append(panel);
box.append(stage);
root.classList.add('is-live');
box.setAttribute('data-edge-free', ''); // live the box is one held screen of the section: the layout check skips it
draw(0);
scrubber = lib.scrub(root, draw);
};
const rest = () => {
if (!live) return;
live = false;
scrubber?.destroy(); scrubber = null;
root.classList.remove('is-live');
root.style.removeProperty('--mo-hold');
box.removeAttribute('data-edge-free');
listHome.parent.insertBefore(list, listHome.next);
pics.forEach((p, i) => { homes[i].prepend(p); p.style.removeProperty('--mo-c'); p.style.removeProperty('--mo-z'); });
chapters.forEach(c => c.classList.remove('is-on'));
stage.remove();
on = -1;
};
const sync = () => (fits() ? go() : rest());
sync();
let t = 0;
const onResize = () => { clearTimeout(t); t = setTimeout(sync, 120); };
addEventListener('resize', onResize);
const rq = matchMedia('(prefers-reduced-motion: reduce)');
rq.addEventListener('change', sync);
return { destroy() { removeEventListener('resize', onResize); rq.removeEventListener('change', sync); rest(); } };
}
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en" data-type="place" data-radius="soft">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Picture develops, the film look · moment demo</title>
<meta name="site-kind" content="other">
<meta name="page-type" content="place">
<meta name="page-kind" content="place">
<script>document.documentElement.classList.add('mo-js');setTimeout(()=>document.documentElement.classList.add('mo-safe'),2500)</script>
<link rel="modulepreload" href="../moments.js"><link rel="modulepreload" href="../lib.js"><link rel="modulepreload" href="moment.js">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400&family=Hanken+Grotesk:wght@400;500&display=swap">
<link rel="stylesheet" href="../../system.css">
<link rel="stylesheet" href="../_demo/demo.css">
<link rel="stylesheet" href="moment.css">
<style>
:root { --p-bg: #f7f4ee; --p-surface: #ece6da; --p-ink: #1f1d1a; --p-muted: #5f5a52; --p-accent: #2f5d50; --p-accent-ink: #ffffff; --p-action: #1f1d1a; --p-action-ink: #f7f4ee;
--font-display: "Fraunces", Georgia, serif; --font-text: "Hanken Grotesk", system-ui, sans-serif; --display-weight: 400; }
.film-head { display: grid; gap: var(--s-4); grid-template-columns: 1fr; margin-bottom: var(--s-6); }
@media (min-width: 1024px) { .film-head { grid-template-columns: 6fr 5fr; align-items: end; gap: var(--s-8); } }
.film-frame { margin: 0; border-radius: var(--r-lg); flex: 1 1 0; min-height: 0; }
</style>
</head>
<body>
<header class="demo-nav"><div class="container">
<a class="demo-nav__logo" href="#">Harbour House</a>
<nav class="demo-nav__links"><a href="#">Rooms</a><a href="#">Dinner</a><a href="#">The coast</a><a href="#">Find us</a></nav>
<div class="demo-nav__actions"><a class="btn btn--secondary btn--sm" href="#">Call us</a><a class="btn btn--primary btn--sm" href="#">Book a room</a></div>
</div></header>
<main>
<section class="section section--hero">
<div class="container demo-split">
<div class="section-head"><h1>Six rooms above the harbour in Staithes</h1><p>A guesthouse by the water with dinner at eight, breakfast until ten and bikes to borrow. Rooms for two from £120 a night.</p><div class="btn-row"><a class="btn btn--primary" href="#">Book a room</a><a class="btn btn--secondary" href="#">See the rooms</a></div></div>
<img class="demo-photo" src="../_demo/photo-640.jpg" alt="Waves on the sand below the village" width="1800" height="1200">
</div>
</section>
<section class="section">
<div class="container">
<div class="film-head"><h2>The view from room four</h2><p>The bay at low tide, from the window seat. Four of our six rooms face the water; ask for one when you book.</p></div>
<figure class="film-frame" data-moment="picture-develops">
<img src="../_demo/photo-1049.jpg" alt="A rock in the bay at low tide, seen from the window of room four" width="1800" height="1200">
</figure>
</div>
</section>
<section class="section ground-surface">
<div class="container demo-split">
<div class="section-head"><h2>Dinner at eight. <span class="muted">Friday and Saturday.</span></h2><p>One menu of three courses from what the boats bring in, £38 a head. Tell us about allergies when you book.</p><a class="link" href="#">See this week's menu</a></div>
<img class="demo-photo" src="../_demo/photo-702.jpg" alt="The moor road above the village" width="1800" height="1200">
</div>
</section>
</main>
<footer class="container demo-footer"><span>Harbour House · demo page for the moment “Picture develops” (the film look)</span><span>Photographs by Sarah Bürvenich, Rosan Harmens and Dominik Schröder on Unsplash</span></footer>
<script type="module" src="../moments.js"></script>
</body>
</html>
@@ -10,17 +10,18 @@
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
"place",
"product",
"service"
],
"look": "One photograph develops once as it comes on screen: a two-tone halftone print in the page's ground and ink, darkest cells first, that gives way to the photograph settling from a slight zoom.",
"look": "One large photograph develops once as it comes on screen. On a place, product or service page (look \"film\") it comes up out of the paper like a print in the developer tray: a faint grey print at first, its darks arriving before its light tones, then its colour, while it settles from a slight zoom, about 1.6 s. The halftone look (\"print\", a two-tone dot print in the page's ground and ink giving way to the photograph) is not dealt: tech leaves this moment out, so it is kept only as opts look \"print\".",
"reduced": "The photograph stands.",
"phone": "The photograph stands.",
"phone": "The film look runs on a phone too (one picture, a filter and a transform, once); the print look shows the photograph.",
"maxPerPage": 1,
"weight": 1,
"needs": {
"photo": true
"photo": true,
"picture": "one photograph with a clear subject, half the container wide or more: the section's subject, not a thumbnail"
},
"avoidWith": [
"liquid-hover",
@@ -39,5 +40,6 @@
"see": true,
"wait": 2600
}
}
},
"phoneRuns": true
}
@@ -1,14 +1,21 @@
/* picture-develops: the frame holds the photograph (cover) and, while developing, one canvas of the
halftone print over it. The photograph settles from a slight zoom when the print clears. */
.mo-pd { position: relative; overflow: hidden; isolation: isolate; }
.mo-pd > img { display: block; width: 100%; height: 100%; object-fit: cover; }
halftone print over it. The photograph settles from a slight zoom when the print clears. The frame
rules also hold without script (no .mo-pd yet): the photograph fills the frame the page sized. */
:is(.mo-pd, [data-moment="picture-develops"]) { position: relative; overflow: hidden; isolation: isolate; }
:is(.mo-pd, [data-moment="picture-develops"]) > img { display: block; width: 100%; height: 100%; object-fit: cover; }
.mo-pd.is-live > img { transform: scale(1.05); }
.mo-pd.is-developing > img, .mo-pd.is-done > img { transition: transform 1400ms cubic-bezier(.16, 1, .3, 1); }
.mo-pd.is-done > img { transform: none; }
.mo-pd > .mo-pd__print { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; display: block; background: var(--surface); border-radius: inherit; }
.mo-pd.is-developing > img { transform: none; }
@media (prefers-reduced-motion: reduce) { .mo-pd > img { transform: none !important; } .mo-pd > .mo-pd__print { display: none; } }
/* the film look: armed just before it scrolls in, the picture waits as a faint print whose shapes
already read (moment.js then runs the develop on the picture itself); done, failed or still it is
the photograph */
.mo-pd--film.is-armed > img { filter: grayscale(1) contrast(.55) brightness(1.32); transform: scale(1.06); }
.mo-pd--film:is(.is-done, .mo-safe, [data-moment-still]) > img { filter: none; transform: none; }
@media (prefers-reduced-motion: reduce) { .mo-pd--film > img { filter: none !important; } }
/* a failed or finished moment never keeps the print over the photograph */
.mo-pd:is(.is-done, .mo-safe) > .mo-pd__print { display: none; }
/* a printed or saved page never waits for the develop: the photograph stands */
@media print { .mo-pd > img { transform: none !important; } .mo-pd > .mo-pd__print { display: none; } }
@media print { .mo-pd > img { transform: none !important; filter: none !important; } .mo-pd > .mo-pd__print { display: none; } }
@@ -1,5 +1,13 @@
/*
picture-develops - a photograph develops once as its frame comes on screen: first a two-tone
picture-develops - a photograph develops once as its frame comes on screen, in one of two looks.
look "film" (the default on a place, product or service page, <meta name="site-kind" content="other">
or <html data-type="place">): the photograph comes up out of the paper the way a print does in the
developer tray: pale, flat and without colour at first, its darks arriving before its light tones,
then its colour, while it settles from a slight zoom. About 1.6 s, once; transform and a CSS filter
on the one picture, no canvas, any origin.
look "print" (the default on a tech, gaming or software page): first a two-tone
halftone print, ink dots on paper in the ground's surface tone (so the frame keeps its edges and
radius while it develops), its darkest cells first, then the print gives way to the photograph,
which settles from a slight zoom. A picture with a clear subject against a plain field (a rock on
@@ -12,13 +20,26 @@
</figure>
The host sets the picture's size (an aspect-ratio or a height); the photograph fills it (cover).
opts: cell px of the halftone (9, 6-14); duration ms (1800); focus [x, y] 0-1 of the crop (0.5, 0.5).
opts: look "film" | "print" (by the page's kind when left out); cell px of the halftone (9, 6-14);
duration ms (film 1600, print 1800); focus [x, y] 0-1 of the crop (0.5, 0.5).
Give it a photograph big enough to be the section's subject (half the container or more): a small
picture developing reads as a loading state, not a moment.
On a phone the film look runs too (one picture, a filter and a transform, once); the print look
(a canvas) shows the photograph there.
*/
export const phone = true;
// a place, product or service page (site-kind other): the photographic look, never the halftone print
const otherPage = () => {
const m = document.querySelector('meta[name="site-kind"]');
return document.documentElement.dataset.type === 'place' || (!!m && m.content.trim().toLowerCase() === 'other');
};
export default function (root, opts, lib) {
const img = root.querySelector('img');
if (!img) return {};
root.classList.add('mo-pd');
if (lib.reduced()) { root.classList.add('is-done'); return {}; }
if ((opts.look || (otherPage() ? 'film' : 'print')) === 'film') return film(root, img, opts, lib);
if (lib.phone()) { root.dataset.momentStill = 'phone'; root.classList.add('is-done'); return {}; }
const cell = Math.max(6, Math.min(14, +opts.cell || 9));
const dur = Math.max(900, +opts.duration || 1800);
const [fx, fy] = Array.isArray(opts.focus) ? opts.focus : [0.5, 0.5];
@@ -99,3 +120,38 @@ export default function (root, opts, lib) {
const seen = lib.once(root, () => { cover(); run(); }, { threshold: Math.min(0.4, 0.6 * innerHeight / Math.max(1, root.getBoundingClientRect().height)) });
return { destroy() { arm.destroy(); seen.destroy(); finish(); } };
}
// the film look: the picture itself, from a pale flat grey print to the photograph (moment.css holds the
// start under .is-armed from just before the frame scrolls in, so a frame no observer reports keeps its photograph)
function film(root, img, opts, lib) {
const dur = Math.max(1000, +opts.duration || 1600);
let done = false, anim = null;
root.classList.add('mo-pd--film');
const finish = () => { if (done) return; done = true; anim?.cancel(); root.classList.remove('is-armed', 'is-live'); root.classList.add('is-done'); };
const arm = () => { if (!done) root.classList.add('is-armed'); };
const run = async () => {
if (done) return;
arm();
try { if (!img.complete) await new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }); }); } catch { /* shows anyway */ }
if (done) return;
root.classList.add('is-live');
// the darks come first (contrast), the colour last (saturation); the zoom settles over the whole run.
// The change starts on the first frame and runs evenly to a soft landing: a print that sat still for
// half a second would read as a picture still loading
anim = img.animate([
{ filter: 'grayscale(1) contrast(.55) brightness(1.32)', transform: 'scale(1.06)' },
{ filter: 'grayscale(.8) contrast(.82) brightness(1.14)', offset: 0.3 },
{ filter: 'grayscale(.35) contrast(.95) brightness(1.04)', offset: 0.62 },
{ filter: 'grayscale(0) contrast(1) brightness(1)', transform: 'none' },
], { duration: dur, easing: 'cubic-bezier(.3, .2, .3, 1)', fill: 'both' });
root.classList.remove('is-armed');
anim.finished.then(finish, finish);
};
const r0 = root.getBoundingClientRect();
if (r0.bottom > 0 && r0.top < innerHeight * 1.25) arm();
const near = lib.once(root, arm, { threshold: 0, margin: '0px 0px 25% 0px' });
// it develops once a quarter of it is on screen (less of a frame taller than half the screen), so the
// faint print is seen only briefly before it starts to change
const seen = lib.once(root, run, { threshold: Math.min(0.25, 0.4 * innerHeight / Math.max(1, r0.height)) });
return { destroy() { near.destroy(); seen.destroy(); finish(); } };
}
@@ -11,7 +11,9 @@
"pages": [
"tech",
"gaming",
"software"
"software",
"product",
"service"
],
"look": "A feature section holds on screen for two screens of scroll while its three steps open one at a time at the left and one frame at the right changes to each step's picture with a short crossfade.",
"reduced": "Nothing is pinned: from 640px each step is a static row, its text at the left and its visual at the right (at most 30svh tall); on a phone each step stands with its 16:9 visual under it. Two to four steps fit two screens at 1440x900, 1024x768, 820x1180 and 390x844.",
@@ -24,7 +26,8 @@
},
"avoidWith": [
"closing-lift",
"line-carries-colour"
"line-carries-colour",
"photo-chapters"
],
"states": {
"start": {
@@ -1,6 +1,6 @@
/* pinned-chapters: at rest (phones, tablets, a short screen, reduced motion, no script, a failed
module) each step is a title, two lines and its visual, and the section is its --h screens (two) or
grows past them, never over the next section. From 640px that rest is a row per step: the text at the
module) each step is a title, two lines and its visual, and the section is as tall as its steps,
one screen at least, at every width: the second screen it holds when live would stand empty. From 640px that rest is a row per step: the text at the
left, the visual at the right, at most 30svh tall; a phone stacks them with a 16:9 visual, so two to
four steps fit two screens at 390x844. Live (.is-live, from 1024px on a
screen 640px or taller) the section is (hold + 1) screens tall, its stage sticks for the hold,
@@ -21,7 +21,7 @@ main [data-moment="pinned-chapters"][data-moment] > .mo-pc__grid { display: grid
@media (min-width: 640px) {
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__steps { gap: var(--s-6); }
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__step {
grid-template-columns: 5fr 6fr; column-gap: var(--s-8); row-gap: var(--s-2); align-items: center;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: var(--gutter); row-gap: var(--s-2); align-items: center;
border-left: 0; border-top: 1px solid var(--line); padding: var(--s-6) 0 0; }
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__step h3 { grid-column: 1; align-self: end; }
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__step p { grid-column: 1; align-self: start; }
@@ -29,15 +29,17 @@ main [data-moment="pinned-chapters"][data-moment] > .mo-pc__grid { display: grid
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__visual > img { max-height: 30svh; }
}
/* a phone: shorter visuals, and a section that grows past its screens rather than run over the next one */
/* at rest the section is as tall as its steps (one screen at least) and never runs over the next one */
main > [data-moment="pinned-chapters"][data-moment]:not(.is-live) { height: auto; min-height: 100vh; min-height: 100svh; }
/* a phone: shorter visuals */
@media (max-width: 639px) {
[data-moment="pinned-chapters"]:not(.is-live) .mo-pc__visual > img { aspect-ratio: 16 / 9; max-height: 26svh; }
main > [data-moment="pinned-chapters"]:not(.is-live) { height: auto; min-height: calc(var(--hm, var(--h, 1)) * 100lvh); }
}
/* live */
[data-moment="pinned-chapters"].is-live { height: calc((var(--mo-hold) + 1) * 100svh); padding: 0; }
main [data-moment="pinned-chapters"][data-moment].is-live > .mo-pc__grid { position: sticky; top: 0; height: 100svh; box-sizing: border-box; padding-top: var(--nav, var(--nav-h, 72px)); grid-template-columns: 5fr 6fr; gap: var(--s-8); align-items: center; align-content: center; overflow: clip; }
main [data-moment="pinned-chapters"][data-moment].is-live > .mo-pc__grid { position: sticky; top: 0; height: 100svh; box-sizing: border-box; padding-top: var(--nav, var(--nav-h, 72px)); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: var(--gutter); align-items: start; align-content: center; overflow: clip; }
/* (the text and the frame on the page's 6/6 split pair, x 732 at 1440, top-aligned like every split of the page) */
[data-moment="pinned-chapters"].is-live .mo-pc__step { border-left-color: var(--line); transition: border-color 300ms ease; }
[data-moment="pinned-chapters"].is-live .mo-pc__step h3 { color: var(--muted); transition: color 300ms ease; }
/* a closed step's lines collapse to nothing: the grid row goes to 0fr and the inner span (min-height 0,
@@ -8,16 +8,13 @@
"trigger": "scroll",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: it moves a photograph against the scroll, a parallax that reads as a template trick on every kind; a wide photograph stands still in its frame.",
"look": "One wide photograph seen through a frame moves a little slower than the page, so the frame reads as a window onto a larger picture.",
"reduced": "The photograph stands.",
"phone": "The photograph stands in its frame.",
"maxPerPage": 1,
"weight": 0.9,
"weight": 0,
"needs": {
"photo": true
},
@@ -11,11 +11,14 @@
"pages": [
"tech",
"gaming",
"software"
"software",
"place",
"product",
"service"
],
"look": "One large statement rests at half ink (readable, about 4 to 1) and turns to ink word by word as it crosses the middle of the screen, a strong word to the accent; it runs back on the way up.",
"reduced": "The statement in ink.",
"phone": "The whole statement in ink, the accent word in the accent.",
"phone": "It runs on a phone as on a desktop (one paragraph's colours); under reduced motion the whole statement is in ink, the accent word in the accent.",
"maxPerPage": 1,
"weight": 1,
"needs": {
@@ -37,5 +40,6 @@
"view": 0.72,
"wait": 350
}
}
},
"phoneRuns": true
}
@@ -10,8 +10,10 @@
opts: from 0.85 (the statement's top at this share of the screen's height turns the first word),
to 0.45 (its bottom at this share turns the last).
Reduced motion, no script: the whole statement in ink.
Reduced motion, no script: the whole statement in ink. It runs on a phone too (one paragraph's
colours, no layout, no pin).
*/
export const phone = true;
export default function (root, opts, lib) {
const text = root.matches('p, h2, h3, blockquote') ? root : (root.querySelector('.mo-wh__text') || [...root.querySelectorAll('p, h2, h3, blockquote')].find(p => !p.matches('.t-label, .t-small, .muted')));
if (!text) return {};
@@ -8,16 +8,13 @@
"trigger": "loop",
"cost": "low",
"webgl": 0,
"pages": [
"tech",
"gaming",
"software"
],
"pages": [],
"retired": "Not dealt: the word hero carries its own live word (layout-v4.md), and a second rotating word in a headline repeats it.",
"look": "One word of the headline changes every two seconds, sliding up out of its mask as the next rises in, underlined in the accent; three rounds, then it rests.",
"reduced": "The written word stands.",
"phone": "The written word stands; the other words are not shown.",
"maxPerPage": 1,
"weight": 1,
"weight": 0,
"needs": {
"words": "a headline with one word that can take 2 to 4 alternatives (what the product is for)"
},
+100 -45
View File
@@ -6,21 +6,26 @@
shows the 2px lift and the 1.03 zoom). <html data-motion="off"> switches the whole layer off; data-motion="live"
is the number tick in a headless shot (motion.js).
references/motion.md says what moves and how fast. Everything here answers the pointer, the
keyboard or a number coming into view once; nothing moves because the page scrolled.
keyboard or a number coming into view once, plus the hero's one entrance on load (the end of this
file); nothing moves because the page scrolled (that is a dealt moment's job, assets/moments).
Hover rules sit under (hover: hover) and (pointer: fine): a phone never keeps a stuck hover.
Under prefers-reduced-motion colours still change; nothing moves or scales.
Hover moves sit under (hover: hover) and (pointer: fine): a phone never keeps a stuck hover.
Hover out runs 120ms against 150ms in (motion.md, tokens).
Under prefers-reduced-motion colours still change and a press still shows; nothing moves or scales.
*/
:root {
--m-in: 140ms; /* hover in: buttons, links, nav, tabs */
--m-out: 200ms; /* hover out, panel close */
--m-in: 150ms; /* hover in: buttons, links, nav, tabs */
--m-out: 120ms; /* hover out */
--m-press: 90ms; /* a press */
--m-panel: 200ms; /* a menu or sheet opening */
--m-close: 120ms; /* a menu closing */
--m-slide: 240ms; /* an indicator travelling between two tabs or links */
--m-image: 600ms; /* a photograph scaling inside its mask */
--m-ease: cubic-bezier(.2, .8, .2, 1); /* ease-out: fast start, soft landing */
--m-image-out: 400ms; /* ...and settling back */
--m-ease: var(--ease, cubic-bezier(.2, .8, .2, 1)); /* the one UI ease-out (system.css --ease): fast start, soft landing */
--m-ease-io: cubic-bezier(.65, 0, .35, 1); /* the indicator: leaves and lands softly */
--m-ease-enter: cubic-bezier(.22, 1, .36, 1); /* an entrance settling: long soft tail, never past its mark */
--m-cycle: 3.2s; /* one cycle of a card's loop (2.5-4 s) */
--m-lift: -2px;
}
@@ -52,18 +57,22 @@ html:not([data-motion="off"]) .btn:active { transform: translateY(1px) scale(.98
}
/* the arrow: the text arrow (.btn--arrow, .link) nudges 3px; the drawn arrow (.m-arrow) grows its
stem and moves its head 3px, the way a good product site's "Learn more" does */
stem and moves its head 3px, the way a good product site's "Learn more" does. The pointer's move
only for a fine pointer; keyboard focus shows it anywhere */
html:not([data-motion="off"]) :is(.btn--arrow, .link)::after { transition: transform var(--m-out) var(--m-ease); }
html:not([data-motion="off"]) :is(.btn--arrow, .link):hover::after { transform: translateX(3px); transition-duration: var(--m-in); }
html:not([data-motion="off"]) :is(.btn--arrow, .link):focus-visible::after { transform: translateX(3px); transition-duration: var(--m-in); }
.m-arrow { width: 12px; height: 12px; flex: none; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.6;
stroke-linecap: round; stroke-linejoin: round; }
.m-arrow__stem { opacity: 0; transform: scaleX(.2); transform-origin: 1px 50%; transform-box: fill-box;
transition: opacity var(--m-out) var(--m-ease), transform var(--m-out) var(--m-ease); }
.m-arrow__head { transition: transform var(--m-out) var(--m-ease); }
:is(a, button):hover > .m-arrow .m-arrow__stem,
:is(a, button):focus-visible > .m-arrow .m-arrow__stem { opacity: 1; transform: none; transition-duration: var(--m-in); }
:is(a, button):hover > .m-arrow .m-arrow__head,
:is(a, button):focus-visible > .m-arrow .m-arrow__head { transform: translateX(3px); transition-duration: var(--m-in); }
@media (hover: hover) and (pointer: fine) {
html:not([data-motion="off"]) :is(.btn--arrow, .link):hover::after { transform: translateX(3px); transition-duration: var(--m-in); }
:is(a, button):hover > .m-arrow .m-arrow__stem { opacity: 1; transform: none; transition-duration: var(--m-in); }
:is(a, button):hover > .m-arrow .m-arrow__head { transform: translateX(3px); transition-duration: var(--m-in); }
}
/* a pill that is a link (the line above a hero headline) */
html:not([data-motion="off"]) a.pill { transition: background-color var(--m-out) var(--m-ease), border-color var(--m-out) var(--m-ease); }
@@ -98,11 +107,12 @@ html:not([data-motion="off"]) .footer ul a:hover { color: var(--ink); }
/* the nav words take no pill (ST-08, ST-11): system.css draws the underline under a word from the left on hover and focus, and
takes it away to the right; the page you are on keeps its still bar (system.css draws it, at the same inset) */
@media (min-width: 1024px) {
/* the full-width panel: fades and settles 6px in 200ms, its columns follow 30ms apart; it closes
in 120ms. While one menu is open, the next word swaps the panel at once (motion.js .m-menu-open) */
/* the full-width panel: fades and settles 6px in 200ms with its columns together (one surface, read
as one); it closes in 120ms. While one menu is open, the next word swaps the panel at once
(motion.js .m-menu-open) */
html:not([data-motion="off"]) .nav__menu {
transform: translateY(-6px);
transition: opacity 120ms var(--m-ease), transform 120ms var(--m-ease), visibility 0s linear 120ms;
transition: opacity var(--m-close) var(--m-ease), transform var(--m-close) var(--m-ease), visibility 0s linear var(--m-close);
}
html:not([data-motion="off"]) .nav__item:is(:hover, :focus-within) > .nav__menu {
transition: opacity var(--m-panel) var(--m-ease) 150ms, transform var(--m-panel) var(--m-ease) 150ms, visibility 0s linear 150ms;
@@ -112,16 +122,6 @@ html:not([data-motion="off"]) .footer ul a:hover { color: var(--ink); }
still fading in finishes its own 200ms instead of snapping */
html:not([data-motion="off"]) .nav.m-menu-open .nav__menu,
html:not([data-motion="off"]) .nav.m-menu-open .nav__item:is(:hover, :focus-within) > .nav__menu { transition-duration: 0s; transition-delay: 0s; }
html:not([data-motion="off"]) .nav__menu .grid > * { opacity: 0; transform: translateY(4px); transition: opacity 100ms var(--m-ease), transform 100ms var(--m-ease); }
html:not([data-motion="off"]) .nav__item:is(:hover, :focus-within) > .nav__menu .grid > * {
opacity: 1; transform: none; transition: opacity 260ms var(--m-ease), transform 260ms var(--m-ease);
transition-delay: calc(170ms + var(--m-i, 0) * 30ms);
}
html:not([data-motion="off"]) .nav.m-menu-open .nav__item:is(:hover, :focus-within) > .nav__menu .grid > * { transition-delay: calc(var(--m-i, 0) * 30ms); }
html:not([data-motion="off"]) .nav__menu .grid > :nth-child(2) { --m-i: 1; }
html:not([data-motion="off"]) .nav__menu .grid > :nth-child(3) { --m-i: 2; }
html:not([data-motion="off"]) .nav__menu .grid > :nth-child(4) { --m-i: 3; }
html:not([data-motion="off"]) .nav__menu .grid > :nth-child(n + 5) { --m-i: 4; }
html:not([data-motion="off"]) .menu-col a { transition: color var(--m-out) var(--m-ease); }
html:not([data-motion="off"]) .menu-col a:hover { transition-duration: var(--m-in); }
html:not([data-motion="off"]) .menu-card .panel { transition: border-color var(--m-out) var(--m-ease); }
@@ -136,22 +136,20 @@ html:not([data-motion="off"]) .footer ul a:hover { color: var(--ink); }
html:not([data-motion="off"]) .tabs.m-has-ind { position: relative; isolation: isolate; }
html:not([data-motion="off"]) .tabs.m-has-ind .tab { position: relative; z-index: 1; }
html:not([data-motion="off"]) .tabs.m-has-ind .tab[aria-selected="true"] { background: transparent; border-color: transparent; }
/* the pill takes its new size at once and travels by transform alone (motion.js: it starts scaled to
the old tab's size at the old place and settles to scale 1 at the new one), so nothing is laid out
again while it moves and its corners are true at rest */
.m-tab-ind {
position: absolute; left: 0; top: 0; z-index: 0; width: var(--w, 0px); height: var(--h, 0px);
border-radius: var(--r-btn); background: var(--ink); pointer-events: none;
transform: translate(var(--x, 0px), var(--y, 0px));
transition: transform var(--m-slide) var(--m-ease-io), width var(--m-slide) var(--m-ease-io), height var(--m-slide) var(--m-ease-io);
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--sx, 1), var(--sy, 1)); transform-origin: 0 0;
transition: transform var(--m-slide) var(--m-ease-io);
}
.m-tab-ind.is-jump { transition: none; }
html:not([data-motion="off"]) .tab { transition: color var(--m-slide) var(--m-ease), border-color var(--m-out) var(--m-ease), background-color var(--m-out) var(--m-ease); }
/* ---------------------------------------------------------------- focus: the ring settles in */
/* only on the plain controls, never on a part of a loop, and not on an element that runs its own
animation (mark it .m-anim): an animation set here would replace that element's own */
html:not([data-motion="off"]) :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible:not([data-m-loop] *, .m-anim, .loop-pause) {
animation: m-focus var(--m-out) var(--m-ease);
}
@keyframes m-focus { from { outline-offset: 6px; outline-color: transparent; } }
/* ---------------------------------------------------------------- focus */
/* the ring (system.css) is drawn in full in the frame the focus lands; no transition */
/* ---------------------------------------------------------------- cards: a 2px lift */
/* the card rises 2px, its border and shadow deepen; a card without its own box (.card--open) lifts
@@ -179,11 +177,11 @@ html:not([data-motion="off"]) .card--open > .panel { transition: transform var(-
the mask clips it, so the box never moves. .m-zoom is the hook for any other photograph: wrap it. */
.m-zoom { overflow: clip; }
html:not([data-motion="off"]) :is(.frame__img, .tile__media > img, .m-zoom img, .m-zoom > video) {
transition: transform var(--m-image) var(--m-ease);
transition: transform var(--m-image-out) var(--m-ease);
}
@media (hover: hover) and (pointer: fine) {
html:not([data-motion="off"]) :is(a, .card:has(a), .m-zoom):hover :is(.frame__img, .tile__media > img),
html:not([data-motion="off"]) .m-zoom:hover :is(img, video) { transform: scale(1.03); }
html:not([data-motion="off"]) .m-zoom:hover :is(img, video) { transform: scale(1.03); transition-duration: var(--m-image); }
}
/* ---------------------------------------------------------------- inputs, switches, checks */
@@ -192,13 +190,16 @@ html:not([data-motion="off"]) .field :is(input, select, textarea), .m-input {
}
html:not([data-motion="off"]) .field :is(input, select, textarea):hover, .m-input:hover { border-color: color-mix(in oklab, var(--ink) 48%, transparent); }
html:not([data-motion="off"]) .field :is(input, select, textarea):focus, .m-input:focus {
/* the field's own ring in place of the outline: a 2px line in --action (3:1 on the ground) and a soft halo,
there at once like every focus ring (the hover's fade does not apply to focus) */
outline: none; border-color: var(--action);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--action) 20%, transparent); transition-duration: var(--m-in);
box-shadow: 0 0 0 1px var(--action), 0 0 0 4px color-mix(in oklab, var(--action) 20%, transparent); transition-duration: 0s;
}
/* a switch: <button class="m-switch" role="switch" aria-checked="false" aria-label="..."></button>
(motion.js flips aria-checked on click unless the page handles it). The knob slides in 180ms and
stretches 4px while pressed. The off track is ink at 48% (3.3:1 or more against the knob and the
ground, the 3:1 a control needs) */
stretches 4px while pressed, both by transform (a scaleX from its left edge; on, the 12px shift
keeps its right edge where it rests). The off track is ink at 48% (3.3:1 or more against the knob
and the ground, the 3:1 a control needs) */
.m-switch {
position: relative; flex: none; width: 40px; height: 24px; padding: 0; border: 0; border-radius: 999px; cursor: pointer;
background: color-mix(in oklab, var(--ink) 48%, var(--bg));
@@ -207,14 +208,14 @@ html:not([data-motion="off"]) .field :is(input, select, textarea):focus, .m-inpu
.m-switch::before {
content: ""; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; border-radius: 999px;
background: var(--bg); box-shadow: 0 1px 3px rgb(0 0 0 / .25);
transition: transform 180ms var(--m-ease), width var(--m-press) var(--m-ease);
transform-origin: 0 50%; transition: transform 180ms var(--m-ease);
}
.m-switch[aria-checked="true"] { background: var(--action); }
.m-switch[aria-checked="true"]::before { transform: translateX(16px); background: var(--action-ink); }
.m-switch:active::before { width: 22px; }
.m-switch[aria-checked="true"]:active::before { transform: translateX(12px); }
.m-switch:active::before { transform: scaleX(1.22); transition-duration: var(--m-press); }
.m-switch[aria-checked="true"]:active::before { transform: translateX(12px) scaleX(1.22); }
.m-switch:hover:not([aria-checked="true"]) { background: color-mix(in oklab, var(--ink) 58%, var(--bg)); }
/* a check box: <input type="checkbox" class="m-check">, the tick draws in 160ms */
/* a check box: <input type="checkbox" class="m-check">, the tick fades in as it grows from 70% to full size, 160ms */
.m-check {
appearance: none; -webkit-appearance: none; flex: none; margin: 0; width: 20px; height: 20px; border-radius: 6px; cursor: pointer;
border: 1px solid color-mix(in oklab, var(--ink) 48%, var(--bg)); background: var(--bg) no-repeat center / 14px 14px;
@@ -223,11 +224,12 @@ html:not([data-motion="off"]) .field :is(input, select, textarea):focus, .m-inpu
}
.m-check::before {
content: ""; width: 10px; height: 6px; margin-top: -2px; border: solid var(--action-ink); border-width: 0 0 2px 2px;
transform: rotate(-45deg) scale(0); transform-origin: 50% 50%; transition: transform 160ms var(--m-ease);
opacity: 0; transform: rotate(-45deg) scale(.7); transform-origin: 50% 50%;
transition: transform 160ms var(--m-ease), opacity 100ms var(--m-ease);
}
.m-check:hover:not(:checked) { border-color: color-mix(in oklab, var(--ink) 64%, var(--bg)); }
.m-check:checked { background-color: var(--action); border-color: var(--action); }
.m-check:checked::before { transform: rotate(-45deg) scale(1); }
.m-check:checked::before { opacity: 1; transform: rotate(-45deg) scale(1); }
/* ---------------------------------------------------------------- FAQ
Every answer stays open (ST-47, system.css): nothing here opens, closes or animates its height. */
@@ -252,6 +254,49 @@ html:not([data-motion="off"]) .field :is(input, select, textarea):focus, .m-inpu
}
[data-m-loop].is-m-off *, [data-m-loop].is-m-paused * { animation-play-state: paused !important; }
/* ---------------------------------------------------------------- the hero's entrance */
/* Once on load the hero enters (motion.js enter(), motion.md): the h1 line by line out of word masks,
then its lead, each button and its facts 60ms apart, the picture settling; about 1.1 s.
Markup: data-enter on the hero <section> and the one head line that sets html.m-enter before the
first paint (with its 700ms safety mark, m-enter-done). Only the words wait, and only in that window
until motion.js takes over (.is-entering): no script, reduced motion, print and data-motion="off"
show the hero as written; a script later than the mark finds the words fading in here.
The pictures (a photograph, a window, the collage) are never hidden, so nothing blinks or flashes
the hero's ground: a photograph waits at 1.05 and a window or collage at .98, by the scale property
(it composes with any transform of their own), and they settle by transition as soon as the
entrance starts or the mark passes. */
@media screen and (prefers-reduced-motion: no-preference) {
html.m-enter:not(.m-enter-done, [data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entering, .is-entered)
:is(h1, h1 ~ *, :has(> h1) ~ *, :has(~ h1)):not([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage,
:has([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage)) { opacity: 0; }
/* the mark passed before motion.js arrived: the words fade in instead of appearing at once */
html.m-enter.m-enter-done:not([data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entering, .is-entered)
:is(h1, h1 ~ *, :has(> h1) ~ *, :has(~ h1)) { transition: opacity 400ms var(--m-ease); }
/* the settle: a photograph from 1.05 inside its frame (the hero clips one of its own), anything else from .98 */
html.m-enter:not([data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-still)
:is(:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):is(img, picture, video),
:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage) > :is(img, picture, video, .frame__img)) {
transition: scale 1100ms var(--m-ease-enter), clip-path 1100ms var(--m-ease-enter);
}
/* the clip's resting value, kept below the waiting rule's weight so that one wins while it applies */
.hero[data-enter] :is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):is(img, picture, video) { clip-path: inset(0); }
html.m-enter:not([data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-still)
:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):not(img, picture, video, :has(> :is(img, picture, video, .frame__img))) {
transition: scale 900ms var(--m-ease-enter);
}
html.m-enter:not(.m-enter-done, [data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entering, .is-entered)
:is(:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):is(img, picture, video),
:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage) > :is(img, picture, video, .frame__img)) { scale: 1.05; }
html.m-enter:not(.m-enter-done, [data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entering, .is-entered)
:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):is(img, picture, video) { clip-path: inset(2.381%); } /* a picture that is itself the media (a band under the header, a full-bleed layer) has no frame: its own clip keeps it inside its box while larger, (1 - 1/1.05) / 2 */
html.m-enter:not(.m-enter-done, [data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entering, .is-entered)
:is([data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage):not(img, picture, video, :has(> :is(img, picture, video, .frame__img))) { scale: .98; }
html.m-enter:not([data-motion="off"]) .hero[data-enter]:not([data-enter="off"], .is-entered) { overflow: clip; }
}
/* while it runs: each word of the h1 in its own mask (the masks go when the h1 gets its own nodes back) */
.hero[data-enter] .m-w { display: inline-block; overflow: clip; vertical-align: top; padding-bottom: .12em; margin-bottom: -.12em; }
.hero[data-enter] .m-w__in { display: inline-block; }
/* ---------------------------------------------------------------- reduced motion */
@media (prefers-reduced-motion: reduce) {
html:not([data-motion="off"]) .card:hover, html:not([data-motion="off"]) .card--open:hover > .panel,
@@ -259,8 +304,18 @@ html:not([data-motion="off"]) .field :is(input, select, textarea):focus, .m-inpu
html:not([data-motion="off"]) .btn:is(:hover, :active), html:not([data-motion="off"]) :is(.btn--arrow, .link):hover::after,
:is(a, button):hover > .m-arrow :is(.m-arrow__head, .m-arrow__stem) { transform: none !important; }
.m-arrow__stem { opacity: 1; transform: none; }
html:not([data-motion="off"]) .nav__menu, html:not([data-motion="off"]) .nav__menu .grid > * { transform: none !important; }
html:not([data-motion="off"]) :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible { animation: none; }
html:not([data-motion="off"]) .nav__menu { transform: none !important; }
/* the controls keep their state and their colour change, without travel: the tab pill and the switch
knob jump, the tick fades in at full size, the underline is drawn at once */
.m-tab-ind { transition: none; }
.m-switch::before { transition-property: background-color; }
.m-switch:active::before { transform: none; }
.m-switch[aria-checked="true"]::before, .m-switch[aria-checked="true"]:active::before { transform: translateX(16px); }
.m-check::before { transform: rotate(-45deg) scale(1); transition-property: opacity; }
html:not([data-motion="off"]) :is(.link, .m-link, p a:not([class]), .footer ul a, .footer .link) { transition-property: color, opacity, background-color, border-color; }
/* a press still shows, as a deeper tone instead of a move */
html:not([data-motion="off"]) .btn--primary:active { background: color-mix(in oklab, var(--action) 74%, var(--bg)); }
html:not([data-motion="off"]) .btn--secondary:active { background: color-mix(in oklab, var(--bg) 86%, var(--ink)); }
[data-m-loop] * { animation-duration: var(--m-cycle) !important; animation-iteration-count: infinite !important;
animation-delay: calc(-1 * var(--m-still) * var(--m-cycle)) !important; animation-play-state: paused !important; }
}
+147 -10
View File
@@ -4,6 +4,11 @@
<script type="module" src="assets/motion/motion.js"></script>
It starts by itself. references/motion.md says what moves and how fast.
- the hero's entrance: .hero[data-enter] enters once on load (the h1 line by line, then the lead,
the buttons and the facts, the picture settling), about 1.1 s, when the head line set html.m-enter
before the first paint:
<script>document.documentElement.classList.add('m-enter');setTimeout(()=>document.documentElement.classList.add('m-enter-done'),700)</script>
- nav: .m-menu-open from the moment a menu starts to show (150ms of intent), so the next word swaps the panel at once
instead of waiting 150ms again; it stays while the pointer is on the word or its panel.
- tabs: every .tabs[role=tablist] gets one indicator that slides to the selected tab (system.js
@@ -19,7 +24,7 @@
system's pause button (one per panel, shared with ui.js). ui.js restarts its own fragments.
- switches: a click on .m-switch flips aria-checked unless a handler before it called
preventDefault().
Under prefers-reduced-motion: no indicator travel, no tick, no loops, no restart.
Under prefers-reduced-motion: no entrance, no indicator travel, no tick, no loops, no restart.
*/
const html = document.documentElement;
const reducedQ = matchMedia('(prefers-reduced-motion: reduce)');
@@ -57,18 +62,28 @@ function tabs() {
ind.setAttribute('aria-hidden', 'true');
list.prepend(ind);
list.classList.add('m-has-ind');
let instant = true;
let instant = true, at = null;
const set = (b, sx = 1, sy = 1) => {
ind.style.setProperty('--x', `${b.x}px`); ind.style.setProperty('--y', `${b.y}px`);
ind.style.setProperty('--w', `${b.w}px`); ind.style.setProperty('--h', `${b.h}px`);
ind.style.setProperty('--sx', String(sx)); ind.style.setProperty('--sy', String(sy));
};
// the pill travels by transform only: it takes the new tab's size at once, is drawn scaled to the
// old tab's size at the old place, and settles to scale 1 at the new place (motion.css)
const place = () => {
const sel = list.querySelector('[role="tab"][aria-selected="true"]');
if (!sel) { ind.style.opacity = '0'; return; }
ind.style.opacity = '';
const now = instant || reduced();
jump(ind, now);
ind.style.setProperty('--x', `${sel.offsetLeft}px`);
ind.style.setProperty('--y', `${sel.offsetTop}px`);
ind.style.setProperty('--w', `${sel.offsetWidth}px`);
ind.style.setProperty('--h', `${sel.offsetHeight}px`);
if (now) { ind.getBoundingClientRect(); jump(ind, false); }
const to = { x: sel.offsetLeft, y: sel.offsetTop, w: sel.offsetWidth, h: sel.offsetHeight };
if (instant || reduced() || !at || !to.w || !to.h) { jump(ind, true); set(to); ind.getBoundingClientRect(); jump(ind, false); }
else {
jump(ind, true);
set({ ...to, x: at.x, y: at.y }, at.w / to.w, at.h / to.h);
ind.getBoundingClientRect();
jump(ind, false);
set(to);
}
at = to;
instant = false;
};
place();
@@ -252,10 +267,132 @@ document.addEventListener('click', e => {
sw.setAttribute('aria-checked', String(sw.getAttribute('aria-checked') !== 'true'));
});
/* ---------------------------------------------------------------- the hero's entrance */
// Once, on load (motion.md, The hero's entrance): the h1 rises line by line out of its masks, then what
// follows it (the lead, each button, the facts) fades up 60ms apart, while the picture settles from a
// slight scale; about 1.1 s in all, ease-out, transform and opacity only. motion.css hides the words
// (never the picture) only while the head line's html.m-enter is on and its 700ms safety mark
// (m-enter-done) is not; the picture waits at its start scale and settles by a CSS transition, so it
// never blinks however late this file is. Without script, under reduced motion and in print the hero
// simply stands; when this file is later than the mark, the words fade in by CSS and the hero stands.
// The h1 gets its word masks for the length of the entrance and its own nodes back afterwards.
const ENTER_MEDIA = '[data-enter-media], .hero__bg, .hero__media, .hero__stage, .hero__art, .hero__window, .hero-visual, .ly-collage';
const ENTER_TEXT = 'h1 ~ *, :has(> h1) ~ *, :has(~ h1)';
const ENTER_LEAF = 'p, dl, ul, ol, a, button, img, picture, video, figure, blockquote, h2, h3, .pill, .btn';
function maskWords(h1) {
// an h1 another script reworks (layout.js: a live word, letters, a picture in the line) rises whole
if (h1.matches('[data-ly-letters], [data-enter-whole]') || h1.querySelector('.ly-word, .ly-pic, .ly-mark, svg, img')) return null;
const own = document.createDocumentFragment();
own.append(...h1.childNodes);
const copy = own.cloneNode(true);
const words = [];
const walk = node => [...node.childNodes].forEach(n => {
if (n.nodeType === 1) return walk(n);
if (n.nodeType !== 3) return;
const frag = document.createDocumentFragment();
n.textContent.split(/(\s+)/).forEach(part => {
if (!part) return;
if (/^\s+$/.test(part)) { frag.append(part); return; }
const w = document.createElement('span'), i = document.createElement('span');
w.className = 'm-w'; i.className = 'm-w__in'; i.textContent = part;
w.append(i); frag.append(w); words.push(w);
});
n.replaceWith(frag);
});
walk(copy);
const label = h1.hasAttribute('aria-label');
if (!label) h1.setAttribute('aria-label', own.textContent.replace(/\s+/g, ' ').trim());
h1.append(copy);
h1.querySelectorAll('.m-w').forEach(w => w.setAttribute('aria-hidden', 'true'));
// words grouped into the lines the browser set them on
const lines = [];
let top = null;
words.forEach(w => { const t = w.getBoundingClientRect().top; if (top === null || Math.abs(t - top) > 3) { lines.push([]); top = t; } lines[lines.length - 1].push(w.firstChild); });
return { lines, restore() { h1.replaceChildren(own); if (!label) h1.removeAttribute('aria-label'); } };
}
function enterSteps(hero, h1, media) {
// what follows (and precedes) the h1 in its column, as whole blocks in reading order; a box of
// layout (a div with no words of its own) is opened, a button row gives one step a button.
// A picture, window or collage (or a box round one) is never opened: it stands as one block and
// settles by itself (motion.css), even when it carries words of its own (a menu card, a note)
const out = [];
const take = el => {
if (el === h1 || el.contains(h1) || out.includes(el) || el.matches('img, picture, video, canvas, .frame__img')) return;
if (media.includes(el) || media.some(m => el.contains(m))) return;
if (el.matches('.btn-row')) { [...el.children].forEach(take); return; }
const own = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
if (own || !el.children.length || el.matches(ENTER_LEAF)) out.push(el);
else [...el.children].forEach(take);
};
hero.querySelectorAll(ENTER_TEXT).forEach(take);
return out.filter(el => !el.closest('[aria-hidden="true"]') && !out.some(o => o !== el && o.contains(el)))
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
}
function enter() {
const hero = document.querySelector('.hero[data-enter]:not([data-enter="off"])');
if (!hero || hero.classList.contains('is-entered') || hero.classList.contains('is-entering')) return;
// still: the picture takes its place at once (no settle); otherwise the CSS settle and fade run out first
const done = (still = false) => { if (still) hero.classList.add('is-still'); hero.classList.add('is-entered'); };
const late = () => setTimeout(done, 1150);
// the head line did not run, reduced motion, a headless shot (it sees the hero at rest, as it sees the
// final number, unless data-motion="live") or the visitor is already below the hero (a reload half way
// down): the hero stands as written. The safety mark has passed: motion.css is fading the words in and
// settling the picture; the hero is marked done once they are through
if (!html.classList.contains('m-enter') || reduced() || shot() || hero.getBoundingClientRect().bottom < 0) return done(true);
if (html.classList.contains('m-enter-done')) return late();
const h1 = hero.querySelector('h1');
if (!h1) return done(true);
const media = [...hero.querySelectorAll(ENTER_MEDIA)].filter((m, i, all) => !all.some(o => o !== m && o.contains(m)));
const steps = enterSteps(hero, h1, media);
const EASE = 'cubic-bezier(.22, 1, .36, 1)';
const anims = [];
let masks = null, over = false;
const finish = () => {
if (over) return;
over = true;
anims.forEach(a => { try { a.cancel(); } catch { /* gone */ } });
masks?.restore();
hero.classList.remove('is-entering');
done();
};
// the fonts first (a line set in the fallback face breaks elsewhere), 250ms at most: the words are
// hidden meanwhile, so nothing is seen twice
Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise(r => setTimeout(r, 250))]).then(() => {
if (html.classList.contains('m-enter-done') && !hero.classList.contains('is-entering')) { over = true; return late(); } // the safety mark won: CSS shows it
masks = maskWords(h1);
// .is-entering ends the CSS hold: the words are held by their animations from here, and the
// picture starts its CSS settle in the same frame
hero.classList.add('is-entering');
const lines = masks ? masks.lines : [[h1]];
const lineStep = Math.min(80, 240 / Math.max(1, lines.length - 1));
// a word starts wholly below its mask, which reaches .12em under the line: no tops show before it rises
lines.forEach((ws, k) => ws.forEach(w => anims.push(w.animate(masks
? [{ transform: 'translateY(calc(100% + .2em))' }, { transform: 'none' }]
: [{ transform: 'translateY(.35em)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 700, delay: 80 + k * lineStep, easing: EASE, fill: 'both' }))));
// a line above the h1 (a notice, a pill) comes with its first line; what follows starts as its
// last line is half way up
const after = 80 + (lines.length - 1) * lineStep + 220;
let k = 0;
steps.forEach(el => {
const above = !!(el.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING);
// the rise is added to whatever transform the part has of its own; the fade replaces its opacity
const t = { duration: 520, delay: above ? 40 : after + Math.min(k++, 6) * 60, easing: EASE, fill: 'both' };
anims.push(el.animate([{ opacity: 0 }, { opacity: 1 }], t), el.animate([{ transform: 'translateY(12px)' }, { transform: 'none' }], { ...t, composite: 'add' }));
});
// a panel the words sit on stands from the first paint (it was never hidden, so it never blinks);
// the pictures settle by CSS over 1.1 s: the hero is done when both are through
anims.push({ finished: new Promise(r => setTimeout(r, 1150)), cancel() {} });
Promise.all(anims.map(a => a.finished)).then(finish, finish);
}).catch(finish);
setTimeout(finish, 2400); // whatever stalls (a hidden tab), the hero stands after 2.4 s
}
/* ---------------------------------------------------------------- start */
export function init() {
if (html.dataset.motion === 'off') return;
if (html.dataset.motion === 'off') { html.classList.add('m-enter-done'); return; }
if (!html.hasAttribute('data-motion')) html.setAttribute('data-motion', '');
try { enter(); } catch (e) { console.warn('motion.js: enter', e); document.querySelector('.hero[data-enter]')?.classList.add('is-entered'); }
try { nav(); } catch (e) { console.warn('motion.js: nav', e); }
try { tabs(); } catch (e) { console.warn('motion.js: tabs', e); }
try { counts(); } catch (e) { console.warn('motion.js: counts', e); }
@@ -7,9 +7,11 @@ fabric, leather, plastic, a light), soft studio light with soft shadows, a softb
floor shadow, the page's accent on the parts that ask for it. It sways slowly as a turntable, or holds
still; a lamp switches on and off on a click; a robot's joints move.
Use it for a **product without photographs**: the render is the product's picture, in the hero and
the statement. Never for a product that has photographs, never on a place page, never captioned,
never a product of another kind. One product render a page.
Use it when the **brief asks for 3D or a render** (a configurator, a product that exists only as a
model): the render is the product's picture, in the hero and the statement. A product without
photographs and without that ask is shown in photographs, its own or stand-ins
(`references/photos.md`). Never for a product that has photographs, never on a place page, never
captioned, never a product of another kind. One product render a page.
## Mount: the product at anchor scale
+101 -20
View File
@@ -4,19 +4,34 @@
data-radius: sharp, soft or round. data-ground: the lit ground behind UI (sand, sky, sage, or
painting with painterly-landscape). data-buttons: the dealt button look (sections.md, section 2):
solid, pill, tonal, arrow or block; the button markup stays the same. data-motion: the
micro-interactions of assets/motion (references/motion.md); keep it. -->
<html lang="en" data-type="[software]" data-radius="soft" data-ground="sand" data-buttons="solid" data-motion>
micro-interactions of assets/motion (references/motion.md); keep it.
lang: the language the page is written in, from the brief (or its market when the brief does not say): "de" for
a German page, "en" for an English one; screen readers read in it and system.css hyphenates headings by it. -->
<html lang="[en]" data-type="[software]" data-radius="soft" data-ground="sand" data-buttons="solid" data-motion>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>[Name]: [what it is] for [who or where]</title>
<meta name="description" content="[One sentence: what it is, for whom or where, the main benefit.]">
<!-- the head of a shared link and the tab (references/system.md, Head): the title and description above, never the
same words; the card picture is the hero's own tile from shoot.mjs (tiles/desktop-sec-01-a.jpg) copied beside
the page as share.jpg; the icon is the client's logo, else the dealt mark as favicon.svg; theme-color is the
page ground (--p-bg as start.mjs prints it). canonical only once the client's domain is known: until then the
line stays a comment and the report lists it. -->
<meta property="og:type" content="website">
<meta property="og:title" content="[Name]: [what it is] for [who or where]">
<meta property="og:description" content="[The description above, or a shorter line of it]">
<meta property="og:image" content="[share.jpg]">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="[favicon.svg]" type="image/svg+xml">
<meta name="theme-color" content="[--p-bg]">
<!-- <link rel="canonical" href="https://[the client's domain]/"> -->
<!-- the kind decides every effect and motion allowance (SKILL.md section 1: data-type and page-type
carry the page type, site-kind the kind). software: an app, SaaS, AI, developer tool or fintech
(type software, site-kind software); a game (type software, site-kind gaming); hardware or a
tech device (type product, site-kind tech). Every other product or brand (type product), a
service firm or studio (type service) and a place (type place) are site-kind other:
photographs, type and flat colour, nothing moving by itself. A place adds
photographs, type and flat colour; their motion is the hero's entrance, the hover layer and one dealt photo- or type-led moment. A place adds
<meta name="page-kind" content="place">. -->
<meta name="site-kind" content="[software]">
<meta name="page-type" content="[software]">
@@ -39,10 +54,14 @@
<!-- a diagram card (assets/diagrams, references/diagrams.md): its stylesheet here and mount.js after the
other scripts; delete both lines when the page has no diagram -->
<link rel="stylesheet" href="assets/diagrams/diagram.css">
<!-- the hero's entrance (references/motion.md): set before the first paint, after the stylesheets, so the hero's
words wait for motion.js instead of flashing (its picture never waits); the 700ms mark fades them in if the
script is late. Keep it on every page. -->
<script>document.documentElement.classList.add('m-enter');setTimeout(()=>document.documentElement.classList.add('m-enter-done'),700)</script>
<!-- the grounds' page tokens before the first paint (assets/backgrounds; plain, not deferred) -->
<script src="assets/backgrounds/backgrounds.js"></script>
<!-- moments (only when moments.mjs dealt one; else delete these lines): the guard, then one preload
for the core, its lib and each dealt moment (assets/moments/<id>/moment.js). system.js mounts them.
<!-- moments (every kind is dealt one, two on a long page: moments.mjs pick --page <kind>): the guard, then one
preload for the core, its lib and each dealt moment (assets/moments/<id>/moment.js). system.js mounts them.
<script>document.documentElement.classList.add('mo-js');setTimeout(()=>document.documentElement.classList.add('mo-safe'),2500)</script>
<link rel="modulepreload" href="assets/moments/moments.js">
<link rel="modulepreload" href="assets/moments/lib.js">
@@ -51,6 +70,9 @@
<!-- page CSS: only what sits inside a visual and the kit lacks, and one --p-h per card group -->
<style>
#features .panel { --p-h: 300px; }
/* a phone stacks the four tiles into the section's two screens; the chat drops its day line and fades over
40px, so its question and the answer's first line stand clear of the fade */
@media (max-width: 639px) { #features .panel { --p-h: 180px; } #features .ui-chat__day { display: none; } #features .stage { --fade-h: 40px; } }
</style>
</head>
<!-- grounds (tech only; assets/backgrounds/pick.mjs deals 0 to 2 section grounds, never a page ground):
@@ -77,9 +99,12 @@
The logo: the client's own SVG; without one, the dealt mark and the name in the display face:
arc, ring, stack, dots: <span class="mark" data-mark="arc" aria-hidden="true"></span>
initial: <span class="mark mark--initial" aria-hidden="true">K</span> (the name's first letter)
wordmark: no mark, the name alone. The footer repeats it. -->
wordmark: no mark, the name alone. The footer repeats it.
The skip link is the bar's first child and the page's first stop for the Tab key: hidden until focused, then
shown over the logo (system.css .skip-link); its words in the page's language ("Zum Inhalt" on a German page). -->
<header class="nav">
<div class="wrap nav__bar">
<a class="skip-link" href="#top">Skip to content</a>
<a class="nav__logo" href="#top"><span class="mark" data-mark="[dealt mark]" aria-hidden="true"></span>[Name]</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item">
@@ -127,12 +152,13 @@
<main id="top">
<!-- HERO: exactly the first screen; the fixed header lies over its top. Paste the dealt hero from
assets/layout/hero-<id>.html (layout/deal.mjs deals it; heroes.json has its numbers) in place of
this one, keeping its shape: section.hero > .wrap > .hero-text (+ one docked strip at most).
this one, keeping its shape: section.hero > .wrap > .hero-text (+ one docked strip at most), and give it
data-enter (its one entrance on load, motion.md; the head line above makes it run).
A place's hero is never dealt: it is always assets/layout/hero-place.html (Recipe 3, the
photograph under the header with nothing on it, the words under it on the ground).
This default is the split: the words on span-6, the client's photograph on span-6 start-7 (the
page's split pair, the same on every split: 6/6, 5/7 or 7/5), as tall as the hero's content box. The primary button carries the drawn arrow (svg.m-arrow). -->
<section class="hero hero--split" aria-labelledby="hero-h">
<section class="hero hero--split" data-enter aria-labelledby="hero-h">
<div class="wrap">
<div class="hero-text grid hero-split">
<div class="hero-copy span-6">
@@ -181,7 +207,7 @@
<h3>[Verb + object]</h3>
<p>[One or two short sentences with one concrete detail.]</p>
<div class="panel" data-fx="[card effect: a tech page only]" data-fx-seed="21">
<div class="stage stage--fade-b"><div class="at-rise fit" style="--fit-max:400px;--ax:max(24px, 50% - 200px);--ay:48px;--ay-sm:48px" data-ui-loop>
<div class="stage stage--fade-b"><div class="at-rise fit" style="--fit-max:400px;--ax:max(24px, 50% - 200px);--ay:48px;--ay-sm:16px" data-ui-loop>
<div class="ui ui-panel ui-chat" data-ui="chat">
<div class="ui-head"><span class="ui-agent" aria-hidden="true"></span><div class="ui-title">[Name]</div><span class="ui-chat__online">Online</span><span class="ui-spacer"></span></div>
<div class="ui-chat__body">
@@ -203,10 +229,26 @@
</article>
<!-- the second tile of row 1: a different fragment (notify, files with real icons, a diagram
from assets/diagrams) or the client's photograph in the panel (<img class="frame__img">) -->
<article class="card card--row card--open span-6">
<h3>[Verb + object]</h3>
<p>[One or two short sentences with one concrete detail.]</p>
<div class="panel"><img class="frame__img" src="[photo or screen]" alt="[what it shows]" loading="lazy"></div>
</article>
</div>
</div>
<div class="grid">
<!-- the third and fourth tiles, built like the first two, the same --p-h, so both rows match -->
<!-- the third and fourth tiles, built like the first two, the same --p-h, so both rows match; a section
with only two tiles drops this row and the g2 class (one screen) -->
<article class="card card--row card--open span-6">
<h3>[Verb + object]</h3>
<p>[One or two short sentences with one concrete detail.]</p>
<div class="panel"><img class="frame__img" src="[photo or screen]" alt="[what it shows]" loading="lazy"></div>
</article>
<article class="card card--row card--open span-6">
<h3>[Verb + object]</h3>
<p>[One or two short sentences with one concrete detail.]</p>
<div class="panel"><img class="frame__img" src="[photo or screen]" alt="[what it shows]" loading="lazy"></div>
</article>
</div>
</div>
</section>
@@ -237,7 +279,9 @@
<!-- PEEK-LIST (a place's or a service's moment): rows beside one photograph that changes at once to
the row under the pointer, the focus or a tap (system.js). Text left, the photograph on the split
line; every row's words on the left line (the row on show is marked by a dot after its name). -->
line; every row's words on the left line (the row on show is marked by a dot after its name).
A price, date or place the brief does not give carries data-assumed on its own element
(<span class="rows__value" data-assumed>3.60 €</span>): the checker lists each one for the report. -->
<template data-part="peek-list">
<section class="section" id="[section id]">
<div class="wrap">
@@ -261,7 +305,12 @@
(.visit.visit--card on span-6, the photograph on span-6 start-7: the panel on --g-alt, 1024px and up only).
The short page adds .visit--cols (address, hours and access as three columns, no rows) and puts the menu
as .rows under it in the same section. Street and district only; a phone only when the brief gives one;
at most two rows under the columns. -->
at most two rows under the columns.
The way there: "Get directions" only with a street from the brief; with a district and no street the link
says "See the area" (?query=district+town); with only a town, no map link at all (a search for a whole
city shows nobody the door).
Hours the brief does not give are assumed: data-assumed on the dl (the report lists them), and then no
open-now line (delete the <p data-open-now>): system.js never says "Open now" from assumed hours. -->
<template data-part="visit">
<section class="section" id="visit" data-place="visit" aria-label="Visit">
<div class="wrap">
@@ -273,7 +322,8 @@
<a class="link" href="https://www.openstreetmap.org/search?query=[street%20district%20city]">Get directions</a>
</div>
<div class="visit__col">
<!-- data-tz: the venue's time zone; system.js marks today's row there -->
<!-- data-tz: the venue's time zone; system.js marks today's row there. Assumed hours:
<dl class="hours" data-tz=".." data-assumed> -->
<dl class="hours" data-tz="[Europe/Berlin]"><dt>[Mon to Fri]</dt><dd>[8 to 18]</dd><dt>[Sat]</dt><dd>[9 to 16]</dd><dt>Sun</dt><dd>Closed</dd></dl>
</div>
</div>
@@ -331,10 +381,10 @@
<a class="nav__logo" href="#top"><span class="mark" data-mark="[dealt mark]" aria-hidden="true"></span>[Name]</a>
<p class="footer__lede">[What the company does, in one plain line.]</p>
</div>
<div class="span-2 footer__col"><h4>[Nav word]</h4><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h4>[Nav word]</h4><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h4>[Nav word]</h4><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h4>[Nav word]</h4><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#contact">Contact</a></li></ul></div>
<div class="span-2 footer__col"><h3>[Nav word]</h3><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h3>[Nav word]</h3><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h3>[Nav word]</h3><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#[section id]">[Link]</a></li></ul></div>
<div class="span-2 footer__col"><h3>[Nav word]</h3><ul><li><a href="#[section id]">[Link]</a></li><li><a href="#contact">Contact</a></li></ul></div>
</div>
<div class="footer__legal">
<span>&copy; 2026 [Company] &nbsp; <a href="privacy.html">Privacy</a> &nbsp; <a href="terms.html">Terms</a></span>
@@ -371,7 +421,8 @@
<template data-part="links-visit">
<!-- row 2 on a place (footer closing footer--visit): where it is, set large, with the way there;
the hours from the split line; one row of the nav's words. Street and district only. -->
the hours from the split line; one row of the nav's words. Street and district only. The link's words
follow the visit block's: Get directions with a street, See the area with a district only, none with a town. -->
<div class="closing__links">
<div class="grid">
<div class="span-6 stack-4">
@@ -379,7 +430,7 @@
<p><a class="link" href="https://www.openstreetmap.org/search?query=[street%20district%20city]">Get directions</a></p>
</div>
<div class="span-6 start-7 stack-4">
<h4>Opening hours</h4>
<h3>Opening hours</h3>
<!-- data-tz: the venue's time zone; system.js marks today's row there. Never .is-today by hand. -->
<dl class="hours" data-tz="[Europe/Berlin]"><dt>[Mon to Fri]</dt><dd>[8 to 18]</dd><dt>[Sat]</dt><dd>[9 to 16]</dd><dt>Sun</dt><dd>Closed</dd></dl>
</div>
@@ -391,8 +442,38 @@
</div>
</template>
<template data-part="booking-form">
<!-- BOOKING FORM (references/system.md, Forms; sections-library.md booking-form): every field with a label you can
see, the type, autocomplete and inputmode that fit it, its error line kept hidden until a submit fails
(system.js shows it, sets aria-invalid and moves the focus to the first field to fix). data-endpoint: the
address the client's form service gives, the only thing that may show data-sent; without one, action is a
mailto: on .example (a stand-in the report lists) and no success line is shown. data-sent names what was sent
in the button's own words ("Request a table" -> "Your table request is in. We reply within a day."). Six fields
at most; never a modal. -->
<form class="form" action="mailto:[hello@brand.example]" method="post" enctype="text/plain" data-endpoint="" data-sent="[Your table request is in. We reply within a day.]" novalidate>
<div class="field"><label for="f-name">[Name]</label>
<input id="f-name" name="name" type="text" autocomplete="name" required aria-describedby="f-name-err">
<p class="field__error" id="f-name-err" hidden>[Write the name the booking is under.]</p></div>
<div class="field"><label for="f-email">[Email]</label>
<input id="f-email" name="email" type="email" autocomplete="email" inputmode="email" required aria-describedby="f-email-err">
<p class="field__error" id="f-email-err" hidden>[Write an email address with an @, like name@example.com.]</p></div>
<div class="field"><label for="f-date">[Date]</label>
<input id="f-date" name="date" type="text" autocomplete="off" required aria-describedby="f-date-err">
<p class="field__error" id="f-date-err" hidden>[Write the day you want to come.]</p></div>
<div class="field"><label for="f-guests">[Guests]</label>
<input id="f-guests" name="guests" type="text" inputmode="numeric" autocomplete="off" required aria-describedby="f-guests-err">
<p class="field__error" id="f-guests-err" hidden>[Write how many people, as a number.]</p></div>
<div class="btn-row"><button class="btn btn--primary" type="submit">[Request a table]</button></div>
<p class="form__status" role="status" data-form-status></p>
<p class="t-small muted">[We reply within a day.]</p>
</form>
</template>
<!-- ACTION BAR (a place: required; other types may use it): fixed at the phone's foot, the main
action, or Get directions and Book, as the brief allows. Delete it on other pages.
action, or Get directions and Book, as the brief allows. Delete it on other pages. Keep it a child of body,
after the footer: system.js shows it once the hero's button has scrolled away and hides it while the screen shows one of
its actions (the nav's button sits in the sheet), so add no script for it; never a third copy of an action in
a notice or a card.
<nav class="actionbar" aria-label="Quick actions">
<a class="btn btn--primary" href="#[section id]">[Main action]</a>
</nav>
+118 -11
View File
@@ -86,8 +86,8 @@
--nav-h: var(--nav); /* the older name, kept for the kit's files */
--icon: 16px; /* small icons: socials, menu chevrons, mono brand marks */
/* motion */
--ease: cubic-bezier(.2, .7, .2, 1);
/* motion: the one UI ease-out (motion.css --m-ease and ui/base.css --u-ease read it; references/motion.md) */
--ease: cubic-bezier(.2, .8, .2, 1);
--dur: 180ms;
}
html[data-radius="sharp"] { --r-sm: 4px; --r-md: 6px; --r-lg: 8px; --r-btn: 4px; }
@@ -179,6 +179,9 @@ ul, ol { padding: 0; list-style: none; }
h1, h2, h3, h4 {
font-family: var(--font-display); font-weight: var(--display-weight);
letter-spacing: var(--display-tracking); text-wrap: balance; overflow-wrap: break-word;
/* a long word (a German compound at 390) breaks at a syllable with a hyphen, in the language of <html lang>;
only words of 10 letters or more, 4 at least on each side, so a short English heading never breaks */
hyphens: auto; -webkit-hyphens: auto; hyphenate-limit-chars: 10 4 4;
}
h1, .t-h1 { font-size: var(--t-h1); line-height: 1.04; }
h2, .t-h2 { font-size: var(--t-h2); line-height: 1.1; }
@@ -247,18 +250,33 @@ main > :is(section, .section).g2 > :is(.wrap, .container) > :is(.group, .fill) {
}
/* the section is a column and its .wrap takes all of it, so a .fill (a photograph, a frame) still
grows to the section's foot (a min-height percentage would not resolve, leaving it at its least) */
main > :is(section, .section):not(.hero, .section--hero), footer.closing { display: flex; flex-direction: column; }
/* :where() keeps it at no weight, so a page's own phone- or tablet-only hiding (section.desk-only { display: none }) wins */
:where(main > :is(section, .section):not(.hero, .section--hero), footer.closing) { display: flex; flex-direction: column; }
main > :is(section, .section):not(.hero, .section--hero) > :is(.wrap, .container), footer.closing > .wrap { flex: 1 0 auto; height: auto; min-height: 0; }
}
/* a phone: lvh (the screen with its bars hidden) for every section but the hero; --hm when stacking
makes the content taller than the desktop count */
@media (max-width: 639px) {
/* at least its screens, never less: content a screen cannot hold makes the section taller (the rhythm
check then asks for --hm: 2 or less content); a fixed height would lay its last rows over the next
section, the fault the phone shots showed most (a list under the closing heading and its button) */
main > :is(section, .section):not(.hero, .section--hero), footer.closing {
height: calc(var(--hm, var(--h, 1)) * 100vh); height: calc(var(--hm, var(--h, 1)) * 100lvh);
height: auto; min-height: calc(var(--hm, var(--h, 1)) * 100vh); min-height: calc(var(--hm, var(--h, 1)) * 100lvh);
}
/* one grid row at least the screens tall that grows with the .wrap's content: the .wrap is stretched to
it, so its .fill and its two groups still share a definite height */
/* (at no weight, as on the tablet: a page's own section.desk-only { display: none } still hides it) */
:where(main > :is(section, .section):not(.hero, .section--hero):has(> :is(.wrap, .container)), footer.closing) {
display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 1fr; grid-auto-rows: auto;
}
main > :is(section, .section):not(.hero, .section--hero) > :is(.wrap, .container), footer.closing > .wrap { height: auto; }
main > :is(section, .section).g2m, footer.closing.g2m { --hm: 2; }
main > :is(section, .section).g2m > :is(.wrap, .container), footer.closing.g2m > .wrap { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); row-gap: calc(var(--padt) + var(--padb) + var(--bar)); }
main > :is(section, .section).g2:not(.g2m) > :is(.wrap, .container) { display: flex; row-gap: 0; }
/* stacked in one, the two groups keep the open cards' 32px, so the first group's last panel never meets
the next card's title */
main > :is(section, .section).g2:not(.g2m) > :is(.wrap, .container) { display: flex; row-gap: var(--s-6); }
/* what the two screens leave goes around the second group, so it opens below the header, not on the fold */
main > :is(section, .section).g2:not(.g2m) > :is(.wrap, .container) > :last-child { margin-block: auto; }
main > :is(section, .section).g2:not(.g2m) > :is(.wrap, .container) > * { align-self: stretch; }
}
/* a layer: the photograph or ground of a whole section, under its words, edge to edge */
@@ -280,6 +298,13 @@ main > :is(.hero, .section--hero) {
}
:is(.hero, .section--hero) > .wrap { height: 100%; display: grid; grid-template-rows: minmax(0, 1fr) auto; row-gap: var(--s-7); }
:is(.hero, .section--hero) > .wrap > .hero-text { min-height: 0; }
/* a phone: the hero is at least the first screen and grows when a short phone (320x640) cannot hold its
words, buttons and picture, so nothing of it lies over the next section or under the header */
@media (max-width: 639px) {
main > :is(.hero, .section--hero) { height: auto; min-height: 100vh; min-height: 100svh; }
main > :is(.hero, .section--hero):has(> :is(.wrap, .container)) { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 1fr; grid-auto-rows: auto; }
main > :is(.hero, .section--hero) > :is(.wrap, .container) { height: auto; }
}
:is(.hero, .section--hero) > .wrap > .hero-strip { grid-row: 2; max-height: 96px; }
@media (max-width: 639px) { :is(.hero, .section--hero) > .wrap > .hero-strip { display: none; } }
.section--hero .section-head { max-width: 860px; }
@@ -325,6 +350,11 @@ main > :is(.hero, .section--hero) {
/* a phone: open cards (words on the ground, the panel under them) stacked one under another keep 32px
between a panel's foot and the next card's title, so a title never reads as the panel's caption */
@media (max-width: 639px) { .grid:has(> .card--open) { row-gap: var(--s-6); } }
/* a phone stacks a two-screen section's four open tiles into its two screens: their panels take 180px (a wide
crop), so the h2 and the first two tiles of two lines each end on the first screen and the four inside both,
not a third of a screen past. A page that sets its group's own --p-h (#features .panel) sets its phone value
too, as the starter does */
@media (max-width: 639px) { main > :is(section, .section).g2 .card--open > .panel { --p-h: 180px; } }
/* spans: desktop >= 1024. Tablet 640-1023: text cards on span-3 and span-6 go two-up; cards with a
panel (a visual needs the width; the one exception is a span-6 pair, below)
and everything else (split text and visuals, span-4 cards, span-5/7/8 blocks) take the full row, so a split stacks and no row is left half empty. Phone < 640: one column. */
@@ -427,10 +457,10 @@ html[data-buttons="arrow"] :is(.btn--primary, .btn--secondary)::after {
}
html[data-buttons="pill"] { --btn-glyph: 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.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
html[data-buttons="arrow"] { --btn-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M1.5 6h8.5M6.5 2.5 10 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
html:is([data-buttons="pill"], [data-buttons="arrow"]) .btn:hover::after { transform: translateX(3px); }
@media (hover: hover) and (pointer: fine) { html:is([data-buttons="pill"], [data-buttons="arrow"]) .btn:hover::after { transform: translateX(3px); } }
.btn:active { transform: translateY(1px) scale(.985); }
.btn--arrow::after { content: "\2192"; transition: transform var(--dur) var(--ease); }
.btn--arrow:hover::after { transform: translateX(3px); }
@media (hover: hover) and (pointer: fine) { .btn--arrow:hover::after { transform: translateX(3px); } }
/* the drawn arrow (assets/motion: its stem grows and its head moves 3px on hover), the last child of
a primary button, a pill or a link: <svg class="m-arrow" viewBox="0 0 12 12" aria-hidden="true">
<path class="m-arrow__stem" d="M1 6h8"/><path class="m-arrow__head" d="M4.5 2.5 8 6l-3.5 3.5"/></svg>.
@@ -454,7 +484,7 @@ html:is([data-buttons="pill"], [data-buttons="arrow"]) .btn:hover::after { trans
transition: text-decoration-color var(--dur) var(--ease); }
.link:hover { text-decoration-color: currentColor; }
.link::after { content: "\2192"; display: inline-block; text-decoration: none; transition: transform var(--dur) var(--ease); }
.link:hover::after { transform: translateX(3px); }
@media (hover: hover) and (pointer: fine) { .link:hover::after { transform: translateX(3px); } }
.pill { display: inline-flex; align-items: center; gap: var(--s-2); height: 32px; padding: 0 var(--s-3);
border-radius: 999px; border: 1px solid var(--line); background: var(--card);
font-size: var(--t-label); color: var(--ink); }
@@ -737,12 +767,20 @@ html:not([data-tone="dark"]) :is(.frame--cta, .frame--lit, .footer--statement) {
.nav__logo { font-size: clamp(16px, 4.6vw, 20px); gap: 6px; white-space: nowrap; }
.nav__logo .mark:not(.mark--initial) { width: 20px; height: 20px; }
.nav:not(.is-open) .nav__actions .btn { height: 44px; padding-inline: var(--s-3); font-size: var(--t-small); }
/* with the phone action bar the bar's button is the action's copy at the foot: the nav's goes into the sheet, so
the first screen shows it once, the hero's (the bar waits until that has scrolled away; without script, twice
at most, the hero's and the bar's; copy.md rule 5, action-repeat) */
:root:has(> body > nav.actionbar) .nav:not(.is-open) .nav__actions { display: none; }
:root:has(> body > nav.actionbar) .nav:not(.is-open) .nav__toggle { margin-left: auto; }
}
/* no script: the sheet cannot open, so the words stay in the bar (it scrolls sideways) and the dead menu button goes */
@media (scripting: none) and (max-width: 1023px) {
.nav__links { display: flex; overflow-x: auto; scrollbar-width: none; margin-right: var(--s-3); padding-right: var(--s-2); border-right: 1px solid var(--line); } /* stops short of the button, and a hairline marks where the row is cut (it scrolls) */
.nav__toggle { display: none; }
}
/* a phone leaves the words under 80px of bar ("Bre" at 320): they stay in the footer's columns, the bar keeps the name
and the main button */
@media (scripting: none) and (max-width: 639px) { .nav__links { display: none; } }
/* nav types (dealt, sections.md). Every one is the same fixed bar on the page's .wrap; the full-width
hover panel on a desktop and the full-screen sheet on a phone are shared, so only the bar's order
@@ -846,7 +884,7 @@ html:not([data-tone="dark"]) :is(.frame--cta, .frame--lit, .footer--statement) {
/* ---------------------------------------------------------------- footer */
.footer { padding-block: var(--section) var(--s-6); border-top: 1px solid var(--line); } /* an older footer after the last section; footer.closing (below) replaces it */
.footer .grid { row-gap: var(--s-7); }
.footer h4 { font: 500 var(--t-small)/1.4 var(--font-text); letter-spacing: 0; color: var(--muted); margin-bottom: var(--s-4); }
.footer :is(h3, h4):not(.closing__cta *) { font: 500 var(--t-small)/1.4 var(--font-text); letter-spacing: 0; color: var(--muted); margin-bottom: var(--s-4); }
.footer ul { display: grid; gap: var(--s-3); font-size: var(--t-small); }
.footer ul a:hover { color: var(--action); }
.footer__legal { display: flex; flex-wrap: wrap; gap: var(--s-4) var(--s-6); justify-content: space-between;
@@ -974,6 +1012,9 @@ footer.closing.g2 > .wrap { grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
.closing__cta > .close__media { flex: 1 1 0; min-height: clamp(120px, 18svh, 160px); position: relative; overflow: clip; border-radius: var(--r-lg); background: var(--surface); }
.close__media > img { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: cover; object-position: var(--pos, 50% 50%); }
.closing__cta.close--type > .btn--xl { align-self: stretch; }
/* the 640px button (product, service, place) stays within half the .wrap, so it never stands over half of it off its
right line: on a place's 1200px column it is 600 (SR-6) */
@media (min-width: 640px) { .closing__cta.close--type > .btn--xl:not(.btn--wide) { width: min(50%, 640px); } }
/* the name across the .wrap: sized in layout.js-free CSS from the container (8 letters fill 1280) */
.close__word { margin-top: auto; font: var(--display-weight) clamp(64px, 14.5vw, 216px)/.86 var(--font-display); letter-spacing: -0.045em;
white-space: nowrap; overflow: clip; color: var(--ink); }
@@ -995,6 +1036,21 @@ footer.closing.g2 > .wrap { grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
.closing__cta > .close__media { min-height: 140px; }
.close__word { font-size: clamp(56px, 19vw, 96px); }
}
/* a phone: every link and control outside a line of running text is a 44px target (the bar's name, the
footer's links, its legal row and credits line, a text link, the pill, a small button, the socials);
a footer list of links loses its gap, since each row is now its own 44px (a plain row in it, "Two days'
notice", is 44px too, so the rows keep one rhythm); a pill that is only a label keeps its 32px */
@media (max-width: 639px) {
:root { --btn-h-sm: 44px; }
.nav__logo, .footer__mark, .link, :is(a, button).pill { min-height: 44px; }
:is(a, button).pill { height: auto; }
.footer ul:has(> li > a) { gap: 0; }
.footer ul:has(> li > a) > li:not(:has(> a)) { display: flex; align-items: center; min-height: 44px; }
:is(.footer ul, .footer__legal, .footer__nav) a:not(.btn) { display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; }
/* a block, not a flex row: "Credits" stays on the line after the sources instead of wrapping alone */
.credits > summary { display: block; min-height: 44px; padding-block: 12px; line-height: 20px; }
.socials a, .footer__legal .socials a { width: 44px; height: 44px; }
}
/* ---------------------------------------------------------------- the big close (closing-type) */
/* the close starts on the left line like every other heading; the big button runs across the .wrap */
@@ -1069,13 +1125,25 @@ main > .photo-band--hero:first-child > .photo-band__text { padding-top: calc(var
}
.pl-photo--end > img, .pl-photo--end > picture > img { margin-left: auto; }
@media (max-width: 1023px) { .pl-photo > img, .pl-photo > picture > img { width: 100%; height: auto; } }
/* the visit screen on a phone ends in its photograph: the .pl-photo after the words (the last in its row of the
split) takes what they leave of the screen, from its own ratio to 4:5 at most, so the screen never ends in an
empty band (the row becomes a column, its gaps kept) */
@media (max-width: 639px) {
[data-place="visit"] > .wrap:not(.grid) > .grid:has(> .pl-photo:last-child) { flex: 1 0 auto; }
[data-place="visit"] > .wrap.grid:has(> .pl-photo:last-child),
[data-place="visit"] > .wrap > .grid:has(> .pl-photo:last-child) { display: flex; flex-direction: column; }
[data-place="visit"] .grid > .pl-photo:last-child,
[data-place="visit"] .grid > .pl-photo:last-child > picture { display: flex; flex-direction: column; flex: 1 0 auto; }
[data-place="visit"] .grid > .pl-photo:last-child > img,
[data-place="visit"] .grid > .pl-photo:last-child > picture > img { flex: 1 0 auto; max-height: calc(100cqw * 1.25); }
}
/* .tile: an open tile, the picture on top and its words on the page ground under it, no box
(a room, a model, a person, a case). One --tile-ratio per row so the pictures line up. */
.tile { display: grid; gap: var(--s-4); align-content: start; color: inherit; }
.tile__media { position: relative; overflow: clip; aspect-ratio: var(--tile-ratio, 4 / 3); border-radius: var(--r-lg); background: var(--surface); }
.tile__media > img { width: 100%; height: 100%; object-fit: cover; transition: transform 600ms var(--ease); }
a.tile:hover .tile__media > img { transform: scale(1.03); } /* inside its mask; the tile never grows (motion.css has the same) */
@media (hover: hover) and (pointer: fine) { a.tile:hover .tile__media > img { transform: scale(1.03); } } /* inside its mask; the tile never grows (motion.css has the same) */
.tile--portrait { --tile-ratio: 4 / 5; }
.tile--wide { --tile-ratio: 16 / 9; }
.tile__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-4); }
@@ -1161,6 +1229,8 @@ a.tile:hover .tile__media > img { transform: scale(1.03); } /* inside its mask
.rows > li:first-child { padding-top: 0; }
.rows__name { font-weight: 500; }
.rows__value { grid-column: 2; grid-row: 1; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* a narrow phone: a long value ("14 to 22 months") wraps in its own column instead of squeezing the name to a word a line */
@media (max-width: 479px) { .rows__value { white-space: normal; max-width: 11ch; text-wrap: balance; } }
.rows > li > p { grid-column: 1; color: var(--ink); max-width: 56ch; }
.rows--spec .rows__name { font-weight: 400; color: var(--muted); }
@@ -1174,11 +1244,27 @@ a.tile:hover .tile__media > img { transform: scale(1.03); } /* inside its mask
}
.field textarea { height: auto; min-height: 120px; padding-block: var(--s-3); resize: vertical; }
@media (max-width: 639px) { .form { grid-template-columns: minmax(0, 1fr); } }
/* a field that fails on submit (system.js, references/system.md Forms): a 2px border and the reason under it in words,
so colour is never the only sign; the error colour is a red pulled towards the ink, 4.5:1 on a light or a dark ground */
.form { --error: color-mix(in oklab, #d92d20 72%, var(--ink)); }
.field :is(input, select, textarea)[aria-invalid="true"] { border: 2px solid var(--error); }
.field__error { font: 500 var(--t-small)/1.4 var(--font-text); color: var(--error); }
.field__error[hidden] { display: none; }
.field__error::before { content: "!"; display: inline-grid; place-items: center; width: 16px; height: 16px; margin-right: var(--s-2);
border-radius: 50%; background: var(--error); color: var(--bg); font-size: 12px; line-height: 1; vertical-align: -2px; }
/* the status line under the button (role="status"): sending, sent or not sent, in words */
.form__status { grid-column: 1 / -1; min-height: 1.5em; font-size: var(--t-small); color: var(--ink); }
.form__status:empty { min-height: 0; }
/* sections part by a change of ground or by space only: no rule between them (.section--rule is plain) */
/* the phone action bar (a place's directions and booking; other types may use it): fixed at the foot
under 640px, one 342x48 button or two of 165, and every section's foot pads for it (--bar) */
under 640px, one 342x48 button or two of 165, and every section's foot pads for it (--bar).
system.js shows it (data-bar="shown") once the hero's main button has scrolled off the top, and hides it
(data-bar="hidden", inert) while that button, or a control that does what one of the bar's does, is on the
screen: the bar never repeats an action the screen already shows. The hero never pads for it then (the bar
is not on the first screen). Without script it is simply always there at the foot, under every section's
padding, covering nothing. */
.actionbar { display: none; }
@media (max-width: 639px) {
:root:has(> body > nav.actionbar) { --bar: calc(64px + env(safe-area-inset-bottom)); }
@@ -1186,8 +1272,21 @@ a.tile:hover .tile__media > img { transform: scale(1.03); } /* inside its mask
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr);
gap: var(--s-3); align-items: center; height: calc(64px + env(safe-area-inset-bottom));
padding: var(--s-2) var(--gut) env(safe-area-inset-bottom); background: var(--bg); border-top: 1px solid var(--line);
transition: transform 240ms var(--ease), visibility 0s;
}
.actionbar .btn { width: 100%; height: 48px; }
/* hidden: below the screen's edge and out of the focus order (visibility; system.js adds inert) */
.actionbar[data-bar="hidden"] { transform: translateY(100%); visibility: hidden; transition: transform 240ms var(--ease), visibility 0s 240ms; }
:root:has(> body > nav.actionbar:is([data-bar="shown"], [data-bar="hidden"])) main > :is(.hero, .section--hero) { --bar: 0px; }
}
/* with script on, hidden from the first frame until system.js has read the screen, so it never shows over the hero
(data-bar="always": system.js could not wire it, so it stays, as without script, and the hero pads for it) */
@media (max-width: 639px) and (scripting: enabled) {
.actionbar:not([data-bar="shown"], [data-bar="always"]) { transform: translateY(100%); visibility: hidden; }
:root:not(:has(> body > nav.actionbar[data-bar="always"])) main > :is(.hero, .section--hero) { --bar: 0px; }
}
@media (max-width: 639px) and (prefers-reduced-motion: reduce) {
.actionbar, .actionbar[data-bar="hidden"] { transition: none; }
}
/* a brand-colour band (product and place may add one, in the page's second half): the accent as the
@@ -1215,6 +1314,14 @@ a.tile:hover .tile__media > img { transform: scale(1.03); } /* inside its mask
/* ---------------------------------------------------------------- helpers */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* the skip link: the first child of the header's .wrap (starter.html), out of the layout until the first Tab, then
a plain button over the logo on the page's ground, on the .wrap's left line of the fixed header, so it never
moves the logo off that line */
.skip-link { position: absolute; left: max(var(--gut), (100% - var(--container)) / 2); top: 50%; z-index: 2; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; }
.skip-link:focus { width: auto; height: var(--btn-h); padding: 0 var(--s-5); clip-path: none; transform: translateY(-50%);
display: inline-flex; align-items: center; background: var(--bg); color: var(--ink); border: 1px solid var(--line-strong);
border-radius: var(--r-btn); font: 500 var(--t-body)/1 var(--font-text); }
.stack-2 > * + * { margin-top: var(--s-2); }
.stack-4 > * + * { margin-top: var(--s-4); }
.stack-5 > * + * { margin-top: var(--s-5); }
+131 -1
View File
@@ -14,12 +14,20 @@
venue's time zone (each dt names its days, "Mon to Fri", "Sat and Sun", "Daily", or carries
data-days="mon-fri" / "sat,sun"). Without data-tz no row is marked: never the visitor's clock,
and an .is-today written in the markup is taken out (it would be wrong six days a week). A
[data-open-now] line reads "Open now, until 16:00" or "Closed, opens Tuesday at 7:30" from them.
[data-open-now] line reads "Open now, until 16:00" or "Closed, opens Tuesday at 7:30" from them, never from
hours marked data-assumed (the brief did not give them).
- forms: form.form is checked on submit: aria-invalid and the reason under each field to fix, a status line, the
focus on the first; sent by fetch only to its data-endpoint, and data-sent shown only once that answers
(references/system.md, Forms).
- moments: a page with [data-moment] loads assets/moments/moments.js (references/moments.md), which
mounts the dealt moments once; data-ready waits for them.
- peek: [data-peek] holds rows [data-peek-to="key"] and photographs [data-peek-for="key"] (all but
the first hidden); the photograph changes at once (MO-07) to the row under the pointer, the
keyboard focus or a tap. A place's or a service's one moment (references/sections.md).
- phone action bar: body > nav.actionbar shows (data-bar="shown") once the hero's main button has
scrolled off the top, and hides (data-bar="hidden", inert) while that button, or a link or button
that does what one of the bar's does (the same address, or the same words), is on the screen;
never while the focus is in it (references/system.md, Nav).
- visuals (assets/effects/fx.js and assets/ui/ui.js are loaded only when the page has any):
* mounts every [data-fx] ground; only a ground inside the hero (.hero) moves, and not when its frame
holds an object or a scene ([data-print], [data-scene], assets/objects). Every other one is
@@ -196,6 +204,9 @@ function openNow() {
const outs = document.querySelectorAll('[data-open-now]');
const dl = document.querySelector('dl.hours[data-tz]');
if (!outs.length || !dl) return;
// hours the brief does not give (data-assumed on the dl or a row) never say "Open now": a visitor would walk to a
// door that may be shut; the line keeps its own text (empty: nothing shows)
if (dl.matches('[data-assumed]') || dl.querySelector('[data-assumed]')) return;
let parts;
try {
parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: dl.dataset.tz })
@@ -226,6 +237,75 @@ function openNow() {
if (text) outs.forEach(o => { o.textContent = text; });
}
/* ------------------------------------------------------------------ forms
form.form (references/system.md, Forms): checked on submit only, never while typing and never by a button held
disabled before. A field that fails gets aria-invalid="true" and its reason under it (its .field__error, named by
aria-describedby; without one, data-error on the field, else the browser's own words), the status line
([data-form-status], role="status") says how many to fix, and the focus moves to the first. A fixed field clears
as it is typed in. With data-endpoint (the client's form address) the form is sent by fetch: "Sending" shows after
300ms, then data-sent once the address answers 2xx, else data-failed; the button is held only while it sends.
Without data-endpoint the browser sends it its own way (a mailto: stand-in opens the mail app) and no success line
is ever shown. Paste is never blocked. */
function forms() {
document.querySelectorAll('form.form').forEach(form => {
form.noValidate = true;
const status = form.querySelector('[data-form-status]');
if (status && !status.hasAttribute('role') && !status.hasAttribute('aria-live')) status.setAttribute('role', 'status');
const say = text => { if (status) status.textContent = text || ''; };
const fields = [...form.elements].filter(el => el.matches('input, select, textarea') && !/^(hidden|submit|button|reset|image)$/.test(el.type));
const errOf = el => {
const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
let err = ids.map(id => document.getElementById(id)).find(e => e && e.classList.contains('field__error'));
if (!err) {
err = document.createElement('p');
err.className = 'field__error'; err.hidden = true;
err.id = `${el.id || el.name || 'field'}-err-${Math.random().toString(36).slice(2, 7)}`;
(el.closest('.field') || el.parentElement).append(err);
el.setAttribute('aria-describedby', [...ids, err.id].join(' '));
}
// the written reason is kept once, the first time; an empty one stays empty, so a later failure shows the
// browser's message of that moment, never the one an earlier failure left in the line
if (!('text' in err.dataset)) err.dataset.text = err.textContent.trim();
return err;
};
const check = el => {
const ok = el.checkValidity();
const err = errOf(el);
if (ok) { el.removeAttribute('aria-invalid'); err.hidden = true; return true; }
el.setAttribute('aria-invalid', 'true');
err.textContent = err.dataset.text || el.dataset.error || el.validationMessage;
err.hidden = false;
return false;
};
fields.forEach(el => el.addEventListener(el.matches('select') ? 'change' : 'input', () => { if (el.getAttribute('aria-invalid') === 'true') check(el); }));
form.addEventListener('submit', async e => {
const bad = fields.filter(el => !check(el));
if (bad.length) {
e.preventDefault();
say(form.dataset.fix ? form.dataset.fix.replace('{n}', bad.length) : bad.length === 1 ? '1 field to fix.' : `${bad.length} fields to fix.`);
bad[0].focus();
return;
}
say('');
const endpoint = (form.dataset.endpoint || '').trim();
if (!/^https?:\/\//.test(endpoint) && !endpoint.startsWith('/')) return;
e.preventDefault();
const button = form.querySelector('[type="submit"]');
const wait = setTimeout(() => say(form.dataset.sending || 'Sending'), 300);
if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
try {
const res = await fetch(endpoint, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(String(res.status));
clearTimeout(wait); form.reset(); say(form.dataset.sent || 'Sent.');
} catch {
clearTimeout(wait); say(form.dataset.failed || 'Not sent: the connection failed. Your words are still here; try again.');
} finally {
if (button) { button.disabled = false; button.removeAttribute('aria-busy'); }
}
});
});
}
/* ------------------------------------------------------------------ peek
a list beside one photograph that changes to the row under the pointer, the focus or a tap */
function peek() {
@@ -255,6 +335,53 @@ function peek() {
});
}
/* ------------------------------------------------------------------ phone action bar
The bar repeats the page's main actions at a phone's foot, so it waits until the hero's main button has
scrolled off the top, and steps away while a link or button on the screen does what one of its own does (the
same address, or the same words): one screen never shows one action twice. Hidden it is inert (nothing in it
takes the focus or is read), and it never hides while the focus is in it. Without script it is always there,
and every section pads for it (system.css, --bar). */
function actionbar() {
const bar = document.querySelector('body > nav.actionbar');
if (!bar) return;
const set = shown => {
const v = shown ? 'shown' : 'hidden';
if (bar.dataset.bar === v) return;
bar.dataset.bar = v;
bar.inert = !shown;
};
if (!('IntersectionObserver' in window)) { set(true); return; }
set(false);
const words = el => (el.textContent || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().toLowerCase();
const own = [...bar.querySelectorAll('a[href], button')];
const hrefs = new Set(own.filter(a => a.tagName === 'A').map(a => a.href));
const labels = new Set(own.map(words).filter(Boolean));
const twins = [...document.querySelectorAll('a[href], button')].filter(el => !bar.contains(el) && ((el.tagName === 'A' && hrefs.has(el.href)) || labels.has(words(el))));
const hero = document.querySelector('main > :is(.hero, .section--hero)');
const lead = hero ? hero.querySelector('.btn--primary') || hero.querySelector('.btn') || hero : null;
let past = !lead; // no hero: only the twins on the screen decide
const onScreen = new Set();
const update = () => {
const show = past && !onScreen.size;
if (!show && bar.contains(document.activeElement)) return;
set(show);
};
if (lead) new IntersectionObserver(es => {
for (const e of es) past = !e.isIntersecting && e.boundingClientRect.top < 0;
update();
}).observe(lead);
// a twin under the bar's own strip is hidden by it, so it counts once it is above the strip
if (twins.length) {
const io = new IntersectionObserver(es => {
for (const e of es) { if (e.isIntersecting) onScreen.add(e.target); else onScreen.delete(e.target); }
update();
}, { rootMargin: '0px 0px -64px 0px' });
twins.forEach(t => io.observe(t));
}
bar.addEventListener('focusout', () => setTimeout(update));
update();
}
/* ------------------------------------------------------------------ visuals */
let probe;
function rgb(value) {
@@ -460,7 +587,10 @@ function init() {
tabs();
try { faq(); } catch (e) { console.warn('system.js: faq', e); }
try { hours(); } catch (e) { console.warn('system.js: hours', e); }
try { forms(); } catch (e) { console.warn('system.js: forms', e); }
try { peek(); } catch (e) { console.warn('system.js: peek', e); }
// a bar that could not be wired stays shown, as without script, and the hero pads for it again
try { actionbar(); } catch (e) { console.warn('system.js: actionbar', e); const b = document.querySelector('body > nav.actionbar'); if (b) { b.dataset.bar = 'always'; b.inert = false; } }
try { pictures(); } catch (e) { console.warn('system.js: pictures', e); }
try { guards(); } catch (e) { console.warn('system.js: guards', e); }
Promise.all([
+3 -2
View File
@@ -57,8 +57,9 @@
inset 0 1px 0 var(--u-hi),
0 1px 2px color-mix(in oklab, var(--u-sh) 55%, transparent),
0 6px 16px -6px color-mix(in oklab, var(--u-sh) 80%, transparent);
--u-ease: cubic-bezier(0.2, 0.7, 0.2, 1);
--u-spring: cubic-bezier(0.3, 1.3, 0.5, 1);
--u-ease: var(--ease, cubic-bezier(.2, .8, .2, 1)); /* the page's one UI ease-out (system.css --ease) */
/* the name is older than the rule: a strong ease-out that lands softly and never passes its mark (no overshoot, motion.md) */
--u-spring: cubic-bezier(.22, 1, .36, 1);
font-family: var(--ui-font, system-ui, -apple-system, "Segoe UI", sans-serif);
font-size: 13px;
+2
View File
@@ -291,6 +291,8 @@
/* on a phone a fragment fits the panel's width (see the placements), so it is only cut at the bottom:
the side fades go, the bottom fade stays */
/* a phone: the card's link and the plan's button are 44px targets (the link keeps its look, its box grows round it) */
@media (max-width: 639px) { .card__link { min-height: 44px; margin-top: 5px; } .plan__btn { height: 44px; } }
@media (max-width: 639px) {
.stage--fade-r { -webkit-mask: none; mask: none; }
.stage--fade-b, .stage--fade-br {
+481
View File
@@ -6,6 +6,14 @@
"light-neutral",
"mono-accent"
],
"trades": [
"saas",
"devtool",
"fintech",
"law",
"clinic",
"retail"
],
"bg": "#ffffff",
"surface": "#f4f4f4",
"ink": "#111111",
@@ -31,6 +39,14 @@
"tags": [
"light-neutral"
],
"trades": [
"clinic",
"wellness",
"saas",
"fintech",
"law",
"hardware"
],
"bg": "#f7f9fa",
"surface": "#ecf0f2",
"ink": "#1f3038",
@@ -49,6 +65,14 @@
"tags": [
"light-neutral"
],
"trades": [
"cafe",
"bakery",
"studio",
"wellness",
"wedding",
"retail"
],
"bg": "#ebe5e3",
"surface": "#e1dad7",
"ink": "#2b2622",
@@ -66,6 +90,13 @@
"tags": [
"light-neutral"
],
"trades": [
"agency",
"studio",
"restaurant",
"workshop",
"law"
],
"bg": "#e8e8e3",
"surface": "#dcdcd6",
"ink": "#181715",
@@ -89,6 +120,13 @@
"tags": [
"light-neutral"
],
"trades": [
"workshop",
"hardware",
"agency",
"studio",
"devtool"
],
"bg": "#dfdedb",
"surface": "#d3d2ce",
"ink": "#26251e",
@@ -107,6 +145,12 @@
"light-neutral",
"photographic-neutral"
],
"trades": [
"hotel",
"retail",
"consumer-product",
"clinic"
],
"bg": "#ffffff",
"surface": "#f4f4f2",
"ink": "#1f2b4d",
@@ -136,6 +180,14 @@
"light-neutral",
"photographic-neutral"
],
"trades": [
"hotel",
"wellness",
"wedding",
"studio",
"law",
"clinic"
],
"bg": "#eeecea",
"surface": "#e4e1dd",
"ink": "#333231",
@@ -161,6 +213,14 @@
"light-neutral",
"photographic-neutral"
],
"trades": [
"cafe",
"restaurant",
"workshop",
"studio",
"hardware",
"consumer-product"
],
"bg": "#dedad3",
"surface": "#d3cec6",
"ink": "#0a0a0a",
@@ -180,6 +240,14 @@
"light-neutral",
"earth"
],
"trades": [
"cafe",
"bakery",
"hotel",
"wellness",
"restaurant",
"consumer-product"
],
"bg": "#ddd6c6",
"surface": "#d2cab8",
"ink": "#2a2a26",
@@ -205,6 +273,13 @@
"light-neutral",
"earth"
],
"trades": [
"cafe",
"bakery",
"restaurant",
"hotel",
"retail"
],
"bg": "#e5e1d8",
"surface": "#dad5ca",
"ink": "#2a1d17",
@@ -229,6 +304,12 @@
"tags": [
"dark-neutral"
],
"trades": [
"hardware",
"ai",
"fintech",
"game"
],
"bg": "#111111",
"surface": "#1c1c1c",
"ink": "#f2f2f2",
@@ -248,6 +329,13 @@
"tags": [
"dark-neutral"
],
"trades": [
"ai",
"devtool",
"saas",
"fintech",
"game"
],
"bg": "#0a0d14",
"surface": "#131824",
"ink": "#e9edf3",
@@ -268,6 +356,12 @@
"dark-neutral",
"earth"
],
"trades": [
"ai",
"devtool",
"hardware",
"game"
],
"bg": "#161c1a",
"surface": "#1f2725",
"ink": "#e3e2da",
@@ -287,6 +381,10 @@
"dark-neutral",
"mono-accent"
],
"trades": [
"game",
"hardware"
],
"bg": "#0a0a0a",
"surface": "#151515",
"ink": "#efefef",
@@ -311,6 +409,11 @@
"dark-neutral",
"earth"
],
"trades": [
"game",
"devtool",
"ai"
],
"bg": "#0f1f10",
"surface": "#162a17",
"ink": "#ffffff",
@@ -339,6 +442,11 @@
"dark-neutral",
"photographic-neutral"
],
"trades": [
"hardware",
"ai",
"fintech"
],
"bg": "#1c1c1c",
"surface": "#262626",
"ink": "#f1f1ef",
@@ -364,6 +472,11 @@
"dark-neutral",
"photographic-neutral"
],
"trades": [
"game",
"hardware",
"fintech"
],
"bg": "#0c0a08",
"surface": "#181512",
"ink": "#ffffff",
@@ -383,6 +496,11 @@
"mono-accent",
"dark-neutral"
],
"trades": [
"hardware",
"devtool",
"game"
],
"bg": "#18191b",
"surface": "#222427",
"ink": "#eff0f1",
@@ -409,6 +527,12 @@
"mono-accent",
"dark-neutral"
],
"trades": [
"devtool",
"ai",
"saas",
"game"
],
"bg": "#0f0f0f",
"surface": "#1a1a1a",
"ink": "#ffffff",
@@ -439,6 +563,10 @@
"mono-accent",
"dark-neutral"
],
"trades": [
"game",
"ai"
],
"bg": "#0d060b",
"surface": "#1b0e17",
"ink": "#ffffff",
@@ -466,6 +594,13 @@
"tags": [
"mono-accent"
],
"trades": [
"restaurant",
"retail",
"agency",
"consumer-product",
"hardware"
],
"bg": "#ffffff",
"surface": "#f2f2f2",
"ink": "#0a0a0a",
@@ -491,6 +626,12 @@
"tags": [
"mono-accent"
],
"trades": [
"saas",
"ai",
"fintech",
"consumer-product"
],
"bg": "#ffffff",
"surface": "#f3f3f3",
"ink": "#232323",
@@ -518,6 +659,13 @@
"tags": [
"mono-accent"
],
"trades": [
"devtool",
"saas",
"ai",
"fintech",
"hardware"
],
"bg": "#eef0f3",
"surface": "#e2e5ea",
"ink": "#0b0c0e",
@@ -543,6 +691,12 @@
"tags": [
"mono-accent"
],
"trades": [
"bakery",
"cafe",
"retail",
"consumer-product"
],
"bg": "#f7f7f7",
"surface": "#ebebeb",
"ink": "#0d2a1c",
@@ -567,6 +721,13 @@
"tags": [
"two-tone"
],
"trades": [
"clinic",
"law",
"wellness",
"fintech",
"saas"
],
"bg": "#f7f9f8",
"surface": "#ebf0ee",
"ink": "#06525c",
@@ -591,6 +752,10 @@
"two-tone",
"single-field"
],
"trades": [
"agency",
"game"
],
"bg": "#1a2039",
"surface": "#212846",
"ink": "#f9a3c0",
@@ -608,6 +773,12 @@
"tags": [
"two-tone"
],
"trades": [
"wedding",
"studio",
"agency",
"consumer-product"
],
"bg": "#edccbe",
"surface": "#e8c2b2",
"ink": "#351a2b",
@@ -632,6 +803,14 @@
"tags": [
"two-tone"
],
"trades": [
"bakery",
"cafe",
"studio",
"retail",
"agency",
"workshop"
],
"bg": "#e6dbc7",
"surface": "#ddd0b9",
"ink": "#062b8a",
@@ -649,6 +828,13 @@
"tags": [
"two-tone"
],
"trades": [
"restaurant",
"law",
"agency",
"retail",
"studio"
],
"bg": "#e2e2e2",
"surface": "#d6d6d6",
"ink": "#7d0c14",
@@ -673,6 +859,14 @@
"two-tone",
"earth"
],
"trades": [
"restaurant",
"hotel",
"wellness",
"bakery",
"studio",
"law"
],
"bg": "#ecebe7",
"surface": "#e1dfd9",
"ink": "#38450f",
@@ -696,6 +890,10 @@
"tags": [
"two-tone"
],
"trades": [
"agency",
"game"
],
"bg": "#3d0012",
"surface": "#4d0519",
"ink": "#ff99d3",
@@ -714,6 +912,10 @@
"two-tone",
"earth"
],
"trades": [
"restaurant",
"game"
],
"bg": "#133621",
"surface": "#1a4429",
"ink": "#f4d96c",
@@ -731,6 +933,10 @@
"tags": [
"single-field"
],
"trades": [
"agency",
"game"
],
"bg": "#0034eb",
"surface": "#002fd6",
"ink": "#ffffff",
@@ -749,6 +955,10 @@
"tags": [
"single-field"
],
"trades": [
"agency",
"consumer-product"
],
"bg": "#e1ac10",
"surface": "#cd9d0f",
"ink": "#0b0b0b",
@@ -766,6 +976,10 @@
"tags": [
"single-field"
],
"trades": [
"agency",
"game"
],
"bg": "#9c1220",
"surface": "#8a101c",
"ink": "#ffffff",
@@ -790,6 +1004,10 @@
"tags": [
"single-field"
],
"trades": [
"consumer-product",
"agency"
],
"bg": "#ff87ab",
"surface": "#ff739d",
"ink": "#0a0a0a",
@@ -814,6 +1032,10 @@
"tags": [
"single-field"
],
"trades": [
"consumer-product",
"agency"
],
"bg": "#6bb4ee",
"surface": "#7dbdf0",
"ink": "#0e1822",
@@ -843,6 +1065,10 @@
"single-field",
"two-tone"
],
"trades": [
"agency",
"consumer-product"
],
"bg": "#ff7a1f",
"surface": "#ff8633",
"ink": "#050f16",
@@ -868,6 +1094,10 @@
"single-field",
"earth"
],
"trades": [
"restaurant",
"game"
],
"bg": "#472913",
"surface": "#55321a",
"ink": "#e4e0cf",
@@ -895,6 +1125,11 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"game",
"consumer-product"
],
"bg": "#e6e0d2",
"surface": "#dcd5c5",
"ink": "#1a1a1a",
@@ -934,6 +1169,10 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"devtool"
],
"bg": "#ffffff",
"surface": "#f3f3f3",
"ink": "#0a0a0a",
@@ -969,6 +1208,10 @@
"tags": [
"flat-loud"
],
"trades": [
"game",
"agency"
],
"bg": "#191919",
"surface": "#242424",
"ink": "#ffffff",
@@ -1004,6 +1247,10 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"game"
],
"bg": "#1423b8",
"surface": "#1220a6",
"ink": "#ffffff",
@@ -1040,6 +1287,10 @@
"tags": [
"flat-loud"
],
"trades": [
"consumer-product",
"game"
],
"bg": "#ffd249",
"surface": "#f5c63c",
"ink": "#0a0a0a",
@@ -1079,6 +1330,10 @@
"tags": [
"flat-loud"
],
"trades": [
"consumer-product",
"agency"
],
"bg": "#ff8126",
"surface": "#ff8d3a",
"ink": "#111111",
@@ -1115,6 +1370,10 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"restaurant"
],
"bg": "#e2dccf",
"surface": "#d7d0c1",
"ink": "#2a1110",
@@ -1154,6 +1413,10 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"saas"
],
"bg": "#ffffff",
"surface": "#f5f5f5",
"ink": "#000000",
@@ -1189,6 +1452,10 @@
"tags": [
"flat-loud"
],
"trades": [
"agency",
"fintech"
],
"bg": "#ffffff",
"surface": "#f6f6f6",
"ink": "#000000",
@@ -1224,6 +1491,12 @@
"tags": [
"pastel-fields"
],
"trades": [
"agency",
"saas",
"consumer-product",
"studio"
],
"bg": "#f7f7f5",
"surface": "#ebebe8",
"ink": "#000000",
@@ -1259,6 +1532,12 @@
"tags": [
"pastel-fields"
],
"trades": [
"fintech",
"saas",
"bakery",
"retail"
],
"bg": "#f8f8f8",
"surface": "#eeeeee",
"ink": "#0a0a0a",
@@ -1290,6 +1569,12 @@
"tags": [
"pastel-fields"
],
"trades": [
"agency",
"studio",
"consumer-product",
"ai"
],
"bg": "#c3d7e5",
"surface": "#d0e0eb",
"ink": "#141414",
@@ -1330,6 +1615,12 @@
"pastel-fields",
"earth"
],
"trades": [
"studio",
"wedding",
"agency",
"consumer-product"
],
"bg": "#a6b3bc",
"surface": "#b2bdc5",
"ink": "#1c1c1c",
@@ -1366,6 +1657,10 @@
"tags": [
"pastel-fields"
],
"trades": [
"game",
"ai"
],
"bg": "#000000",
"surface": "#0d0d0d",
"ink": "#ffffff",
@@ -1405,6 +1700,11 @@
"tags": [
"pastel-fields"
],
"trades": [
"consumer-product",
"agency",
"studio"
],
"bg": "#cbe4f2",
"surface": "#bcdcee",
"ink": "#0a0a0a",
@@ -1440,6 +1740,10 @@
"tags": [
"photographic-neutral"
],
"trades": [
"hardware",
"consumer-product"
],
"bg": "#a8a8aa",
"surface": "#b4b4b6",
"ink": "#0e0e0e",
@@ -1465,6 +1769,10 @@
"photographic-neutral",
"earth"
],
"trades": [
"hardware",
"game"
],
"bg": "#2b2216",
"surface": "#382d1e",
"ink": "#e4e2da",
@@ -1481,5 +1789,178 @@
"seenIn": [
"opl-the-deep-end"
]
},
{
"id": "warm-paper-walnut",
"name": "Warm paper and walnut",
"tags": [
"light-neutral",
"earth"
],
"trades": [
"cafe",
"bakery",
"hotel",
"wedding",
"studio",
"retail"
],
"bg": "#f7f3ec",
"surface": "#ede6da",
"ink": "#2a2622",
"muted": "#665e55",
"accent": "#7a4a2c",
"accentInk": "#ffffff",
"note": "Warm off-white holds the page with brown-black type and one darker paper step for the visit panel and the goods cards. Walnut brown is the one button, the focus ring and the links; photographs carry the rest of the colour."
},
{
"id": "flour-terracotta",
"name": "Flour and terracotta",
"tags": [
"light-neutral",
"earth"
],
"trades": [
"bakery",
"cafe",
"restaurant",
"retail",
"workshop"
],
"bg": "#f6f1e7",
"surface": "#ebe3d4",
"ink": "#2b241e",
"muted": "#6a5f54",
"accent": "#a3472b",
"accentInk": "#ffffff",
"note": "A flour-white ground with warm brown-black type and a sand step for panels. Terracotta red is the main button and the brand band, never a second fill; the photographs of ovens, tiles and bread bring the warmth."
},
{
"id": "linen-harbour",
"name": "Linen and harbour blue",
"tags": [
"light-neutral"
],
"trades": [
"hotel",
"wellness",
"wedding",
"cafe",
"clinic"
],
"bg": "#f3f1ec",
"surface": "#e6e2d9",
"ink": "#1d2530",
"muted": "#5a6270",
"accent": "#2c5877",
"accentInk": "#ffffff",
"note": "Linen white with blue-black type and one greyer linen step for the booking form and the room cards. A muted harbour blue is the one button and the brand band; nothing else is tinted."
},
{
"id": "limewash-ochre",
"name": "Limewash and ochre",
"tags": [
"light-neutral",
"earth"
],
"trades": [
"bakery",
"hotel",
"studio",
"workshop",
"restaurant"
],
"bg": "#f4efe5",
"surface": "#e7dfcf",
"ink": "#29251e",
"muted": "#675e51",
"accent": "#8c6418",
"accentInk": "#ffffff",
"note": "A limewashed off-white with near-black brown type and a straw step for panels. Ochre is the button and the focus ring, darkened so its white label reads; the ground stays quiet under warm photographs."
},
{
"id": "clay-pine",
"name": "Clay and pine",
"tags": [
"light-neutral",
"earth"
],
"trades": [
"wellness",
"hotel",
"cafe",
"workshop",
"studio"
],
"bg": "#efe9e0",
"surface": "#e2d9cb",
"ink": "#22261f",
"muted": "#5f6158",
"accent": "#2e5b44",
"accentInk": "#ffffff",
"note": "Pale clay holds the page with green-black type and a darker clay step for panels. Pine green is the one button and may fill the brand band at the close; wood, stone and plants in the photographs match it."
},
{
"id": "chalk-tomato",
"name": "Chalk and tomato",
"tags": [
"light-neutral",
"mono-accent"
],
"trades": [
"restaurant",
"cafe",
"retail",
"bakery"
],
"bg": "#f8f5ef",
"surface": "#ede7dc",
"ink": "#1f1b18",
"muted": "#645c54",
"accent": "#b8381f",
"accentInk": "#ffffff",
"note": "Chalk white with near-black type and one warm step for the menu panel. Tomato red is the order button and the brand band and appears nowhere else; the menu stays black type on the ground."
},
{
"id": "plaster-rose",
"name": "Plaster and rose",
"tags": [
"light-neutral"
],
"trades": [
"wedding",
"retail",
"bakery",
"wellness",
"studio"
],
"bg": "#f6f0eb",
"surface": "#ebe0d9",
"ink": "#2c2325",
"muted": "#6a5d5e",
"accent": "#9a3c4c",
"accentInk": "#ffffff",
"note": "Pale plaster with warm near-black type and a pinker plaster step for panels. A deep rose is the one button and the brand band; flowers and fabric in the photographs carry the softer colour."
},
{
"id": "steel-orange",
"name": "Steel and orange",
"tags": [
"light-neutral",
"mono-accent"
],
"trades": [
"workshop",
"hardware",
"retail",
"consumer-product"
],
"bg": "#eeeeeb",
"surface": "#e1e1dd",
"ink": "#1c1d1f",
"muted": "#5c5e62",
"accent": "#c2501b",
"accentInk": "#ffffff",
"note": "Light steel grey with graphite type and one darker grey step for panels and the price rows. Workshop orange is the one button and the focus ring; tools and bikes in the photographs bring their own colour."
}
]
+3 -2
View File
@@ -11,8 +11,9 @@ Section and panel grounds use `--g-main`, `--g-alt` and `--g-dark` only (system.
same order at 390. Photograph layers and the tech grounds below do not count among them.
- **`--g-main`:** white, or a tint of lightness 0.80 to 0.97 with low saturation (channel spread
0.12 or less), taken from the client: a place's walls, paper or shopfront (warm off-white by
default, `#F7F3EC`; white for a shop that sells mostly online), a product's photo backdrop, warm
0.12 or less), taken from the client: a place's walls, paper or shopfront (the palette
`start.mjs deal --page place --trade <trade>` deals for its trade, never one fixed off-white for
every place; white for a shop that sells mostly online), a product's photo backdrop, warm
greys for a service, blue-leaning or cool grey for software. It covers 55% of the page's height or
more (60% on software and product). Never pure-white panels on a cream page.
- **`--g-alt`:** the same hue (within 20 degrees), 0.06 to 0.15 darker, never under 0.70
+3 -1
View File
@@ -39,7 +39,9 @@ Motion: `assets/ui/ui.js`. Objects instead of photographs: `assets/ui/_lab/objec
is 16/24 in that ink: points, the line under a stat, plan lines and lists, a quote's name and role, a
result's bar labels. Muted small text is for labels only (a price's "/ month", a section tag).
`.card--row` holds the lines at 2 from 1024px so titles and panels line up across a row.
- 32px from the last line to the panel. One `--p-h` per card group (page CSS).
- 32px from the last line to the panel. One `--p-h` per card group (page CSS), and a phone value beside it when
the group's tiles stack into a two-screen section (`@media (max-width: 639px) { #features .panel { --p-h: 180px } }`
holds four tiles of two lines each in two phone screens).
## Two levels, never three
+138
View File
@@ -0,0 +1,138 @@
# Copy moves
The move for each part of the page, distilled from a study of professional sites written before
language models (2014 to 2021). Every example here is written for this file, for made-up clients: learn
the move, never the line. The rules of `copy.md` hold throughout: the h1 names the category, every
heading has its noun, buttons are a verb and an object, nothing is invented.
## The hero
Professional heroes are short (2 to 9 words), one clause, never two adjectives stacked. On a tasteskill
page the h1 names the category, so the form is chosen among the ones that do:
| Form | Example | When |
| --- | --- | --- |
| Category and buyer | "Payroll app for restaurant teams" | software, a service nobody knows yet |
| Category and place | "Bakery on Mill Street, Leith" | every place |
| Category and a true, unexpected promise | "Tax return app that asks eleven questions" | the category is known and the twist is in the brief |
| Category and one striking customer | "Rota planner for the night shift at three hospitals" | the brief names that customer and it may be shown |
| Category and scope | "Card payments for market stalls" | infrastructure, B2B with a real scope |
| Category, place and year | "Guesthouse on the lake shore since 1962" | the brief gives the year |
| A narrow superlative | "The only ferry to the island in winter" | the brief proves it, and the next line says how |
| Name and genre | "Kelpmoor, a co-op sailing game" | a game |
The other forms professionals use (a greeting, a line to the reader's state of mind, a question, an
imperative, an enemy, a twisted idiom, product news) never take the h1. They can take the sub-line, a
section's first line, the close, or the page's one brand line, each with a fact beside it:
- **The reader's state:** "Three hundred emails since Friday? The inbox sorts them by sender before you
open it."
- **An enemy, a habit or a category:** "Most booking forms ask for your life story. Ours asks for two
dates."
- **Product news, plain:** "New this spring: the 12-litre model, same price."
**The sub-line:** software and services: how it works and the main benefit, 15 to 30 words, one fact or
one mechanism, never a row of adjectives. A place: the fact with today's hours, 80 characters at most.
## Section headings
- **The name with a claim under it** is the default: a heading that names the part ("Rooms and
prices") and one line under it that claims ("Six rooms, all with the lake on one side").
- **A verb and the thing** works where the reader does something: "Send invoices from the van", "Plan
the week's shifts". Never a bare command ("Get started", "Come climb").
- **A plain claim with its noun:** "Pays staff on the same day".
- **A figure from the brief:** "212 routes, reset every Tuesday".
- **Questions only in the FAQ.** A heading is never a question (`copy.md`); the line under the close's
heading may ask once, plainly, when the register allows it.
## About and story
1. **Start in a moment the brief gives, not a thesis:** the day the oven arrived, the first market
stall, the season the river flooded the cellar.
2. **Anecdotes, a timeline or questions, never value pillars** ("Our values: quality, community,
passion" is the weakest about page there is).
3. **Failure and luck belong in the plot** when the brief has them: the first plan that did not work,
the landlord who said yes.
4. **Founders as people, not titles,** and only the names the brief gives.
5. **Values as behaviours with numbers:** "We pay growers twice the market price"; a rule, not an
adjective.
6. **End in a modest present tense,** not a vision: "Eleven of us now, and the same oven."
7. **Say how to reach them:** the form, the street, the hours.
A portfolio's about: one line with what the person does (a deflating word welcome), what they make and
for whom, one or two projects with dates, one sentence of belief, availability as a plain date.
## Features and how it works
- **The mechanism in verb-led steps:** "Forward the invoice. The app reads the total and the due date.
It pays on the day you choose."
- **The outcome first, the feature as its proof:** "Nobody misses a shift change", then how the
reminder works.
- **A moment, not a use case:** "Your drummer cancels at six; the stand-in gets the set list at
6:01."
- **Real values in the examples:** times, sizes, amounts, never "customisable".
- **The product doing the work:** UI fragments with the client's own data, names and numbers
(`cards.md`). On tech the UI carries most of the words (`copy.md`, the tech word budget).
- **Names are copy too:** rooms, plans, blends and models the brief names, used the same way every
time.
- **Card length varies with the material:** a title (a verb and an object) and one or two sentences;
a card the brief knows a lot about may run longer than its neighbours.
## Proof
- **Only what the brief gives:** quotes word for word with the name and role or town, figures with
their source, logos of real customers who may be shown.
- **Let others say the superlatives,** with full attribution.
- **Rough beats polished:** a real quote stays as written, lowercase and all.
- **A rule instead of a claim:** "Every frame is brazed by the person who measured you" proves the
craft without the word.
- **None is fine.** Craft, luxury and small places often show no testimonials: the photographs and the
facts carry the page.
## Prices, FAQ, booking: the plain zone
- **The tone drops to facts:** the reader is anxious here.
- **Perks priced and literal:** "Late checkout until 14:00 for 15 euro", "Bike hire 12 euro a day".
- **A risk reversal beside the button, only when true:** "Cancel up to two days before, free".
- **Admit limits in the FAQ:** "Android comes next year; today it is iPhone only."
- **Ask the buyer's real objection and answer it flatly:** "Can I take my data out? Yes: every table
exports as CSV, any time."
## Buttons and the close
- **A verb and the real object** (`copy.md` rule 4): "Book a room", "Order a cake", "Start a free
trial".
- **Literal even when the register plays:** the button says what happens next.
- **A low-pressure exit for browsers** where the register fits: "See the rooms first".
- **The close restates the hero's promise with one fact** from the brief, and the main action word for
word.
## Microcopy: where a voice is most welcome
Write these whenever the page has them; a stock form error under a hand-tuned hero gives the game
away. The form rules of `copy.md` hold (a label says what goes in, an error names the field and what to
type, the sent line names what was sent in the button's words); the register shows in the words around
them:
- **Form error:** "Mondays we are closed: choose a day from Tuesday to Sunday."
- **Sent message:** "Your table request is in. We answer before noon the next day." (the time from the brief)
- **Empty state:** "No notes yet. Your first meeting lands here when it ends."
- **404:** "This page moved. The menu and the hours are one click up."
- **Newsletter confirmation:** "Subscribed. The next letter goes out on the first Sunday of the
month." (the rhythm from the brief)
- **Field hint:** "Only for this booking." (and only what the brief promises)
## The footer
Facts only: the legal row of `copy.md` (the brand, this year, Privacy, Terms, the credits line), the
street and district, the hours on a place. No sign-off, no pun built on the name.
## Numbers and humour
- **Odd human numbers beat round big ones:** "eleven people came to the first tasting", "45 seconds
per side", when the brief gives them.
- **Every number sits in a sentence,** beside the claim it proves.
- **Humour points at ourselves, never the customer,** and lives in brackets, asides and microcopy, not
at the end of every paragraph. None on a serious purchase (money, health) beyond the microcopy.
- **Rhythm on purpose, once:** a run of short sentences that paints a scene, a list that ends on a
surprise, one long sentence beside a short one.
@@ -0,0 +1,154 @@
# Copy registers
The voices to choose from, drawn from how professional sites wrote before language models (2014 to
2021). Choose one per client; borrow a single move from a second. Every sample line is written for this
file, for a made-up client. A register shapes the sub-line, the body, the story and the microcopy; it
never bends the rules of `copy.md` (the h1's category, the headings' nouns, the buttons' verbs).
**None is the default.** Choose by the buyer, the price and the client's real character; when the brief
does not settle it, take the register least like the last page this session wrote.
## 0. The model's default (the one to avoid)
Warm, wry, modest, a little literary, mildly against big companies. Paragraphs end on a deadpan twist;
sincerity comes from honest, calm, quiet, actually; a strip of figures, three polished quotes, a pun in
the footer. Use it only when the client asks for it, and then within the device budget.
## 1. Facts only
- **Sounds like:** "Kessler Partners advises family firms on succession. Founded 1994, nine partners,
offices in Basel and Freiburg."
- **Moves:** plain declaratives with names, dates and counts; third person or a terse first person;
exclusivity shown by a rule or a fact, never by an adjective.
- **Fits:** a strong track record, professional services, B2B infrastructure, clubs, consultants.
- **Fails when:** the facts are weak. Then it reads as an empty CV.
## 2. Technical and dry, with proof
- **Sounds like:** "Every pull request gets its own database, seeded from last night's backup. Branch
in 4 seconds; delete it on merge."
- **Moves:** a label paired with a hard figure; the mechanism in verb-led steps; real commands, UI
strings and file names on the page; the enemy is a category ("most preview setups"); jargon kept on
purpose for experts or translated once; the product as the subject of the sentence.
- **Fits:** developer tools, infrastructure, APIs, technical software.
- **Fails when:** it turns into a spec dump with no pain named, or into abstractions ("power and
alignment").
## 3. The founder's letter
- **Sounds like:** "We ran a print shop for eleven years and lost a day a week to quotes. So we built
the quoting tool we wanted, and now 300 shops use it."
- **Moves:** first person; a named habit or a category as the enemy; the reader's pain in their own
words; a refrain used on purpose; an argument carried across the sections, each paragraph within
`copy.md`'s lengths; a plain human sign-off in the text (never the footer).
- **Fits:** products with a stance, small software companies, anything founder-led and against the way
things are done.
- **Fails when:** the stance is put on, or the founder has no real argument.
## 4. Self-aware and playful
- **Sounds like:** "Hot sauce from one farm in Kent. Medium is hot. Hot is a mistake you make once."
- **Moves:** a straight face about a small product; the joke in the sub-line or a card, with the fact
right under it; house words the brief gives; the microcopy in full voice; failures on display. Hype
words stay out even as a joke: copy-lint FAILs them, ironic or not.
- **Fits:** drinks, snacks, small consumer goods, small studios, playful apps.
- **Fails when:** the jokes are random, aimed at the customer, or put on a serious purchase (money,
health).
## 5. Literary and formal
- **Sounds like:** "A balm for hands worn by cold water and rope. Citrus, cedar, a little smoke. 75 ml,
34 euro."
- **Moves:** an exact, slightly raised vocabulary; "clients", not "customers"; sensory notes in tight
lists; materials and botanicals by name; never an exclamation mark; the price plain on the button.
- **Fits:** skincare, fragrance, design objects, high-end retail.
- **Fails when:** it turns ornate without being exact ("a luxurious ritual of the senses").
## 6. Proper nouns and restraint
- **Sounds like:** "The old customs house above the harbour, built 1871. Eight rooms face the strait;
the ferry to the island leaves below the terrace at 7:40."
- **Moves:** places, history, buildings and geography do the work; unhurried physical verbs (overlook,
stroll, reside); no prices in the hero, no quotes; the story in three sentences and a date. The word
"luxury" never appears.
- **Fits:** luxury hotels, heritage houses, architecture practices, galleries.
- **Fails when:** there is no real place or history in the brief to name.
## 7. Sensory facts, second person, present tense
- **Sounds like:** "You order at the counter beside the roaster. The cardamom buns come out at 8:00
and again at 11:00."
- **Moves:** what the reader will see, smell, hear or carry, each one a literal fact of the place (the
oven, the hour, the floor, the weight); craft shown as a repeated step and a constraint; one defining
absence ("no mirrors in the studio"); odd human numbers. Never a metaphor or a mood paragraph: the
floor of `copy.md` holds.
- **Fits:** cafes, food, bars, fitness, yoga, spas, anything consumed with the body.
- **Fails when:** every section turns sensory. Prices and booking stay plain.
## 8. Historical scene
- **Sounds like:** "In 1958 the tram stopped outside, and the bakery opened at five for the drivers. It
still does."
- **Moves:** opens on a dated moment, not on the brand; streets, furniture and people named; old
photographs as evidence, never captioned on the page (`copy.md`).
- **Fits:** restaurants, family firms, anything with a real lineage in the brief.
- **Fails when:** the history is invented or generic ("rooted in tradition").
## 9. Plain-spoken activist
- **Sounds like:** "Every jacket we sell, we take back. In 2025 that was 3,140 jackets, and 2,200 went
back on sale."
- **Moves:** a mission in one measurable sentence; the hard subject said plainly; admits its own harm;
buttons that act on the mission; proof in numbers and reports.
- **Fits:** mission-led consumer brands, non-profits, firms with real impact figures in the brief.
- **Fails when:** the mission is a paragraph of abstract nouns ("inspiring healthier communities").
## 10. Refusal and narrowness
- **Sounds like:** "We repair bicycles. Not e-bikes, not scooters. Bicycles, in two days, at the bench
you can see from the street."
- **Moves:** positioned by what it will not do; scarcity stated as an honest limit; the makers named
when the brief names them; a ritual as community.
- **Fits:** makers, small batches, specialists, single-product companies.
- **Fails when:** the refusal is invented to sound focused. (It spends the page's negation run.)
## 11. The product as the hero
- **Sounds like:** "The X2 runs 140 minutes on a charge and climbs a 2 cm sill. It maps the flat on the
first run."
- **Moves:** almost no "we"; the product is the subject of every sentence; a turn of phrase is followed
at once by a spec; one moment from life ("crumbs under the sofa at 22:00").
- **Fits:** hardware, physical products, apps with one strong feature.
- **Fails when:** a play on words has no fact under it.
## 12. Hurried and conversational
- **Sounds like:** "Packaging for small food brands, by three people in one studio in Bristol (the one
who answers the phone also draws the box)."
- **Moves:** talks to one busy person; asides in brackets; self-deprecation; casual section labels that
still name the thing; permission to leave.
- **Fits:** small studios, freelancers, portfolios, direct-to-consumer brands with a founder.
- **Fails when:** it gets chummy ("We get it.") or the jokes replace the information.
## Matching voice to the trade (starting points only)
| Trade | Try first | Avoid |
| --- | --- | --- |
| Cafe, roastery, bakery | 7 sensory, 8 historical, 4 playful | 0 the default |
| Developer tool, infrastructure | 2 technical-dry, 3 founder's letter | 4 playful in the hero |
| Software for small businesses | 3 founder's letter, 11 product as hero | abstract "power and alignment" |
| AI product | 2 technical-dry, 11 product as hero | "magic", "superpowers" |
| Agency, studio | 1 facts only, 12 hurried, 4 playful | stacked category nouns ("digital brand experience company") |
| Portfolio | 12 hurried, 1 facts only | a list of credits with no person in it |
| Luxury hotel | 6 restraint | the word "luxury" |
| Guesthouse, small hotel | 12 hurried, 7 sensory, 6 restraint | "boutique", "curated experiences" |
| Fitness, yoga | 7 sensory, 10 refusal | "the best version of yourself" |
| Architecture | 6 restraint, 1 facts only | "spaces that inspire" |
| Course, teaching | 3 the teacher's letter, 7 sensory for a physical skill | "transform your life" |
| Skincare, beauty | 5 literary, 10 refusal | "clean", "glow" with no ingredient |
| Finance | 1 facts only, 3 founder's letter | jokes about money worries, unsourced savings |
| Hardware, appliances | 11 product as hero, 2 technical-dry | adjectives where the spec should be |
| Game | the game's own world and tone, 12 for the studio | the default register on the store page |
| Drinks, snacks | 4 playful, 9 activist | 0 the default |
| Repair shop, trade | 10 refusal, 1 facts only | "passion", "quality you can trust" |
| Wedding, events, photography | 7 sensory, 12 hurried | "capturing your special day" |
+126
View File
@@ -0,0 +1,126 @@
# Copy tells
What makes website copy read as model-written, with the fix for each. Dated November 2026: the tells
move with every model generation, so learn why each one reads as a model and do not just memorise the
list. One tell proves little; clusters and repetition are the signal. `scripts/copy-lint.mjs` counts the
ones that can be counted; its id is in brackets.
Severity: **ban** (one is a tell), **cap** (fine once, a tell when repeated), **context** (counts only
beside other tells).
## Why it happens
A model picks the phrasing that fits the most pages, and tuning smooths it toward safe, positive,
dense prose. The odd true fact gets replaced by a claim that fits any business. So the specific has to
come from the input (the brief, the client's words), never from style rules; and every rule that bans a
habit needs a positive move beside it, or the model reaches for the nearest substitute.
## First generation: the old tells (mostly avoided now; still checked)
- **Ban words** [copy-slop-word]: delve, tapestry, testament to, realm, embark, beacon, multifaceted,
paradigm, myriad, plethora, meticulous, intricate, interplay, garner, boasts (for "has"); elevate,
unlock, unleash, empower, harness, supercharge, turbocharge, seamless, effortless, cutting-edge,
revolutionary, transformative, holistic, next-level, next-generation, world-class, best-in-class,
game-changer, game-changing, state-of-the-art, bespoke, synergy, leverage, reimagine, powerful,
robust, nestled, unparalleled, unrivalled. Each stands in for a fact: "seamless" for the setup steps,
"robust" for the failure case, "powerful" for the figure. A word that is literal for the trade stays:
a climbing harness, bespoke tailoring, a seamless knit, an elevated terrace, a game's realm. So
does the client's own name (Elevate Pilates) where the logo, the title before its ":" or
`og:site_name` writes it: one WARN [copy-brand-word], kept when the brief gives the name.
- **Stock lines** [copy-stock-phrase]: "In today's fast-paced world", "Unlock the power of", "The
future of X is here", "Say goodbye to", "Whether you're A or B", "Join thousands of", "Trusted by teams
worldwide", "Imagine a world where", "Ready to transform your X?", "Look no further", "one-stop shop",
"X made simple", "at your fingertips", "in just a few clicks", "It's not just a X, it's a Y", "Not
only X but also Y", "Here's the thing", "Let that sink in".
- **Residue** [copy-residue]: "Certainly!", "I hope this helps", "[Company Name]", lorem ipsum,
`{{tokens}}`, markdown asterisks or `##` on the rendered page, a `[NEED]` left in. The strongest single
tell: a conversation or a template pasted into the page.
- **Shapes:** the -ing rider tacked onto a fact (", ensuring a smooth start") [copy-ing-rider]; the
question answered at once ("The result? Three hours back.") and the colon reveal ("One thing changed:
X") [copy-reveal]; "serves as", "stands as", "plays a key role" where "is" or "has" would do
[copy-puffery].
- **Punctuation:** em dashes everywhere [copy-em-dash]; exclamation marks in body copy
[copy-exclaim]; emoji as bullets; an arrow after each link's words, whatever the link.
- **Dead metaphors:** landscape, ecosystem, journey (unless it is travel), "in the X space", hub,
"the world of", "where X meets Y", "in the heart of" [copy-puffery]. Name the street or the thing.
## Second generation: the tells now
These look like good copy, which is why they survive review.
- **The house voice** (the meta tell). Warm, wry, modest, a little literary, for every client. Fix:
a register from `copy-registers.md`, chosen per client.
- **Invented specifics** (ban). "6:12 a.m., from the window of Room 9", "Batch No. 047", "38% fewer
re-runs": detail produced as decoration because detail persuades. Fix: the brief's details only;
without them, a plainer line.
- **The strip of figures** [copy-stat-strip]. A row of three or four figures, each over a one-word
label, under the hero or the story, on nearly every model page. Fix: each number in the sentence it proves; a strip only
of plain facts the brief gives (a price, the hours, a count). A menu's prices, a timetable and a column
of one unit are not strips.
- **Two-fragment headings "X. Y."** (cap 1) [copy-two-fragment]. "Twelve rooms. Three ways to stay."
Fix: the other heading shapes in `copy-moves.md`: a name with a claim under it, a verb and the thing,
a plain sentence.
- **Number-pair headings** (cap 0 to 1) [copy-number-pair]. "Four projects, four very different
problems." Two unrelated model pages wrote almost exactly this. Fix: say what the section holds.
- **Contrast frames and their substitutes** (cap 2 in all) [copy-contrast]. "Freshness isn't a
slogan here." Suppress "not X but Y" and it returns as "rather than", "instead of", "without the".
Fix: state the positive fact alone. A buyer's question in the FAQ is not the device.
- **Negation runs** (cap 1) [copy-negation]. "No forms, no back-and-forth." Keep one only when each
item is a refusal the buyer can check.
- **Virtue words** (ban about ourselves) [copy-virtue]. honest, calm, quiet, actually, genuinely,
truly, simply, thoughtful, carefully, intentional, steady, dependable. Fix: show it with an admission
or a fact. A page never calls itself honest.
- **The dry closer.** "They hold spoons now." on the end of most paragraphs; the same joke on two
unrelated sites. Fix: end on information; the humour moves to the microcopy.
- **The footer sign-off** [copy-footer-signoff]. "Made in Rotterdam, fired at 1,260 degrees."
"Every Tuesday since 2019." Fix: the legal row of `copy.md`, nothing else.
- **The name's metaphor run into the ground.** A brand called Kiln that "fires" every section. Fix:
once, or never.
- **The origin template.** "In [year], [two first names] found [an old hardware store] and kept [one
original detail]." Fix: the brief's real story; with none, no story section.
- **Polished testimonials** [copy-quotes]. Always three, each a small story with a number and a
punchline. On a tasteskill page there are none unless the brief gives them, word for word.
- **The default cast** [copy-cast, copy-cast-company]. People: Priya (Raman, Shah, Nair), Daniel or
Amara Okafor, Marcus, Dana, Aisha, Lena M., Tomas, Nora, Maya, Mara and Theo, Isla, Ines, Jess, Sarah J.,
Elara. Companies: Fieldnote, Kestrel, Halcyon, Brightloop, Acme, Northwind, Lumen, Nimbus, Apex, Zenith,
Vertex. Fix: person names only from the brief; a mock's names fit its town and generation; a coined
brand is none of these (`copy.md`, names.json).
- **The section template.** Eyebrow, claim heading, short lede, three or four cards, every section;
"Kind words" over the quotes; step numbers on parallel features [copy-numbered]; a small label set
over each section's heading [copy-eyebrows]; "Most popular" on the middle plan. Fix: shape from the reader's questions
(`copy-voice.md` section 3).
- **Response-time promises** [copy-response-time]. "Within two working days" on site after site. Fix:
one, from the brief, worded the same wherever it repeats.
- **"Since [year]"** [copy-since]: once, where it matters, from the brief.
- **Proof figures** [copy-proof-figure]. "3x faster", "10,000+ teams", "98% satisfied": round, neat
and unsourced. Fix: only figures the brief gives, with their unit, in a sentence.
- **Perfect evenness.** Every card the same length, every paragraph the same arc [copy-rhythm]. Fix:
spend words where the brief knows most; one section may run long, one may be two lines.
- **Microcopy left unwritten.** A hand-tuned hero over a stock form error and a stock thank-you. Fix:
write them in the register (`copy-moves.md`).
- **Process talk** [copy-reveal]. "This section shows", "Below, you'll find", "Here are 3 reasons",
"We've added". The page never talks about itself or about how it was built.
- **The stale year** [copy-stale-year]. A copyright line older than this year.
## Over-correction: what the rules themselves produce
| Rule applied | Habit it breeds | Counter |
| --- | --- | --- |
| No em dashes | Comma splices, full stops only | colons, brackets, semicolons where natural |
| No "not X but Y" | "rather than", "instead of", "without the" | count them together; state the positive |
| No hype words | calm vocabulary: quietly, steady, honest, practical | a fact in place of any adjective |
| Avoid threes | everything in pairs | list as many items as there are |
| Be specific | invented numbers and anecdotes | the fact sheet, or a plainer line |
| Short, punchy sentences | a drumbeat of fragments | one sentence a section may run long |
| End strong | a moral or a quip on every paragraph | end on information |
| Add personality | a joke in every section | a humour budget, spent in the microcopy |
| Vary section length | one huge and one tiny section on every page | length follows the material |
| One rule pack on every page | a new uniform house style | the sameness check across pages |
## Not tells (leave them alone)
Perfect grammar. Plain "is" and "has". "In order to". A transition word on its own. Title case when it
is the client's house style. One "no card needed" when it is true. A narrow superlative the brief can
prove. A refrain repeated on purpose. A list of three when there are three things. Copy the client wrote
themselves (when copy-lint FAILs a word of it, the gate takes a waiver for that one hit with the brief
as the reason: `gate.md`). Selling on a sales page: persuasion is the genre; the test is whether it stands on facts.
+179
View File
@@ -0,0 +1,179 @@
# Copy voice
The copy layer. `copy.md` holds the rules every page keeps: the five rules, the contacts, nothing
invented. This file decides what the words say and how they sound, so two pages for two clients never
read like one writer. Read it at step 2, before the first sentence; `copy-tells.md` and
`scripts/copy-lint.mjs` check the result at step 6. The other three files: `copy-moves.md` (the move for
each part of the page), `copy-registers.md` (the voices to choose from), `copy-tells.md` (what gives
model copy away).
## Why model copy reads as model copy
The old slop is mostly gone: no "elevate", no "seamless", few em dashes. The new slop looks like good
copy, used the same way on every page:
- **One voice for every client:** warm, wry, modest, a little literary, mildly against big companies. A
CI tool and a yoga studio sound alike.
- **The same devices in every section:** a strip of three invented figures, headings in two fragments
("Short menu. Long list of regulars."), "X, not Y", "No X. No Y.", a dry joke closing each paragraph, a
pun signing off the footer.
- **Sincerity said instead of shown:** honest, calm, quiet, actually, simply, carefully.
- **Detail invented to look specific:** a batch number, a minute of the morning, a percentage nobody
measured, and the same few names (Priya, Okafor, Fieldnote) on unrelated sites.
Professional copy uses the same devices. What separates it is five things: its details are **true**
(and often a little inconvenient); each device is used **once**, where it lands hardest; every brand has
its **own voice**; it takes **risks** (it names an enemy, admits a limit); and its pages are **uneven**,
leaving out what has nothing true to say. The cause underneath is missing input: with no facts, a model
fills a template with plausible inventions. **The fix is facts and decisions, never synonyms.** A word
swapped for its synonym is the next tell.
## 1. The fact sheet, from the brief
Before a sentence, write down what the brief, the client's files or an existing site give, word for
word where you can. Each field feeds a part of the page:
| Field | What it holds | Where it goes |
| --- | --- | --- |
| What it is | One plain sentence, no adjective | the h1 and the sub-line |
| For whom, and not for whom | The best-fit buyer; who should look elsewhere | the h1 or sub-line, one line in the FAQ |
| What they do today instead | A spreadsheet, the big rival, the bakery across town, nothing | the pain line, the comparison |
| The true difference | What is true here and not elsewhere, with its proof | the claims worth a heading |
| Customer words | Phrases from reviews, emails, the brief's own quotes | the pain line, headings |
| Numbers | Each with where it came from | sentences, the facts strip |
| People | Names and roles the brief gives | the about block, a quote |
| Odd true details | History, an object, a ritual, a house word, the awkward bit | the texture you would otherwise invent |
| A limit | What it does not do, who it is wrong for, what is closed | one admission a page |
| An enemy | A habit or a category ("re-typing invoices"), never the reader | the pain line, one section |
| The offer | Price, trial, booking terms, the one action | buttons, the close |
- **Nothing is invented** beyond what `copy.md` allows (a coined name; on a place, plausible hours and
prices). An empty field stays empty: its section shrinks or goes, and the report lists what the client
must supply. Never a `[NEED]`, a bracket or a "coming soon" on the page.
- **A thin brief is no reason to stop:** write the best page the facts allow. Only when a person is in
the conversation and asks for copy (not a build) ask at most six questions in one message, most useful
first: who switched to you from what, and why; the one number you would bet on; the objection you hear
most; what you are bad at or not for; a strange true detail of how you work; three real customer
messages.
- **A limit and an enemy come from the brief or from plain facts of the trade** ("closed Mondays",
"Mac only", "no lift; rooms on three floors" when the brief says so). An invented flaw is still an
invention.
## 2. The voice card
**The default voice is out** unless the client asks for it: warm, wry, modest, slightly literary,
ending paragraphs on a deadpan twist and saying honest, calm or quiet about itself. It is not bad; it is
what every model writes now, and readers know it.
Pick one register from `copy-registers.md` by the buyer, the price and the client's real character.
When the brief does not settle it, take the one least like the last page this session wrote. Write the
card on the plan (step 3), never on the page:
```
Voice: technical-dry (copy-registers.md 2)
Pairs: exact, not cold; sure, not loud; dry only in the empty state
Person: "you" for what the buyer gets, "we" for the work; the product is the subject in feature lines
Sentences: mostly 8 to 16 words; one long one a section, where it explains
Humour: none in the hero, the prices or the FAQ
House words: the brief's own nouns (shifts, rota, cover), the same word every time
Never: virtue words about ourselves, a pun on the name, a strip of claims
```
- **Copy the client's own voice first.** A writing sample, an old site or the brief's wording overrides
the menu (never the five rules or the integrity rules).
- **Nine other trades:** picture this voice on nine businesses in nine other trades. If it suits any of
them, push it further toward this client.
- **Voice holds, tone moves.** The hero and the story carry the most voice. Prices, the FAQ, booking and
forms go plain: readers there are anxious. Errors and confirmations are short and kind.
- **The register never bends copy.md's rules:** the h1 still names the category, every heading still has
its noun, every button is still a verb and an object (`copy.md`). Voice lives in which facts you pick,
the sentence shapes, the admission and the microcopy.
## 3. Shape from the reader's questions
A reader asks, roughly in this order: what is this, is it for me, why believe you, how does it work,
what does it cost, what if it goes wrong, what do I do now. How much they already know sets the start:
- **They do not know they have the problem:** open the sub-line on a moment they recognise.
- **They know the problem:** name it in the customer's words, then the product.
- **They are comparing:** lead with the mechanism, what this does differently.
- **They know the client:** the product, the offer, the price, the action.
- **A section exists only if the brief has true material for it.** Five full sections beat eight
padded ones; on a place, the photographs set the count (`sections.md`).
- **Counts follow the content:** four real features are four cards, one quote is one quote, two plans
are two plans. Zero testimonials is a normal page.
- **Find the organising idea** when the brief has one: the menu as the page, a timetable, a founder's
letter, one object carried through every section. Let it break the template once.
- **Not the skeleton:** eyebrow, claim heading, two-line lede, three cards, figures, in every section.
Vary what goes in the sections: a list, a table, a short letter, one longer paragraph (five lines
at most, `copy.md`), a heading with one line under it.
## 4. Draft
Read `copy-moves.md` before drafting a whole page.
- **Facts where adjectives want to go.** "Fast" is followed within the sentence by the figure or the
mechanism; adjectives that stay are physical or measurable (wood-fired, 58 mm, ground-floor).
- **Proper nouns over categories:** the tools by name, the street, the river, the bus line.
- **The mechanism in verb-led steps.** How it works persuades more than how good it is.
- **The pain in the customer's words,** before the product, when the brief gives them.
- **One admission a page,** said plainly, from the brief.
- **Proof beside its claim,** from named outsiders the brief gives. No proof: a strip of plain facts
from the brief, or nothing (`copy.md`).
- **Numbers in sentences, with their unit,** uneven where real.
- **Buttons:** a verb and the real object (`copy.md` rule 4). A true risk reversal sits beside the
button ("Free for 14 days, no card"), only when the brief gives it.
- **Microcopy carries the voice too:** form labels and errors, the sent message, empty states, the 404
page, the newsletter confirmation, a cookie notice if the page needs one. Write them in the register;
this is where a dry line is welcome.
- **Sentence length follows meaning.** A short sentence lands a point; a long one carries an
explanation. Not every line punchy; not variety by rule either.
- **Repeat the product's name** rather than cycling through "the platform, the tool, the solution".
## 5. The device budget, per page
None of these is banned; each works once. The problem is using them by default. `copy-lint.mjs`
counts them: over the budget is a WARN, twice the budget plus one or more is a FAIL (a tic).
| Device | Budget | Note |
| --- | --- | --- |
| Heading in two fragments "X. Y." | 1 | spend it where it lands hardest |
| Number-pair heading "Four X, one Y" | 0 to 1 | the most repeated model habit |
| Negation run "No X. No Y." | 1 | only when each item is a refusal a buyer can check |
| Contrast "X, not Y", isn't, rather than, instead of, without the | 2 in all | banning one form moves you to the next; count them together |
| Dry closing line on a paragraph | 2, never in a row | most paragraphs end on information |
| Pun on the name or its metaphor | 1, never the h1 | it is the page's one brand line (`copy.md` rule 3) |
| Sign-off line in the footer | 0 | the footer holds facts (`copy.md`) |
| Strip of figures | facts from the brief only | never invented KPIs |
| Virtue words about ourselves | 0 | the lint skips literal ones (a quiet room, calm water, genuine parts) and allows 2 |
| Lists of three | when there are three things | never in consecutive sentences |
| Step numbers on cards | only where the order is the content (a booking's steps) | none on features that read in any order |
| Eyebrow labels | 1 or 2 a page | a heading that names the thing needs none |
| Testimonials | the brief's only | name and role or town; none is fine |
| Response-time promise | 1, from the brief | the same words wherever it repeats |
| "Since [year]" | 1, from the brief | |
## 6. Check
At step 6, before the gate (which runs the lint again):
1. **Lint:** `node <skill>/scripts/copy-lint.mjs index.html --kind <kind>`. Fix every FAIL. Fix each
WARN, or keep it on purpose and say why in the report.
2. **A rival's name:** put a rival's name, or the same trade in another town, into the h1, the sub-line
and each heading. A line that still reads true says nothing about this client: rewrite it from the
fact sheet.
3. **Sameness across pages:** when this session built other pages, pass them all to `copy-lint.mjs`
at once and read the first headings side by side, and the heading phrases they share. A shared
cadence or joke is a tic: break it.
4. **Read it aloud, as the client.** Cut every sentence they would not say about themselves.
5. **Over-correction:** after the fixes, look for the habit that replaced the one you removed: "rather
than" multiplying, every sentence a fragment, everything in pairs, a joke deleted and nothing in its
place. A rule that made a sentence worse is broken on purpose.
6. **A fresh reader,** when the run can spare a subagent: give it only the page's text and ask what
this is, who it is for, why this over the alternative, what it costs, what would make it hesitate,
and which three lines sound like any other website. Fix what it finds. Never iterate to beat an AI
detector.
The report carries the voice card in one line, every assumed price or hour (`copy.md`), and what the
client must supply.
+68 -19
View File
@@ -1,9 +1,35 @@
# Copy
Say what it is and what it does, in words a stranger understands in five seconds. Every sentence is
one you would say out loud to a customer. No metaphors, poetry, invented terms or ad wording. Every
studied site is a description only: copy how the examples are written (plain, category first),
never their words, code, pictures or signature effect.
one you would say out loud to a customer. No metaphors, poetry, invented terms or ad wording (the one
brand line of rule 3 aside). Every studied site is a description only: copy how the examples are
written (plain, category first), never their words, code, pictures or signature effect.
## The copy layer
This file holds the rules every page keeps. What the words say and how they sound is the copy layer, read at step 2
before the first sentence:
- `copy-voice.md`: the fact sheet from the brief, the voice card, the page's shape from the reader's
questions, the device budget, the checks. Start here.
- `copy-registers.md`: the voices to choose from, and which suit which trade.
- `copy-moves.md`: the move for each part of the page: hero forms that name the category, headings,
the story, features, proof, prices, buttons, microcopy, the footer.
- `copy-tells.md`: what gives model copy away and the fix for each, what over-correction breeds, and
what is not a tell.
Where they differ, this file wins: nothing invented, nothing bracketed or left open on the page, no
captions, the h1 names the category, buttons are a verb and an object.
**copy-lint.** `node <skill>/scripts/copy-lint.mjs index.html --kind <kind>` counts the countable
tells in well under a second a page (no browser). FAIL: residue (a `[NEED]`, a template token,
markdown, chatbot lines), the old slop words and stock lines, and a device used more than twice its
budget plus one (three two-fragment headings, five contrast frames, five virtue words): fix every
one. WARN: a device over its budget, a strip of figures, a default-cast name, a footer sign-off, a
response-time promise worded twice, proof figures, exclamation marks, puffery, a stale year: fix it or
keep it and say why in the report. Several pages at once also print their first headings side by side
and the heading phrases they share. `--json` for scripts; exit 1 on a FAIL. The gate runs it on the
page as a static check: its FAILs block like the checker's (ids `copy-*`), its WARNs do not.
## Five rules
@@ -15,7 +41,8 @@ never their words, code, pictures or signature effect.
2. **The first screen holds one hard fact:** a number, a place, a price or a date.
3. **Every heading has a noun that names a thing:** the product, a part of it, the place, a section
noun (prices, rooms, menu, opening hours, questions, getting here, visit) or a digit. One brand
line a page at most, never the h1.
line a page at most, never the h1: it is the page's one figure of speech or pun, with a fact in
the line under it.
4. **Buttons are a verb plus what the visitor gets,** 1 to 5 words, starting with book, buy, order,
pre-order, reserve, try, start, get, download, compare, see, watch, read, find, join, subscribe,
sign up, contact, configure, add, send, email or request; a bare noun only where the link opens
@@ -23,7 +50,10 @@ never their words, code, pictures or signature effect.
5. **Few actions, each with one wording:** at most 2 in the hero (one on a place), 10 different
labels a page at most. The main action names the transaction (Book a room, Order a cake, Start a
free trial; on a place the brief's order or booking, else Get directions, never a side event) and
reads the same in the nav, the hero, the close and the phone bar, and nowhere else.
reads the same in the nav, the hero, the close and the phone bar, and nowhere else. One screen
shows it twice at most: the nav's button and the hero's on a desktop (the checker WARNs it, so
look that nothing else repeats it), the hero's on a phone (system.js keeps the phone bar away
from a screen that already shows its action); a third copy on one screen FAILs [action-repeat].
## Plain versions
@@ -43,11 +73,15 @@ figure of speech.
words, one line to two sentences. A place: the fact, 80 characters at most, with today's hours
("Sourdough, rye and cardamom buns from 7:30, Tuesday to Sunday."); a hotel the rooms and the town.
- **Section headings** name the thing ("Rooms and prices", "Works with your tools") or make a short
plain claim ("Pays staff on the same day"), 1 to 6 words, at most two lines. A noun phrase or "we
do X for Y", never a command, a question (FAQ entries may be questions), a colon heading or a line
that only makes sense after the page ("Continuity slips", "It reads everything you decide").
plain claim ("Pays staff on the same day"), 1 to 6 words, at most two lines. A noun phrase, "we
do X for Y", or a verb with the thing it acts on ("Send invoices from the van"); never a bare
command ("Get started", "Come climb"), a question (FAQ entries may be questions), a colon heading
or a line that only makes sense after the page ("Continuity slips", "It reads everything you
decide"). Two fragments ("Twelve rooms. Three ways to stay.") once a page at most (`copy-tells.md`).
**Card titles:** a verb and an object ("Warns about double shifts"), one line, real words, never a
coy label ("The market run").
coy label ("The market run"). A heading with a grey second half is two sentences: the first ends
with a full stop before the `.muted` span ("Rooms and prices. <span class="muted">Breakfast
included.</span>"), never two phrases that run into one [run-on].
- **Under a heading** one line to two sentences (10 to 40 words); a card body 1 or 2 sentences, 25
words at most, in the buyer's words with one concrete detail; no paragraph over five lines at
1440. A price does the selling, not a mood heading ("Rooms from 140 € a night, breakfast
@@ -64,15 +98,23 @@ figure of speech.
and up).
- **Pictures** stand beside short copy, never in place of it, and words never stand where a
photograph fits. Every word is live text.
- **Form words:** a label says what goes in ("Arrival", "Guests"), never a question or a
placeholder in its place; an error names the field and what to type ("Write the day you want to
come"); the sent line names what was sent in the button's own words ("Your table request is in. We
reply within a day."). The rest of the recipe: `system.md`, Forms.
- **Self-check:** read each line aloud to a customer. Is it clear what the product does? If not,
rewrite it.
rewrite it. Then the copy layer's checks (`copy-voice.md` section 6), copy-lint first.
## What may be assumed
- **Prices, address, hours and dates come from the brief;** the report lists any you assumed. Only
two things may be assumed when the brief lacks them: a coined brand name (below), and on a place
plausible hours and prices for the trade, uneven and in the local currency (3.60, not 4.00).
Nothing else is invented.
Nothing else is invented: a product or service with no price in the brief says "Price on
request" or shows none. Each assumed price, hour, date or place carries `data-assumed` on its own
element (`<span class="rows__value" data-assumed>3.60 €</span>`, `<dl class="hours"
data-assumed>`); the checker lists every one for the report [assumed], and assumed hours never
drive an open-now line [open-now].
- **No invented proof:** no invented reviews, ratings, review counts, quotes, testimonials,
statistics, customer counts, customer names, portraits, clients, awards, press lines or products.
Customer logos only for real customers the brief names and that may be shown; a real tool the
@@ -112,9 +154,10 @@ phone number still gets the address, email and imprint rules.
and never an invented street or number; the type moment `address` sets that district. List the missing street in the report
as the client's to supply.
- **Maps:** no map loads with the page. Get directions opens
`https://www.openstreetmap.org/search?query=<street>+<district>` (no street given:
`?query=<district>+<town>`), with no coordinates and no route; on the phone the address opens the
same search. A map is drawn only for a street the brief gives (`fx-map` baked still, the pin on the
`https://www.openstreetmap.org/search?query=<street>+<district>`, with no coordinates and no
route; on the phone the address opens the same search. No street given: the link says "See the
area" and opens `?query=<district>+<town>`; only a town: no map link and no directions button at
all, since a search for a whole city shows nobody the door [map-link]. A map is drawn only for a street the brief gives (`fx-map` baked still, the pin on the
street line, never a building); a builder's real street never gets a real map, since it pins a
stranger's door. Never an iframe.
- **Phone: none, unless the brief gives one.** No number, no `tel:` link, no Call button. A
@@ -122,11 +165,14 @@ phone number still gets the address, email and imprint rules.
- **No imprint page and no company details:** no register number (HRB), VAT or tax id, company
number, managing director or court. The legal row holds the brand, the current year, Privacy,
Terms and the credits line; an Imprint link only when the brief gives the company's own details.
- **Email and web:** a form, or a button ("Email us") whose `mailto:` is on `.example`
(`hello@brand.example`). Never print an email address, a domain or a social handle, not even a
`.example` one. `.socials` stays empty unless the brief gives the accounts.
- **Email and web:** a form (`system.md`, Forms: it posts to the client's form address when the
brief gives one, else its `mailto:` on `.example` stands in and the report lists it), or a button
("Email us") whose `mailto:` is on `.example` (`hello@brand.example`). Never print an email
address, a domain or a social handle, not even a `.example` one. `.socials` stays empty unless the brief gives the accounts.
- **Names are coined,** not dictionary words and not a business that exists, unless the brief gives
the name; person names come only from the brief. Search a coined name with its trade ("name
the name, and never one that models keep coining (Kestrel, Halcyon, Lumen, Nimbus, Fieldnote:
`copy-tells.md`; copy-lint FAILs one as the page's own name, in its title or h1, and WARNs one
anywhere else, a logo wall or a mock); person names come only from the brief. Search a coined name with its trade ("name
coffee") and as a domain; if a business of that trade or a famous person has that exact name in
the first 10 results, coin another. Write each query and result in `names.json` beside the page
(`"searched": false` with no search tool).
@@ -141,4 +187,7 @@ Metaphors and "where X meets Y"; slogans, greetings and boasts in the hero; ques
exclamation marks; all-caps labels; invented capitalised concepts ("the Proof Layer"); unexplained
programme names; jokes or slang in labels; mood paragraphs; a literary quote as a full-screen band;
filler (seamless, effortless, cutting-edge, unlock, elevate, supercharge, reimagine,
next-generation, game-changing, powerful, robust).
next-generation, game-changing, powerful, robust, and the rest of `copy-tells.md`); virtue words about the client (honest, calm, quiet, actually, simply); a sign-off or a pun
in the footer; the model's default voice (`copy-voice.md` section 2). copy-lint FAILs the old slop words,
the stock phrases, left residue and a device used past twice its budget; it WARNs the rest it can
count, and the rival's-name check and the read-aloud (`copy-voice.md` section 6) catch what it cannot.
@@ -15,32 +15,34 @@ Thirteen objects in three families and one builder; each family's README is the
- **One strong object moment a page.** Never beside a second effect or over a moving ground.
- **Place, service and every page of site-kind other** (cafe, guesthouse, shop, studio, clinic,
product brand): no object, print or scene; plain photographs lead. House rule: shader
effects only where the trade has a technical subject. The one exception is below: a physical
product with no photographs shows its own render.
effects only where the trade has a technical subject. The one exception is below: a product
whose brief asks for 3D shows its own render.
- **Product with photographs**: the photograph leads.
- **Any physical product without photographs** (a lamp, a chair, a bike, a stroller, a robot arm),
of site-kind other or tech: **the product is rendered, never hidden** (in testing: a cargo bike's
page that never showed the bike scored 3 of 10). The client's own render or CAD view comes
first; else `assets/objects/product-render` composes this product from a JSON spec of its own
parts, sizes and materials (Product renders, below) and it leads the hero (`deal.mjs --type
product --has object`), then whole in at most two more sections, each a different view, big in
its frame, never in a grey panel; its use (a scene whose setting fills the frame), close-ups of
its parts and an exploded view carry the other sections (Views, below; `sections.md` section 1,
One object, and Product, its seven rules). It is the product's picture, not
an effect: on the page's flat ground or the lit ground on tech, stills by default, live only when
asked (`data-product-mode="live"`), at most one live context, no print,
no shader ground or moment beside it on site-kind other. A `print-*` object of the product's own
kind (a lamp maker's lamp) may stand in for the spec; never an object of another kind (a wheel is
not a bicycle), a drawn SVG, a model of loose primitives the helper did not compose, mood stock
or another maker's product. **Hardware and devices too** (a robot arm, a drone, a camera, a
sensor; in testing: a robot arm shown as a silk shader and an order form lost): with no photographs
or client renders it takes its product render, `deal.mjs --type product --kind tech --has
object`. A curved shell is composed of rounded boxes, cylinders and fillets at its real
measurements; never a `split` hero holding only an order form, never an effect in its place.
- **A game without art files**: its key art is a product render of its hero object (the ship, the
crane barge, the mech) or a scene of its world composed from its own objects on one floor, on the
`product` stage (`deal.mjs --type software --kind gaming --has object`) and full bleed in the
world section; an effect may sit behind it, never replace it (`sections.md`, Gaming).
of site-kind other or tech: **photographs first, never a render by default.** The client's own
photographs, a cut-out or a CAD view the brief supplies; else stand-in photographs of the same
kind of thing found with `photos.mjs search` and listed in the report (`photos.md`, A product
without photographs; `sections.md`, Product). In testing, a cargo bike's page that never showed
the bike scored 3 of 10, and pages carried by a render and a fixed set of props read as random
3D objects: be clean, show the real kind of thing.
- **A render only when the brief asks for 3D or a render** (a configurator, "show it in 3D", a
product that exists only as a model): `assets/objects/product-render` composes this product from
a JSON spec of its own parts, sizes and materials (Product renders, below) and it leads the hero
(`deal.mjs --type product --has object`, hardware adds `--kind tech`), then whole in at most two
more sections, each a different view, big in its frame, never in a grey panel; close-ups of its
parts carry the others (Views, below; `sections.md` section 1, One object, and Product). It is
the product's picture, not an effect: on the page's flat ground or the lit ground on tech, stills
by default, live only when asked (`data-product-mode="live"`), at most one live context, no
print, no shader ground or moment beside it on site-kind other, no props unless a headline's
promise needs one. Never an object of another kind (a wheel is not a bicycle), a drawn SVG, a
model of loose primitives the helper did not compose, mood stock or another maker's product. A
curved shell is composed of rounded boxes, cylinders and fillets at its real measurements; never
a `split` hero holding only an order form, never an effect in its place.
- **A game without art files**: its hero is the game's name set large over a stand-in photograph of
its setting (`deal.mjs --type software --kind gaming --has setting`, the `photo` hero), its world
section the widest of those photographs full bleed; an effect may sit in a section of its own,
never replace the key art (`sections.md`, Gaming). A render of its hero object or world only when
the brief asks for 3D.
- **Software, AI, games with art**: a print object as a print on the card it is about (a globe for
regions); a game's own world object for `fixed-hero-object` (`scenes.md`).
- **Photo prints and the photo scenes** print a photograph, and no page prints, dithers or grades a
@@ -75,9 +77,10 @@ only; `data-scene` here is not a big scene, which is `data-big-scene`, `scenes.m
## Product renders
`assets/objects/product-render`: the picture of a physical thing that has no photographs or client
renders: a product of site-kind other (a bike, a lamp, a chair), hardware (a robot arm, a drone, a
device) and a game's hero object or world without art files (`sections.md`, Product and Gaming).
`assets/objects/product-render`: the picture of a physical thing when the brief asks for 3D or a
render: a product of site-kind other (a bike, a lamp, a chair), hardware (a robot arm, a drone, a
device) or a game's hero object or world. Without that ask, a thing with no photographs is shown in
photographs, the client's or stand-ins (`photos.md`, `sections.md`, Product and Gaming).
`build.js` turns a JSON spec of the thing's own parts into a raymarched studio render (real
materials, soft light and shadows, a softbox in glossy parts, a floor shadow, the page's accent on
the parts that ask for it); `auto.js` mounts it; `make-posters.mjs` checks the spec and prints its
@@ -125,13 +128,13 @@ their format, never ship one as the client's product.
### Views (a different one in every section; the product whole in three at most)
All from the one spec, with the kit's own names (`build.js`, the three worked specs show each);
write each section's view on its plan line, no two alike (`sections.md`, Product: the six-section recipe names them: `side`, `three-quarter`, the use scene, `detail:<part>` or an `exploded` scene, a `rear-quarter` of the spec's own; whole views in three sections at most).
write each section's view on its plan line, no two alike (`side` for the hero, `three-quarter`, `detail:<part>` close-ups or an `exploded` scene, a `rear-quarter` of the spec's own; whole views in three sections at most; `sections.md`, Product).
| spec key | what it gives |
|---|---|
| (none needed) | the standard views `three-quarter` (the spec's `angle`), `side` (a long product's widest: its hero), `side-left`, `front`, `rear`, `top`, `low` (only those the spec's `supports` lists); `detail:<part>[,<part>]` (or `detail-<part>`) frames the named parts and runs the rest off the frame: a close-up |
| `views` | the spec's own views, `{ "detail-saddle": { "part": ["saddle*", "seat post"], "angle": -42, "elev": 16, "margin": 1.3 } }` (`from` a standard view; a part name takes the parts it begins, `*` any) |
| `scenes` | the product in use or loaded: `{ "loaded": { "label", "hide": [parts], "props": [{ "prop", "at", "size", … }], "parts", "lit", "pool", "click" } }`. Props: `crate`, `bag`, `child-seat`, `bench`, `parcel`, `desk`, `book`, `mug`, `sheet`, `tray`; `lit` lights the props round a lamp. A scene shows the headline's promise at a size the section can read |
| `scenes` | the product in use or loaded: `{ "loaded": { "label", "hide": [parts], "props": [{ "prop", "at", "size", … }], "parts", "lit", "pool", "click" } }`. The props the helper can draw: `crate`, `bag`, `child-seat`, `bench`, `parcel`, `desk`, `book`, `mug`, `sheet`, `tray`; `lit` lights them round a lamp. No scene and no prop by default: one only where a headline's promise needs it (four crates for "takes four crates"), at a size the section can read; a use photograph is better |
| `colors` | the colourways offered, `[{ "id", "name", "accent", "materials"? }]`: the accent re-lights the parts painted `"color": "accent"`, `materials` swaps others |
| `make` | what `make-posters.mjs` prints: `{ "variants": ["three-quarter", "side", "front", "detail-saddle", "three-quarter+loaded"], "grounds": ["light", "dark"] }` (`view+scene`) |
| a lamp's light | the spec's `click: "light"` and `pool`; `{"step":0}` off |
+20 -15
View File
@@ -3,7 +3,8 @@
Who gets effects, how many, how they are dealt and where they sit. An effect is anything a shader,
canvas, WebGL, a print pass or a scroll drive makes: effect grounds (`data-fx`, `assets/effects`),
the WebGL creative parts (`effects-creative.md`), objects and prints (`effects-objects.md`), big
scenes (`scenes.md`), moments (`moments.md`), a film or 3D hero and timed loops. `cards.md` wins on
scenes (`scenes.md`), the tech moments (`moments.md`; the photo- and type-led ones every kind deals
are not effects), a film or 3D hero and timed loops. `cards.md` wins on
any card detail; `motion.md` holds every timing.
## The gate (house rule 8)
@@ -16,21 +17,24 @@ On other, photographs, type and flat colour carry the page. Banned there: canvas
video, any CSS gradient (grounds, panels, glows, scrims), SVG pattern or texture grounds,
`backdrop-filter` (the nav too), `filter`, `mix-blend-mode`, `mask-image`, glass, grain, halftone,
dither, duotone, print, `[data-fx]`, `[data-print]`, `[data-scene]`, `[data-big-scene]`,
`[data-moment]`, `[data-ui-loop]`, `[data-m-loop]`, `[data-m-count]`, SVG animation, GIFs, Lottie, a
custom cursor and any scroll or animation library. Allowed: `fx-map` baked still, `fx-photo` as a
plain photograph, real icons, a still diagram (not on a place), and on a physical product with no
photographs its own product render (`[data-product]`, `effects-objects.md`): the product's
`[data-ui-loop]`, `[data-m-loop]`, `[data-m-count]`, SVG animation, GIFs, Lottie, a
custom cursor and any scroll or animation library. Allowed: the hero's entrance and the one or two
dealt photo- or type-led moments (`photo-chapters`, `pinned-chapters`, `picture-develops` in its
film look, `word-highlight`: `moments.md`), `fx-map` baked still, `fx-photo` as a
plain photograph, real icons, a still diagram (not on a place), and on a physical product whose
brief asks for 3D its own product render (`[data-product]`, `effects-objects.md`): the product's
picture, not an effect: stills by default, live only when asked (`data-product-mode="live"`), at
most one live context, on the flat ground, its poster beneath.
most one live context, on the flat ground, its poster beneath. Without that ask a product with no
photographs is shown in stand-in photographs (`photos.md`).
## What each kind gets
| | tech | product, service (other) | place |
|---|---|---|---|
| Lead picture | the client's UI (hardware: its photographs or render, else its product render, never an effect or an order form alone; a game: its name and its art, else its key art rendered with the product-render helper, an effect at most behind it) | its photographs; a product without them: its product render | its photographs (a remote place: its region's landscape) |
| Lead picture | the client's UI (hardware: its photographs or CAD render, else stand-in photographs of its kind, never an effect or an order form alone; a game: its name and its art, else its name over a stand-in photograph of its setting; a render only when the brief asks for 3D) | its photographs; a product without them: stand-in photographs of its kind (a render only when the brief asks for 3D) | its photographs (a remote place: its region's landscape) |
| The one effect | a live ground, a WebGL part, an object or a big scene | none | none |
| Card panel grounds | one card group, still, from the effect's family; with a big scene the lit ground (`data-ground`), no `data-fx` | the client's photograph | none |
| Moments | up to 2 in sections, at most 1 in the hero | none | none |
| Moments | 1 or 2 a page, two at most in all: in sections, one of them may sit in the hero | 1 or 2 in sections, photo- or type-led (`moments.md`) | 1 or 2 in sections, photo- or type-led (`moments.md`) |
| Big scene | 0 or 1 (it is the effect) | none | none |
| Diagram cards | 0 to 2, pulsing | 0 or 1, still | none |
| Loops | one timed loop, one per card group | none | none |
@@ -39,12 +43,13 @@ most one live context, on the flat ground, its poster beneath.
**The subject before the effect** (in testing: a robot arm shown as a silk shader and an order form,
and an unnamed game shown as an abstract effect, both lost to pages that showed the thing). An
effect never stands where the product, the device or the game's world should be. Hardware with no
photographs or client renders, and a game with no art files, take a product render
(`effects-objects.md`, Product renders) as their picture: the hardware itself, the game's hero
object or a scene of its own objects, whole on the stage and full bleed in the world section. The
render is the picture, not the effect: the page's one effect may sit behind it as the stage's
ground (every render placement is transparent over its frame), counts as the beat and shares the
four contexts with it; it never replaces it and never sits beside it as a second focal visual.
photographs or client renders takes stand-in photographs of the same kind of machine at work, and
a game with no art files its name set large over stand-in photographs of its setting (`photos.md`,
Live search; `sections.md`, Product and Gaming), full bleed in the world section. When the brief
asks for 3D, a product render (`effects-objects.md`, Product renders) is the picture instead, not
the effect: the page's one effect may sit behind it as the stage's ground (every render placement
is transparent over its frame), counts as the beat and shares the four contexts with it; it never
replaces the picture and never sits beside it as a second focal visual.
Every visual that is not the effect, a card ground, a moment, the scene or a product render is a
real UI capture or a real photograph. A photograph is never filtered, printed, dithered or graded on any page.
@@ -106,7 +111,7 @@ fragment, a card group), add the patterns and prints to `--exclude`; `<NOUI>` is
## Deal the moments and the scene
**Moments** (0 to 2): dealt with `moments.mjs pick --page <kind>`, the excludes and the hero rule
**Moments** (1 or 2, on every kind): dealt with `moments.mjs pick --page <kind>`, the excludes and the hero rule
exactly as `moments.md` (Deal them) says; pass the scene and the effect so the WebGL total stays
within four. Never a moment in the scene's section or beside it.
+204
View File
@@ -0,0 +1,204 @@
# The finish gate
A build is not finished while the checker still finds a FAIL it could fix. `scripts/gate.mjs` runs the
checks that matter, lists what is open, and counts rounds, so a build iterates for real and still ends.
## Arm it
At step 1, before any page exists:
```bash
node <skill>/scripts/gate.mjs --start index.html --kind <kind> --category "<what is sold>" --primary "<main action>"
```
- A framework build names what a browser can open: its dev server's URL (`--start http://localhost:5173/`,
with the server running when the gate runs) or its built page (`--start dist/index.html --root dist`).
A built page is checked as built: rebuild before every gate run, or the round is spent on the old build.
The gate opens only a local page: a file, or a URL on `localhost`, `127.0.0.1`, `[::1]`, `*.localhost`
or `*.test`.
- `--start` on a build whose rounds are still open does not reset them: it only updates the page, URL,
root, kind, category and primary it checks. `--start --new` begins a new build. `--rounds N` (3 by
default) is set only when a build begins.
- `start.mjs deal` arms the folder as well when no build is open there; `css` and `brand` never do.
## What it does
- **A build is active** while `.taste-skill/build.json` exists in the project folder (or one or two
folders below), or while `.taste-skill/gate.json` says its rounds are still open: deleting the marker
does not end a build. A `start.mjs deal` in a folder one or two below an open build joins that build
instead of arming a second one.
- **The page:** the one named, else the marker's page or URL, else `index.html`, else the only `.html`
file, else the `index.html` of the only folder below that has one.
- **The checks**, one at a time, each in its own headless Chrome with a fresh profile:
`consistency.mjs --quick --widths 1440,390` (with the kind the page declares, else the marker's, and its category, primary and
root, as step 6 runs it) and `assets/layout/check.mjs --sizes 1440x900,390x844`. The consistency run includes
`empty-area` (a section over 35% empty inside its container WARNs, over 50% FAILs) and the `basics`
block (names, alt, labels, heading order, the Tab walk, lang, head tags, map links, assumed facts):
their FAILs block like the others; their WARNs (skip-link, share-meta, action-repeat at two,
assumed, the form's `.example` stand-in) never do. The quick run leaves out what only step 6's full
run checks: `forms`, `reduced-motion`, `hover-move`, `hover-spill`, `loop-pause`, `hidden-visual`, the scroll walks. The 390 width brings the phone checks (`collide`,
`tap-size`) and a walk at 320 for anything past the screen (`off-viewport`); `hidden-content` also
loads the page with scripts off. Plus two read from the files:
- **kit-edited**: a file the page loads from `assets/` differs from the skill's copy (whitespace,
quotes, semicolons and commas aside, so a formatter is not an edit). A kit copy from an older skill version
counts too: copy the current one.
- **checker-gaming**: the page's own code tests for `navigator.webdriver`, `window.__playwright`,
`callPhantom` or the checker's `__cc` hooks (code only: a code sample in a string is not a test).
- **gaming-suspect** (waivable): a user-agent test for "headless", or `aria-hidden` stripped from every
picture a selector finds. The kit's own `assets/motion/motion.js` tests for `HeadlessChrome` (its
`shot()`: in a headless browser the counts and the hero entrance stand as written, at their end,
unless the page sets `data-motion="live"`). An unchanged kit copy is skipped by its text; a build that bundles or ports it
(a framework's `src/lib/motion.js`, a `dist/assets/index-*.js`) gets this finding for the kit's
line: waive it with "the kit's motion.js shot() test, ported unchanged" as the reason.
- **the copy** (waivable): `scripts/copy-lint.mjs --json --kind <kind>` on the page's file, or, when
the file holds no sections (a built app, a dev server), on the page as the checker's browser built
it; with neither the copy is listed as not checked, never passed. Its FAILs block (`copy-residue`,
`copy-slop-word`, `copy-stock-phrase`, a device used twice its budget plus one times or more (two-fragment and number-pair headings, contrast
frames, negation runs, virtue words; riders, puffery, reveals and repeated promises only WARN), a
brand coined as Kestrel or Halcyon); its WARNs are listed and never block (`copy.md`,
`copy-tells.md`; `copy-brand-word` is a listed word inside the client's own name). These are
copy-lint's ids; the checker's copy-headline, copy-fact and the rest are its own. No browser: under
a second.
A file whose text is a kit file's is the kit's, wherever it sits. The two checkers' ids for one fault of
one section (section-height / screen-height / screen-short, one-section / neighbour) are one finding.
A full run takes a few minutes on a CPU Chrome.
- **WARNs never block,** but for two house rules the checker only WARNs and the gate holds as FAILs:
`hero-enter` (the hero has no `data-enter`, or the head line that sets `html.m-enter` is missing) and
`moment-none` (the page deals no moment). The first few WARNs are listed.
- **Waivers.** A FAIL you judge false goes in `.taste-skill/waivers.json`:
```json
[{ "check": "one-section", "target": "section#visit", "reason": "the hours list is one screen at 390; the check counts the phone bar" }]
```
`check` is the finding's id, `target` a part of where it is (3+ characters, or the whole of a short
where), `reason` 12+ characters.
One waiver covers one finding: a target that matches several is ignored and says "name one". Each
copy word, stock phrase or residue hit is its own finding, named by the word and its sentence; a copy
device used as a tic is one finding, waived only by its whole where as the gate prints it (its count
and hits), so nothing added later is covered. A word the client's own copy uses (the brief quotes it)
is waived hit by hit with the brief as the reason. A waived
FAIL is printed as WAIVED: it goes in the report. kit-edited and checker-gaming cannot be waived. More
than 5 waivers is itself a FAIL: "too many waivers: fix instead".
- **Rounds** (`.taste-skill/gate.json`). A run whose page or waivers changed since the last round is a
round; a manual rerun of an unchanged page is not. A hook stop before the gate's first run by hand in
the build is no round either: it lists the FAILs and blocks (its blocks are counted apart), and the
first run by hand counts that page's round, so a question to the user mid-build costs nothing. After
a hook block, the next finish on the unchanged page is allowed once without a round (a question to
the user: the block says "just stop again"); other finishes on it block twice without a round
("nothing changed"), then each counts as one; a change starts that count again. Below the last
round: FAIL, fix, finish again. At the last round the gate says once "rounds used up": stop fixing,
put the open FAILs in the report, finish. Every later finish is allowed. A PASS, and the last
round's OPEN, rename `build.json` to `build.done.json`, so a later deal starts a build of its own.
- **When the user ends it.** Only when the user tells you to stop, or to ship the page as it is:
`gate.mjs --done "<what they said>"` (12+ characters) closes the build with their words: status
`done` in `gate.json`, `build.json` renamed to `build.done.json`, every later finish allowed. Its
last line is `GATE: DONE (...)`; put it, their words and the open FAILs in the report. Never for
your own call that the page is good enough: that is what the rounds are for.
- **It never traps an agent.** Every block is counted in `.taste-skill/gate-blocks.json`, which `--start`
never clears; past the rounds plus 4 blocks for one build (the blocks before the first run by hand
have that many of their own), or 30 in 6 hours in the folder, every finish is allowed. No page, a framework shell, or checks that crashed or ran past 9.5 minutes block once with
what to do, then allow. A crashed or timed-out run is stored for its page: a later finish on the
unchanged page reuses it instead of running the checks again (once more at most, when the agent was
not yet told). Each check runs with a watchdog that kills it, and its Chrome, if the gate is
killed; nothing else is ever killed.
## The manual gate
```bash
node <skill>/scripts/gate.mjs index.html
```
Its last line is exactly one of:
```
GATE: PASS
GATE: FAIL <n> (round <k> of <max>)
GATE: OPEN <n> (rounds used up)
GATE: QUICK <n> (not a round; run without --quick to finish)
GATE: SETUP (...)
GATE: ERROR (...)
GATE: OFF (no build active)
GATE: DONE (<the user's words>) after --done
```
PASS: finish. FAIL: fix what it lists and run it again. OPEN: stop fixing; the open FAILs and the waivers
go in the report. QUICK: a look between rounds, never the last line of a build. SETUP (no page yet, a
framework's source shell, a page that is not local): arm what a browser can open (`--start`, above) and
run it again. ERROR (the checks themselves could not run) or OFF (no build armed here): put the line in
the report and finish; do not rerun. Above the line: every open FAIL with where it is and the fix, every
waiver, the first WARNs. `--quick` (the checker at 1440 and 390, the layout check at 1440) is never a round. The crops are in
`.taste-skill/gate/consistency`.
## As a hook
In Claude Code the skill's own frontmatter adds the gate as a Stop hook once the skill is invoked. The
command is one POSIX `sh` line (Git Bash on Windows, `sh` on macOS and Linux) that runs the first
`gate.mjs` it finds, in this order: `$CLAUDE_SKILL_DIR/scripts/`, `$CLAUDE_PLUGIN_ROOT/scripts/` and
`$CLAUDE_PLUGIN_ROOT/skills/taste-skill/scripts/` (a plugin install), each only when the variable is
set; then the install folders by name, `$CLAUDE_PROJECT_DIR/.claude/skills/design-taste-frontend/`,
`$CLAUDE_PROJECT_DIR/.agents/skills/design-taste-frontend/`, `~/.claude/skills/design-taste-frontend/`
and `~/.agents/skills/design-taste-frontend/` (`npx skills`, `install.sh`). When it finds none, the stop
is allowed with no hook error; when the folder has a `.taste-skill` it writes
`.taste-skill/gate-hook-missing.txt` (the time and the variables it read), and when a build is armed
there or one folder below it prints `tasteskill gate: gate.mjs not found (CLAUDE_PLUGIN_ROOT=...)` on
stderr, so a hook that never reached the gate shows in the transcript. A skill installed under another
folder name, other apps, or a Claude Code without skill hooks take one of these. Each runs in every
session of the project and finds a build in its folder or one or two below. Where none is active it
exits at once, but once for a tasteskill page (a `page-type` meta) changed in the last day in the
folder or one below that was never armed: it says how to arm it (`.taste-skill/unarmed.json` records
that it told). It holds only the first session it blocks, lets an orchestrator
whose builds sit in two or more folders below stop, and ignores a build idle for 24 hours. Replace
`/path/to/skill` with the skill's folder.
**Claude Code**, `.claude/settings.json` (blocks with JSON on stdout):
```json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "node \"/path/to/skill/scripts/gate.mjs\" --hook claude", "timeout": 600 } ] }
]
}
}
```
**Codex**, `.codex/hooks.json` (the same output as Claude Code: `{"decision":"block","reason":...}`):
```json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "node \"/path/to/skill/scripts/gate.mjs\" --hook codex", "timeout": 600 } ] }
]
}
}
```
**Antigravity**, `.agents/hooks.json` (it answers `{"decision":"continue","reason":...}` to keep the agent working):
```json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "node \"/path/to/skill/scripts/gate.mjs\" --hook antigravity", "timeout": 600 } ] }
]
}
}
```
In every mode the gate allows a stop by printing nothing and exiting 0. It reads the hook's JSON on stdin
(2 seconds at most) for `cwd` and `session_id`; `stop_hook_active` does not end the rounds early, the round
count in `gate.json` does.
Every hook call leaves one entry in `gate.json`'s `history`, an allowed stop too: `"mode": "hook"`, the
`host`, `"decision": "allow"` or `"block"` and a short `reason` (a run of the checks adds its status, FAIL
count and round). After a PASS or with the rounds used up it goes in that build's `gate.json`; with no
build armed, in the newest `gate.json` in the folder or one or two below, else in the folder's own
`.taste-skill` when there is one. It never makes a new `.taste-skill` folder, and never writes into a build
below that an orchestrating session does not hold. An entry alone keeps the file's time, so it never
makes an idle build look active.
A hook added by a skill belongs to the main session: a subagent that loads the skill is not held, and its
parent is held instead. Run the manual gate inside a subagent.
+31 -19
View File
@@ -87,11 +87,13 @@ fails a section that is not one or two screens at the tablet too (the default ru
- **The close and the footer are one section,** `footer.closing` (one screen; `.g2m` allowed on a
phone): `footer.closing > .wrap` holds exactly two children, row 1 the dealt close and row 2 the
links and the legal row. No separate closing band.
- **No scroll tricks:** no scroll-snap, scroll-jacking or smooth-scroll library. The one exception
is tech: the big scene and a dealt scroll moment run inside their own section (`data-big-scene`,
`data-moment`), which stays exactly 1H or 2H (a scene with `{"hold": 1}`: two screens live; its
composition at rest one or two, `scenes.md`, Its height); they may pin only with
`position: sticky` inside that box. Nothing else on any page is sticky.
- **No scroll tricks:** no scroll-snap, scroll-jacking or smooth-scroll library. The one exception:
the big scene (tech) and a dealt scroll moment (every kind, `moments.md`) run inside their own
section (`data-big-scene`, `data-moment`), which stays exactly 1H or 2H (a scene with
`{"hold": 1}`: two screens live; its composition at rest one or two, `scenes.md`, Its height),
except `photo-chapters`, which is (hold + 1) whole screens live (3H for three chapters, 4H for four
or five, set by the moment; no `--h` on it) and as tall as its chapters at rest; they may pin only
with `position: sticky` inside that box. Nothing else on any page is sticky.
## 3. The hero
@@ -139,16 +141,21 @@ Dealt by kind and by the material the brief really has, never by taste, never th
pages; a place never deals (the place hero below):
```bash
node <skill>/assets/layout/deal.mjs --type software|product|place|service [--kind gaming|tech] --has photo,ui,film,object --history .taste-skill/layout-history.json
node <skill>/assets/layout/deal.mjs --type software|product|place|service [--kind gaming|tech] --has photo,ui,film,object,setting --history .taste-skill/layout-history.json
```
`--type` is the page type (`SKILL.md` section 1: a game is `software`, hardware `product`). A game
adds `--kind gaming` (its own weights, never `ui`): `--has film,photo` when the brief supplies its
trailer or key art, `--has object` without art files (its key art rendered with the product-render
helper on the `product` stage; a game never deals `statement` or `word`, and always passes `--has`);
hardware adds `--kind tech`. A product or hardware with no photographs passes `--has object`, which
always prints the `product` hero: its render whole on the full-width stage, its widest view, never a
`split` column or an order form alone (`sections.md`, Product). Printed, never dealt: the place hero.
trailer or key art, `--has setting` without art files (stand-in photographs of its setting from
`photos.mjs search`: it prints the `photo` hero, the game's name set large on the calm side; a
game never deals `statement` or `word`, and always passes `--has`); hardware adds `--kind tech`.
A product or hardware with no photographs of its own passes `--has photo` for its stand-ins
(`photos.md`, A product without photographs); `--has object` only for a cut-out or when the brief
asks for 3D or a render, and it always prints the `product` hero: the product whole on the
full-width stage, its widest view, never a `split` column or an order form alone (`sections.md`,
Product). A product page is dealt only the `product`, `photo` and `film` heroes: a `split`,
`word`, `statement` or `triptych` hero cannot show the product at 80% of the content width.
Printed, never dealt: the place hero.
It prints the hero (its demo, numbers, and the moment, ground and effect ids that fit the kind; on
other only the hero itself counts). Copy only its `<section class="hero …">` from
@@ -161,7 +168,7 @@ dealt only without a strong photograph.
|---|---|---|---|
| `split` | text span-5; the picture span-6 start-7, top-aligned with the h1, ending on the right line; one opaque float card on its lower left | text, then the picture (at least 240 tall) | the float card's dot (tech) |
| `photo` | a full-bleed photograph layer, the words on its calm, dark side on six columns (h1 80, two lines), four facts at the foot | the photograph cut to the top, the words under it on the ground | none |
| `product` | a head row (h1 cols 1-7 on two lines; the lead and the buttons stacked on cols 9-12, their foot on the h1's), then the product itself big with one to three hotspots: a render (its widest view, `data-product-place="stage"`) or a cut-out on the page's ground across the wrap, no box, gradient or radius, the stage down to 24 above the fold (1280x566 at 1440x900: the city bike's side 1046 wide, 82% of the wrap); a product photograph edge to edge down to the fold, square corners. A game names itself in the h1; a `[data-print]` or `[data-scene]` on the stage keeps the lit, rounded stage | stacked; the render 86% of the column (294 of 342), the photograph the screen width; a tap opens a hotspot | hotspots on click; a render is a still (live only when asked, `data-product-mode="live"`) |
| `product` | a head row (h1 cols 1-7 on two lines; the lead and the buttons stacked on cols 9-12, their foot on the h1's), then the product itself big, nothing on it (hotspots only when the brief asks for them): a product photograph edge to edge down to the fold, square corners; a cut-out or a render (its widest view, `data-product-place="stage"`, only when the brief asks for 3D) on the page's ground across the wrap, no box, gradient or radius, the stage down to 24 above the fold (1280x566 at 1440x900: the city bike's side 1046 wide, 82% of the wrap). A game names itself in the h1; a `[data-print]` or `[data-scene]` on the stage keeps the lit, rounded stage | stacked; the photograph the screen width, a render 86% of the column (294 of 342) | none; a render is a still (live only when asked, `data-product-mode="live"`) |
| `editorial` | the headline is the picture: 156px on three lines across the wrap, one or two photographs set in the line as pills | 56px on four lines | the pills open once on load (tech); still elsewhere |
| `collage` | text span-5; a lit frame span-6 start-7 with the product window cut by its bottom, one result card that loops | the window alone | one card loop (tech) |
| `word` | an oversized h1 (144px, two lines) with one live word last, underlined; lead and buttons span-6, four facts span-5 start-8 | 54px on three lines | the live word, two rounds then rest (tech only) |
@@ -179,8 +186,8 @@ caption under the picture.
### The place hero
Every place page, never dealt: one still, full-bleed photograph of the place under the header with
nothing on it (no text, scrim, gradient, fade or box), the words on the ground below. No moment, no
move.
nothing on it (no text, scrim, gradient, fade or box), the words on the ground below. It enters
once on load like every hero (`motion.md`, The hero's entrance); no moment in the hero itself.
- 1440: `.hero.hero--photo` on `html[data-type="place"]`, `--photo: 480px` (the photograph at y 72
to 552, its subject in the middle 640px), `--hgap: 40px`. Under it: the h1 at 48/53 on columns 1
@@ -190,9 +197,12 @@ move.
- A shop or bookshop may take a letterbox inside the column instead: 1200 x 504, 24px under the
header (`--photo: 528px`).
- 640 to 1023: the photograph at most 56vw tall, the words stacked under it, the hero ending by
H - 24. 390: the photograph 390 x 292 (a phone crop, subject centred), `--hgap` 24, the h1 at
32/37 (three lines at most), the sub-line at 18/28, one 342 x 48 button; the stack ends by
H - 24 - bar.
H - 24. 390: the photograph at least 390 x 292 (a phone crop, subject centred); with
`data-phone-fill` on it (photos.mjs prints it with the 4:5 phone crop) it grows by whatever the
words leave of the screen, to 390 x 488 at most (about 390 x 480 at 390x844), so the screen ends
on the button and never on an empty band; `--hgap` 24, the h1 at 32/37 (three lines at most), the
sub-line at 18/28, one 342 x 48 button; the stack ends at H - 24 (the phone bar waits until the
button has scrolled away; without script, H - 24 - bar).
- The photograph is real and of this place in daylight: the room from the door, the counter with
the machine, bread on shelves, the shopfront with customers, the workshop stand, an arrangement in
a real room, the best room or the house. Never a mood shot, scenery, a cut-out or a hazy file; a
@@ -236,13 +246,15 @@ move.
## 5. Type
- **Dealt, never picked:** `start.mjs deal --page <kind>` deals three Google pairings for the
kind; take the one that suits the client, then `start.mjs css --page <kind>`. A `word` or
kind; take the first (the next only when the first clashes with the client's own logo or
photographs, or the hero's stress test fails `hero-lines`), then `start.mjs css --page <kind>`. A `word` or
`editorial` hero needs a face with a weight or width axis: deal with `--axis`. Every pairing a
kind can be dealt, with its exact Google Fonts link: `start.mjs types --page <kind>`.
`library/type.json` is the one source of every face, weight, tracking and `--ly-scale`: keep the
`:root` block as start.mjs prints it. (`assets/layout/fonts.html` reads type.json and shows every
pairing; a hero demo's face is a specimen, not a source.) Load from Google
Fonts with `display=swap`; a fallback face never shows.
Fonts with `display=swap`; a fallback face never shows (`font-load` warns a family that
never loaded).
- One family, or a display face plus a text face with fixed jobs; never three; at most four font
files. Never Inter, Roboto, Open Sans, Lato, Montserrat, Poppins, Space Grotesk, Plus Jakarta Sans
or the system look.
+101 -50
View File
@@ -1,42 +1,68 @@
# Moments
A moment is the award-site part of a page (house rule 8; with the house overrides): the one place, or
two, where a page does something a visitor remembers; the rest stays still. Each is a folder in
`assets/moments/<id>/` (`moment.js`, `moment.css`, `meta.json`, a full `demo.html`); `index.json`
lists them, `gallery.html` links the demos and `moments.js` runs them. Seventeen folders: two
retired (`headline-arrives`, `closing-lift`), four left out of the house deal (below), and eleven
dealt: letters that fly into a statement, liquid type, a circle or a line that changes the ground,
a statement that lights up word by word, a photograph seen through giant type, pinned chapters, a counter, a marquee row, a pointer
light, a liquid photograph.
A moment is the award-site part of a page (house rule 8): the one place, or two, where a page does
something a visitor remembers, in a section of its own; besides it the page has only the hero's
entrance and the hover layer (`motion.md`, The rule). Each is a folder in `assets/moments/<id>/`
(`moment.js`, `moment.css`, `meta.json`, a full `demo.html`); `index.json` lists them,
`gallery.html` links the demos and `moments.js` runs them. Eighteen folders: five retired
(`headline-arrives`, `closing-lift`, `lines-rise`, `window-parallax`, `word-rotate`), and thirteen
dealt by kind (below).
**Which pages.** Kind software, gaming or tech (hardware: a product page with a technical subject)
only: one moment on a short page, two on a long one (eight sections or more, in two different
sections), never three. **A plain product, a service, a place, shop, cafe or guesthouse gets none**:
they lead with photographs, type and flat colour (the house overrides, point 3). `pick` makes this
the rule: it needs `--page`, and any other kind returns an empty deal. A moment counts against the
page's big scene (`effects-usage.md`) as one of its beats.
**Which pages: every page.** One moment on a short page, two on a long one (eight sections or more,
in two different sections), never three, on every kind. The place's short page (under 8
photographs, `sections.md` section 4) skips the type moment: its one moment is `picture-develops`
on the section's photograph (`--fits section --photo`, dealt again with `--exclude photo-chapters`
when it gives `photo-chapters`), or, with no photograph, `word-highlight` in a one-screen statement
section of its own, so the page is four screens.
| page kind | dealt from | look |
|---|---|---|
| place (cafe, bakery, guesthouse, shop, gym; a studio with a door is passed as `--page place`, since `--page studio` reads as service) | `photo-chapters`, `picture-develops` (film), `word-highlight` | photographs and type |
| product (site-kind other) | `photo-chapters`, `pinned-chapters`, `picture-develops` (film), `word-highlight` | photographs and type |
| service (firm, clinic, agency, trade) | `photo-chapters`, `pinned-chapters`, `picture-develops` (film), `word-highlight` | photographs and type |
| software, gaming, tech | the eleven that list them: every dealt moment but `photo-chapters` and `picture-develops` | as before 2.5.1 |
Why these for the other kinds: each is led by the client's own photographs or words, moves only in
its own section, costs no WebGL and reads as calm (a hotel, a law firm, a wedding photographer). Not
on them: anything with a shader, WebGL or a liquid or pointer effect (`liquid-hover`,
`liquid-type`, `cursor-spotlight`), anything that reads as a graphic trick (`iris-opens`,
`line-carries-colour`, `letters-assemble`, the halftone of `picture-develops`, and
`giant-type-pass`: a photograph inside letters reads as a template trick, muddies a warm palette
and is a parallax), a loop (`marquee-row`) and a count (`counter-rolls`). On those
pages the two of a long page are the statement moment (`word-highlight`) and one section moment
(the photographs), or the photograph that develops beside one of the chapters moments (two kinds:
one runs once as it enters, the other follows the scroll). `photo-chapters` and `pinned-chapters` never share a page and do different
jobs: photo-chapters tells a story in full-width photographs with one chapter at a time on a panel;
pinned-chapters walks through steps listed beside one frame (how a booking or an order goes).
A moment counts against a tech page's big scene (`effects-usage.md`) as one of its beats.
## Deal them, never choose by taste
```bash
# the section moments: one on a short page, two on eight sections or more (less a hero moment)
# a place, product or service page: one on a short page, two on eight sections or more
node <skill>/scripts/moments.mjs pick --page place|product|service --count 1|2 --fits section,statement \
--photo --history .taste-skill/history.json
# a software, gaming or tech page (less a hero moment)
node <skill>/scripts/moments.mjs pick --page <kind> --count 1|2 --fits section,statement \
--exclude lines-rise,picture-develops,window-parallax,word-rotate \
[--scene <id>] [--effects <ids>] [--contexts <n>] [--photo] --history .taste-skill/history.json
# a hero moment (0 or 1): the same with --count 1 --fits hero; kept only when the dealt hero lists it
# a hero moment on tech (0 or 1): the same with --count 1 --fits hero; kept only when the dealt hero lists it
```
- **`--page`** is the page's kind (`SKILL.md` section 1: `software`, `gaming` or `tech`; `ai`,
`saas` and `technical-product` are read too). Any other kind prints `no moment: ...` and an empty
deal (`--json`: `"picks": []` with a `reason`), exit 0: the plan then writes "no moment".
- **`--page`** is the page's kind (`SKILL.md` section 1: `software`, `gaming`, `tech`, `place`,
`product` or `service`; `ai`, `saas`, `technical-product`, `cafe`, `bakery`, `guesthouse`,
`hotel`, `shop`, `studio`, `agency`, `firm`, `clinic` and a few more are read too). A word that
names no kind (`other` is three kinds) stops the deal with exit 1: say which.
- **`--photo`** on every place, product or service page (they all have photographs): without it
the photo moments drop out and a long page cannot be dealt two.
- **`--fits section,statement`:** a statement is a section of its own, so the letters, liquid type,
word-highlight and the counter come only with it; `section` alone deals none of them.
- **Always out:** `lines-rise` (a reveal), `picture-develops` (a photograph that develops),
`window-parallax` (parallax on a photograph) and `word-rotate` (the `word` hero carries its own
live word, `layout-v4.md`). A dealt scene's twins and avoidMoments are left out by `--scene <id>`
itself.
- **No kind needs `--exclude`:** each pool is what lists the kind in `meta.json` (the short page's
`photo-chapters`, above, is the one case).
`picture-develops` is dealt only in its film look, on place, product and service; its halftone
look stays in the kit as `data-moment-opts='{"look": "print"}'` with its print-shop demo, never
dealt. A dealt scene's twins and avoidMoments are left out by `--scene <id>` itself.
- **The hero moment** (0 or 1, never on a big-scene hero) is kept only when the dealt hero lists it
in `heroes.json` (`moments[].id`): after the excludes that is `liquid-hover` on `split` and
in `heroes.json` (`moments[].id`): that is `liquid-hover` on `split` and
`photo`, `cursor-spotlight` on `product`, `collage`, `film` and `centred`, `liquid-type` on
`statement` (tech, a headline of six words or fewer), and none on the others. Off touch only;
reduced motion rests. Two moments in all at most.
@@ -51,28 +77,33 @@ node <skill>/scripts/moments.mjs pick --page <kind> --count 1|2 --fits section,s
back only after all the others and never straight after itself (the three dealt last stay out
even across a cycle's end). Never two of one kind (hover and liquid count as one: one pointer
effect a page), never two of one slot, never two that avoid each other, a moment that needs a
photograph only with `--photo`. Write each id and its section in the plan. A dealt moment with
photograph only with `--photo`; on a place, product or service page one statement and one
section, or two kinds (`picture-develops` with a chapters moment). Write each id, its section and its purpose in the plan (`motion.md`, Why it moves). A dealt moment with
nothing to carry is dealt again with it added to `--exclude`.
- **Retired** (`meta.retired`, weight 0, never dealt): `headline-arrives` (MO-62, the headline never
hides on load) and `closing-lift` (it needs a closing band of its own and its words stand on a
gradient over the photograph). `magnetic-button` (CK-94) is gone.
- **Retired** (`meta.retired`, weight 0, never dealt): `headline-arrives` (every hero now enters
through the motion layer, `motion.md`, The hero's entrance; a moment that also moves the headline
would be a second entrance) and `closing-lift` (it needs a closing band of its own and its words stand on a
gradient over the photograph); since 2.5.1 also `lines-rise` (every hero's lines already rise on
load), `window-parallax` (a parallax on a photograph) and `word-rotate` (the `word` hero carries
its own live word, `layout-v4.md`). `magnetic-button` (CK-94) is gone.
## Where each fits
| id | kind · fits | phone and reduced motion |
|---|---|---|
| word-rotate | text · hero, three rounds then rests | the written word stands |
| lines-rise | text · one section or statement, once | the heading and its lines stand |
| word-rotate | retired · hero, three rounds then rests | the written word stands |
| lines-rise | retired · one section or statement, once | the heading and its lines stand |
| counter-rolls | text · the proof figures, once; never in a card | the figures stand |
| word-highlight | scroll · one statement | the whole statement in ink |
| giant-type-pass | scroll · one statement with a photograph of mid to dark tones | the photograph stands in the letters, smaller type |
| word-highlight | scroll · one statement | runs on a phone; reduced motion: the whole statement in ink |
| giant-type-pass | scroll · one statement with a photograph of mid to dark tones (tech only) | the photograph stands in the letters, smaller type |
| letters-assemble | scroll · one statement of 2-5 words, set very large: its letters fly into the line | the statement as written |
| pinned-chapters | scroll · one feature section of 2-4 steps, two screens live | static rows (or each step with its visual under it) |
| window-parallax | scroll · one wide photograph | the photograph stands in its frame |
| window-parallax | retired · one wide photograph | the photograph stands in its frame |
| marquee-row | scroll · a row of real words (trades, cities, formats; never logos: a logo never moves), with a pause button | the words in a wrapped row |
| line-carries-colour | transition · a section on a new ground keeps it edge to edge; inside, an inset panel in the old ground (even gutter, card corners) is cleared by a line climbing from its foot; each block shows whole once the line has passed it | the section on its own ground, no panel, no line |
| iris-opens | transition · a section on a new ground keeps it edge to edge; inside, an inset panel in the old ground (even gutter, card corners) opens a disc with a hairline accent ring; each block shows whole once the disc holds it | the section on its own ground, no panel, no ring |
| picture-develops | transition · one photograph with a clear subject, once | the photograph stands |
| picture-develops | transition · one large photograph with a clear subject, once, 1.6 s (look `film`): it comes up out of a faint grey print, darks first, then colour; the halftone look `print` is not dealt | the film look runs on a phone; reduced motion: the photograph stands |
| photo-chapters | scroll · one section of 3-5 chapters (a title and two lines each, one photograph each) under its h2 and lead: one photograph the width of the page, each next one uncovered from its foot over the last, the chapter on a panel at its lower left; about two thirds of a screen of scroll a chapter, so the section is three screens live (four with four or five chapters); place, product, service | runs on a phone held upright; reduced motion: the chapters side by side, each photograph over its words (a phone stacks them), the section as tall as they are |
| closing-lift | transition · the close, with a photograph | a plain closing section |
| cursor-spotlight | hover · the hero frame | the light rests |
| liquid-type | liquid · a hero or statement headline ripples like water under the pointer, an SVG filter, no WebGL | the headline as written |
@@ -85,8 +116,12 @@ without script or when its module fails. The loader decides: it hands each momen
`reduced()` answers `lib.still()`, which is true under `prefers-reduced-motion` and on a phone (under
640px wide, or a touch screen turned sideways under 500px tall). A moment written against `reduced()`
therefore shows its finished state there, and the host carries `data-moment-still="reduced"`,
`"phone"` or `"card"`. The answer is taken when the moment mounts (a window resized across 640px
applies it on the next load; `pinned-chapters` asks again on resize). Each `meta.json` says what the
`"phone"` or `"card"`. **Phones still get a moment:** the ones light enough for a phone (one
picture or one paragraph at a time; transform, clip, filter or colour) say `export const phone =
true` in their `moment.js` and `"phoneRuns": true` in `meta.json`, and run there; only reduced
motion and a card keep them still. Those are `word-highlight`, `picture-develops` (film) and
`photo-chapters` (from 600px of height), which covers every place, product and service deal. The answer is taken when the moment mounts (a window resized across 640px
applies it on the next load; `pinned-chapters` and `photo-chapters` ask again on resize). Each `meta.json` says what the
still shows (`reduced`, `phone`). Hover moments do nothing on touch screens either.
**Nothing on a card moves on scroll** (house rule). A moment sits on a hero, a statement, one
@@ -98,7 +133,10 @@ leaves one that does still (`data-moment-still="card"`, `data-moments-card` on `
The host is a section of the page (or, for the text moments, the headline or the row inside it);
mark it as the moment's header comment shows (`data-moment="<id>"`, options in `data-moment-opts`).
Every section stays one or two screens tall (`layout-v4.md`, `--h`), live and at rest: a transition's
Every section stays one or two screens tall (`layout-v4.md`, `--h`), live and at rest, but the two
chapters moments: `photo-chapters` is (hold + 1) screens live (three for three chapters, four for
four or five; write no `--h` and no `data-screen` on it: the checker and the layout check both read a
live photo-chapters section as whole screens, four at most) and both are as tall as their content at rest. A transition's
host is the whole section that sits on the new ground, a statement moment is one section with its h2
first, a marquee or counter row merges into the section above as its last row. In `<head>`, the one line plus a `modulepreload` for
the loader, its lib and the dealt moment:
@@ -120,44 +158,57 @@ module gets `data-moment-failed` and the class `mo-safe` on its own element, whi
reads as "show the finished state now". A demo's copy and photographs are placeholders. Every moment
takes the page's tokens (`--bg`, `--ink`, `--muted`, `--accent`, `--line`, the display face).
A moment's section in a page is copied from one of the two page models, which mount moments inside
a starter page and pass the checker: `assets/moments/_demo/in-page.html` (a place:
`photo-chapters`, `word-highlight`) and `in-page-service.html` (a service: `pinned-chapters`,
`picture-develops` in its film look). A moment's own `demo.html` shows it alone and is not a page
model.
## Rules
- **The kind's count, an id once.** `data-moments-over` and `data-moments-repeat` on `<html>` are faults.
- **No random effects as you scroll.** A scroll moment is scrubbed to the scrollbar inside its own
section, which stays one or two screens tall, and does one purposeful thing; at the section's top
section, which stays one or two screens tall (`photo-chapters` three or four), and does one purposeful thing; at the section's top
and bottom it shows a finished state (every transition and `letters-assemble` finish before the
section's top is a fifth of the screen down), so each judge tile shows one whole section. No card,
row or tile ever enters on scroll. `consistency.mjs` reports a scroll finding inside the dealt
`[data-moment]` section; that one is expected, any other is a fault.
- **Content is readable without the motion:** the words are in the HTML, in their finished place; a
moment never hides a headline on load (`headline-arrives` is retired for it).
moment never hides a headline on load (the hero's own entrance is the motion layer's, `motion.md`).
- **Photo-led on the other kinds:** a place, product or service page's moment carries the client's
own photographs (or library ones that pass `photos.md`), each used once on the page, and its
words are the page's real copy; never a stand-in picture to give the moment something to move.
- **One count a page:** `counter-rolls` or `data-m-count` (`motion.md`), never both.
- **Nothing blinks, bounces or overshoots.** Ease-outs, 600-1,000 ms for a reveal; a loop runs its
rounds and rests, or carries a pause button.
- **Nothing blinks, bounces or overshoots.** Ease-outs (cubic-bezier(.22,1,.36,1) for a settle),
600-1,000 ms for a reveal, 1.6-1.8 s for a photograph developing; a scrubbed wipe or change takes
about half of its chapter's share of the scroll and holds for the rest; a loop runs its rounds and
rests, or carries a pause button.
- **The page's edges.** A moment's text sits in a `.container` or `.wrap` like every other section: its
words end on the page's left and right edges. A transition's cover is an inset panel inside the host
section with an even gutter on every side (card corners, never touching the section's edges), its
line or ring inside the panel; every heading, paragraph, button and picture appears whole, never cut
by the line or the disc (each moment's `meta.json`, `look`, says how).
- **Cost:** no WebGL but `liquid-hover` (one context, only near the screen; a plain photograph without
- **Cost:** no WebGL but `liquid-hover` (tech only) (one context, only near the screen; a plain photograph without
WebGL or when the shader fails). `liquid-type` is one SVG filter on one headline, on only while a
ripple is alive. Every other moment costs CSS variables, a transform or a clip and a passive
listener.
- **One pointer effect a page:** a hover or liquid moment rules out `fx-button`, `fx-spotlight` and an
object that turns to the pointer, and the reverse; `pick` never deals two of them.
- **Photographs:** `giant-type-pass` and `liquid-hover` want mid to dark tones with edges and detail; a
- **Photographs:** `giant-type-pass` and `liquid-hover` (tech) want mid to dark tones with edges and detail; a
pale dusk sky reads lavender in a letter and a flat sea shows no ripple. Never violet or lavender.
- **No captions;** the photograph's credit goes in the footer line.
## Check it
- `node <skill>/scripts/moments.mjs check <page.html> --page tech [--scene <id>] [--effects a,b] [--contexts n]`
reads the built page: how many moments, any twice, retired, off its page kind, in a card, holding a
figcaption, two that avoid each other, the WebGL total against 4, a missing loader. Exit 1 on a fault.
- `node <skill>/scripts/moments.mjs check <page.html> --page <kind> [--scene <id>] [--effects a,b] [--contexts n]`
reads the built page: how many moments, any twice, retired, off its page kind (a WebGL or effect
moment on a place is off its kind), in a card, holding a figcaption, two that avoid each other, two
of one slot on a place, product or service page, the WebGL total against 4, a missing loader. Exit 1
on a fault.
- `node <skill>/scripts/moments.mjs shoot --out shots/moments --profile <own chrome dir> --port 5720 --only <id> [--reduced]`
shoots the demo's start, middle and end states at 1440 and 390 in the skill's headless Chrome (WebGL
on the CPU); the end must be the finished section. At 390 and under `--reduced` it also checks the
still rule: the host must say `data-moment-still` and no animation may run inside it (a line
`STILL?` otherwise). `--probe "<js expression>"` prints a value after each state.
on the CPU); the end must be the finished section. Under `--reduced`, and at 390 for a moment
without `phoneRuns`, it also checks the still rule: the host must say `data-moment-still` and no
animation may run inside it (a line `STILL?` otherwise). `--probe "<js expression>"` prints a value after each state.
- The headless Chrome draws WebGL on the CPU, so `liquid-hover` shows a fainter ripple there than on a
GPU and its end state waits 6 s to be the plain picture; a text or scroll moment looks the same on both.
+129 -54
View File
@@ -5,62 +5,126 @@ after `ui/cards.css`, `assets/motion/motion.js` (module) after `system.js` (`sta
three). **It is not optional:** never remove, skip or override it; if a check complains about it,
fix the markup, not the layer. A missing or invisible hover layer is a fault on every kind (round
C: a fintech, a cafe and a guesthouse page lost motion points because a hovered card or button
showed no change); a still page is not. Give it markup to act on: a linked card is a `.card`
showed no change), and since 2.5.1 so is a page without its hero entrance or its dealt moment. Give it markup to act on: a linked card is a `.card`
holding its link (never `.m-nolift`), a photograph that leads somewhere is `<a class="m-zoom">`
round its `<img>` (or `img.frame__img` inside a linked frame), buttons are `.btn`, links in
running text `.link`. Shoot a hovered card and a hovered photograph at 1440 and see
them change: the card 2px up, the photograph at 1.03. `assets/motion/demo.html` shows every interaction. This file
holds every hover, press, loop and reduced-motion number; other files point here.
What may move, by kind (house rules 5 and 8):
- **Every kind:** the hover and focus layer below, and swaps a click or a tap makes (a tab on
software, a peek-list's photograph). Every change is the visitor's.
- **Tech:** also loops inside a card's visual (diagram pulses included), one timed loop in the hero
or the `[data-visual]` frame, one count, and the effect, moments and scene `effects-usage.md`
deals.
- **Other (product, service, place):** the hover layer alone. Nothing moves by itself, on load, by
time or on scroll: no entrance, ticker, marquee, slideshow, looping picture, pulsing dot, count or
moment. An open-now line may be filled once at load; the panel reads right without it. A
product render is a still by default; live only when asked (`data-product-mode="live"`), at
most one live context, with its pause button in the corner, a still on a phone and under reduced
motion (`effects-objects.md`).
## The rule: every page moves, in three places
Every page, every kind, has the same three layers of motion, and nothing else moves:
1. **The hero's entrance** on load, once (below): the h1 line by line, then its lead and buttons, the
picture settling. About 1.1 s, then the hero stands.
2. **One dealt moment** in a section of its own (two on a long page: eight sections or more), big
enough to remember: `moments.mjs pick --page <kind>` deals it (`moments.md`). On a place, product
or service page it is photo- or type-led (photographs that change in place, a photograph that
develops, a statement that lights up); shader and WebGL
effects stay on tech, gaming and software (house rule 8: a cafe, bakery, guesthouse or shop gets
photographs, never a shader).
3. **The hover and press layer** below, on every link, button, linked card and linked photograph,
and the swaps a click or a tap makes (a tab on software, a peek-list's photograph).
On tech, gaming and software pages also: loops inside a card's visual (diagram pulses included),
one timed loop in the hero or the `[data-visual]` frame, one count, and the effect and scene
`effects-usage.md` deals. Other pages have none of those: no ticker, marquee, slideshow, looping
picture, pulsing dot or count. An open-now line may be filled once at load; the panel reads right
without it. A product render is a still by default; live only when asked
(`data-product-mode="live"`), at most one live context, with its pause button in the corner, a still
on a phone and under reduced motion (`effects-objects.md`).
**Scrolling moves nothing but the dealt moment.** No card, row or tile ever fades, rises or staggers
in as the page scrolls (stock reveals), no text moves at another speed than the page (parallax on
text), and the page is never held or steered by script (scroll-jacking, smooth-scroll libraries).
A moment may hold its own section on screen with `position: sticky` while the page scrolls natively.
**Why it moves.** The plan names each motion and its purpose in a few words, one line each: "hero
entrance: the name arrives first, then what to do"; "photo-chapters in Rooms: three rooms shown in
the space of one"; "hover: every link answers". A motion with no purpose is cut. Content is
readable at rest, without script and under reduced motion: motion only ever adds to a page that is
already whole.
## Tokens
`--m-in` 140ms (hover in), `--m-out` 200ms, `--m-press` 90ms, `--m-panel` 200ms, `--m-slide`
240ms (an indicator travelling), `--m-image` 600ms, `--m-cycle` 3.2s; tech loop tokens:
`--m-roll` 700ms, `--m-count` 1000ms, `--m-level` .6s, `--m-bob` 3.2s. Ease `--m-ease`
cubic-bezier(.2,.8,.2,1) for every UI step; `--m-ease-io` cubic-bezier(.65,0,.35,1) only for
travel; cubic-bezier(.22,1,.36,1) only for digit rolls; linear for loops and drifts. No bounce or
overshoot. Distances: card lift 2px, photo zoom 1.02 to 1.03, arrow 3px, menu drop 6px, button step 1px
`--m-in` 150ms (hover in), `--m-out` 120ms (hover out), `--m-press` 90ms, `--m-panel` 200ms (a menu opening), `--m-close` 120ms, `--m-slide`
240ms (an indicator travelling), `--m-image` 600ms in and `--m-image-out` 400ms, `--m-cycle` 3.2s;
tech loop tokens: `--m-roll` 700ms, `--m-count` 1000ms, `--m-level` .6s, `--m-bob` 3.2s.
Curves: one UI ease-out, `--ease` in `system.css` = cubic-bezier(.2,.8,.2,1) (`--m-ease` and the
fragments' `--u-ease` read it) for every UI step; `--m-ease-enter` cubic-bezier(.22,1,.36,1) for
an entrance settling (the hero, a moment, a digit roll; `--u-spring` in `ui/base.css` is the same
curve); `--m-ease-io` cubic-bezier(.65,0,.35,1) only for travel; linear for loops and drifts. No
curve passes its mark: no bounce, no overshoot. The motion layer moves transform, scale and opacity
only (a hover also changes colours) and never animates width, height, top, left, margin or padding.
Inside a dealt moment a few more cheap properties move, each named in its `meta.json`: a clip-path
(a wipe), a filter (a photograph developing), the colour of words (a statement lighting up) and,
on tech only, a background position (a photograph passing through type). Distances: card lift 2px, photo zoom 1.02 to 1.03, arrow 3px, menu drop 6px, button step 1px
up (2px on `.btn--xl`; none on the text-link secondary of the `arrow` and `block` looks), press 1px
down and scale .985 (that secondary: scale only); no others (`assets/motion/motion.css`, buttons).
## The hover layer
Hover only for a fine pointer; every hover control also works by tap and keyboard and has a focus
state; nothing resizes or pushes a neighbour; no rotate, skew, glow, shine sweep or chip glide.
Hover moves only for a fine pointer (`(hover: hover) and (pointer: fine)`; keyboard focus shows the
same state anywhere); every hover control also works by tap and keyboard and has a focus state; nothing resizes or pushes a neighbour; no rotate, skew, glow, shine sweep or chip glide.
| Part | Motion | Time |
|---|---|---|
| Button | on hover the fill steps 14% toward the ink (an outline gains a faint fill and a stronger border) and the box steps up 1px (2px on `.btn--xl`); press sinks it to 1px below rest and scales .985, so a hover shot and a press shot each show a move; the text-link secondary of the `arrow` and `block` button looks keeps its underline and does not step (its press scales .985 only); fine pointer only; page CSS never sets a `.btn`'s `transform` or `transition` | 140ms in, 200ms out, 90ms press |
| Arrow | `svg.m-arrow` moves 3px right and settles back; one arrow style a page | 140 / 200ms |
| Link | one underline style at regular weight: running text and `.link` rest on a faint 1px hairline and an ink line draws left to right, leaving to the right; nav and footer words rest bare, darken and draw a 1px line 4px under the word | 140 / 200ms |
| Nav | the words take the drawn underline (no gliding pill on any kind); the panel opens after 150ms of intent, fades in with a 6px drop, its columns together, closes in 120ms; with one open the next word swaps it at once; the chevron turns 180 degrees on tech, flips at once on other | 200ms, 120ms |
| Button | on hover the fill steps 14% toward the ink (an outline gains a faint fill and a stronger border) and the box steps up 1px (2px on `.btn--xl`); press sinks it to 1px below rest and scales .985, so a hover shot and a press shot each show a move; the text-link secondary of the `arrow` and `block` button looks keeps its underline and does not step (its press scales .985 only); fine pointer only; page CSS never sets a `.btn`'s `transform` or `transition` | 150ms in, 120ms out, 90ms press |
| Arrow | `svg.m-arrow` moves 3px right and settles back; one arrow style a page | 150 / 120ms |
| Link | one underline style at regular weight: running text and `.link` rest on a faint 1px hairline and an ink line draws left to right, leaving to the right; nav and footer words rest bare, darken and draw a 1px line 4px under the word | 150 / 120ms |
| Nav | the words take the drawn underline (no gliding pill on any kind); the panel opens after 150ms of intent, fades in with a 6px drop with its columns together (one surface, never column after column), closes in 120ms; with one open the next word swaps it at once; the chevron turns 180 degrees on tech, flips at once on other | 200ms, 120ms |
| Current page | a 2px bar under its nav word (`aria-current="page"`) | still |
| Tabs (software, click only) | one indicator slides to the clicked tab: the ink pill `system.css` draws, its label in the ground colour; the panel swaps (200ms at most); every panel one fixed height; on other no tabs | 240ms |
| Card with a link | lifts 2px, its border darkens a step, an optional soft shadow (blur 24px, 10% ink at most, only on hover) | 180ms, border 140 / 200ms |
| Tabs (software, click only) | one indicator slides to the clicked tab by transform alone (it takes the new tab's size at once, starts scaled to the old one and settles to scale 1): the ink pill `system.css` draws, its label in the ground colour; the panel swaps (200ms at most); every panel one fixed height; on other no tabs | 240ms |
| Card with a link | lifts 2px, its border darkens a step, an optional soft shadow (blur 24px, 10% ink at most, only on hover) | 180ms in, 120ms out |
| Card without a link | nothing (`.m-nolift`) | |
| Photograph in a linked card or link | scales 1.02 to 1.03 inside its frame (`overflow: hidden`); the frame, card and boxes never grow; never dims, fades or takes an overlay; a product card's name underlines with it (`.m-zoom` for any other wrapper) | 600ms |
| Focus | a 2px ring in the accent, its offset settling from 6px to 2px | 200ms |
| Photograph in a linked card or link | scales 1.02 to 1.03 inside its frame (`overflow: hidden`); the frame, card and boxes never grow; never dims, fades or takes an overlay; a product card's name underlines with it (`.m-zoom` for any other wrapper) | 600ms in, 400ms out |
| Focus | a 2px ring in the action colour, 2px off the control, drawn in full in the frame the focus lands; no transition | none |
| Field | the action border and a 3px soft ring | 140ms |
| `.m-switch`, `.m-check` (tech) | the knob slides 16px and stretches 4px while pressed; the tick draws; on other they jump | 180, 90, 160ms |
| `.m-switch`, `.m-check` (tech) | the knob slides 16px and stretches 4px while pressed, both by transform; the tick fades in as it grows from 70% to full size; on other they jump | 180, 90, 160ms |
| Copy button (tech) | the glyph swaps to a check in place, holds 1.5 to 2 s, returns | 150ms |
FAQs show every answer open (`system.css`): no accordion, and nothing ever animates height,
max-height or padding. Hooks: `[data-m-loop]` (your own CSS loop, tech), `data-ui-loop` (a card
group's one fragment loop, tech), `data-m-count` (tech hero only), `.m-link`, `.m-zoom`, `.m-nolift`.
## The hero's entrance (every kind)
Once on load the hero arrives, then stands: the h1 rises line by line out of word masks (700ms each,
the lines 80ms apart, at most 240ms between the first and the last; each word starts wholly under
its mask), then whatever follows it in its column (the lead, each button, the facts) fades up 12px,
520ms each, 60ms apart, starting as the last line is half way up; a line above the h1 (a notice, a
pill) comes with its first line. The hero's picture is never hidden and never fades: a photograph
settles from 1.05 to 1 over 1.1 s inside its frame (a full-bleed one inside the hero), a window,
object or collage from .98 over 900ms, each as one block (the words on a menu card or a note in it
never step on their own); a panel the words sit on stands from the first paint. All of it about
1.1 s, ease-out (`--m-ease-enter`), transform, scale and opacity only, once a visit, never again on
scroll.
An h1 that `layout.js` reworks (a live word, letters that answer the pointer, a picture in the
line) rises whole instead of line by line.
The builder writes two things, and nothing else (`starter.html` has both):
```html
<!-- in <head>, after the stylesheets (where starter.html has it): the words wait for motion.js instead of flashing -->
<script>document.documentElement.classList.add('m-enter');setTimeout(()=>document.documentElement.classList.add('m-enter-done'),700)</script>
<!-- on the dealt hero's section -->
<section class="hero hero--split" data-enter aria-labelledby="hero-h">
```
Optional: `data-enter-media` on the hero's picture when it is none of the layouts' own
(`.hero__bg`, `.hero__media`, `.hero__stage`, `.hero__art`, `.hero__window`, `.hero-visual`,
`.ly-collage`); `data-enter-whole` on an h1 that must rise as one; `data-enter="off"` on a hero
that holds a big scene with its own load motion (the checker takes the scene as the entrance, and only
inside such a hero). `motion.js` does the rest (`enter()`): it lends
the h1 its word masks for the length of the entrance and gives the h1 its own nodes back afterwards
(`.is-entering` while it runs, `.is-entered` after). Only the words are hidden, and only while
`html.m-enter` is on and before `motion.js` takes over or the 700ms mark passes: without script,
under reduced motion, in print and with `data-motion="off"` the hero stands as written; when the
script is later than the mark, the words fade in over 400ms and the picture settles by CSS alone. A headless screenshot sees the hero at rest (as it sees a count's final number)
unless `<html data-motion="live">`. Never a loader, a gate, a delay before reading, or a second
entrance (`headline-arrives` is retired for it).
## Loops inside a card's visual (tech)
House rule: a bit faster, and repeating.
@@ -107,9 +171,9 @@ House rule: a bit faster, and repeating.
ground, never in a card, panel or `[data-visual]`; its width reserved; the final figure in the
HTML, shown under reduced motion, without script and on phones; never a year, price or phone
number. A live counter changes only its last one to three digits.
- **Load:** hero text is in place on the first frame on every kind, never faded or moved. On tech at
most one load-once motion inside the hero or `[data-visual]` (a visual assembling, 600 to
1200ms), settled by 1.5 s; a click swap 400 to 600ms. No gate, loader or intro.
- **Load:** the hero's entrance above, on every kind. On tech also at most one load-once motion
inside the `[data-visual]` frame (a visual assembling, 600 to 1200ms), settled by 1.5 s; a click
swap 400 to 600ms. No gate, loader or intro.
- **Pause:** anything still moving 5 s after it is seen has one round pause button per panel: a
solid 32px disc, 12px in from the bottom-right (in a card beside its title), always shown or
fading in on hover and focus, the same size and place on every card, never over copy, never on a
@@ -121,39 +185,50 @@ House rule: a bit faster, and repeating.
## Scroll
- **Other kinds, and every card on every kind:** nothing animates on scroll. No pinned or sticky
scene, scroll-snap, scroll or view timeline, reveal of any kind (fade, rise, stagger, mask wipe,
letters filling, words lighting, lines drawing), parallax, drift, scroll-tinted ground, progress
line, scroll-spy marker or count on view.
- **Tech:** the big scene and one or two dealt section moments may follow the scroll, each inside
its own section (`data-big-scene` with `{"hold": 1}`, `data-moment`), which stays 1H or 2H
(`scenes.md`, Its height), pinned only with `position: sticky` inside that box. Its text is live and visible without the motion (opacity 1,
4.5:1 at every step); reduced motion and no script show the finished still; phones get the still
or a light version (transform and opacity, no WebGL); `perf.mjs` passes. It never moves a card, a
- **Outside the dealt moment's section (and the tech page's big scene), on every kind:** nothing
animates on scroll. No card, row or tile reveal of any kind (fade, rise, stagger, mask wipe), no
parallax or drift, no scroll-snap, scroll-tinted ground, progress line, scroll-spy marker or count
on view.
- **Every kind:** the one or two dealt moments may follow the scroll, each inside its own section
(`data-moment`; on tech also the big scene, `data-big-scene` with `{"hold": 1}`), which stays 1H
or 2H (`scenes.md`, Its height; `photo-chapters` is 3H or 4H live, about two thirds of a screen a
chapter, `moments.md`), pinned only with `position: sticky` inside that box. A scroll
moment is scrubbed to the scrollbar (it runs back on the way up) and shows a finished state at both
ends; an enter moment (`picture-develops`) runs once, 1.6 to 1.8 s. Its text is live and visible without the motion (opacity 1,
4.5:1 at every step); reduced motion and no script show the finished still; phones run the light
ones (`word-highlight`, the film develop, `photo-chapters`: transform, clip, filter or colour, no
WebGL) and get the still of the rest; `perf.mjs` passes. It never moves a card, a
photograph, pricing, an FAQ, a quote block or the footer. Driven by native `animation-timeline`, a
passive scroll read written to a custom property once a frame, or GSAP ScrollTrigger scoped to that
section without its pin. No smooth-scroll library (Lenis, Locomotive, ScrollSmoother) on any page,
no animation library at all on other.
- The header and the phone bar look the same at every scroll position.
section without its pin (tech only). No smooth-scroll library (Lenis, Locomotive, ScrollSmoother)
on any page, no animation library at all on other: the kit's moments need none.
- The header and the phone bar look the same at every scroll position; the bar only comes up once
the hero's button has scrolled away and steps down while the screen shows one of its actions
(system.js, 240ms, at once under reduced motion).
## Reduced motion, no script, phones
Colours still change; menus fade; the tab indicator jumps; the underline appears without travelling
(`transition-property: opacity, color, background-color, border-color` on links, `.m-tab-ind`,
`.m-switch::before`, `.m-check::before`). Every loop, effect, moment and scene shows its finished
frame, marquees stand as a still row, numbers show their final text, pause buttons are hidden. On a
Colours still change; menus fade; a press shows as a deeper tone instead of a move; the tab
indicator and the switch knob jump to their place; the tick appears at full size; the underline
appears without travelling (`motion.css`, reduced motion: `transition-property` limited to
opacity and colours on links, `.m-tab-ind`, `.m-switch::before`, `.m-check::before`). The hero
stands as written. Every loop, effect, moment and scene shows its finished frame, marquees stand as a still row, numbers show their final text, pause buttons are hidden. On a
phone card loops and effects hold their finished picture or poster, heavy canvases become stills,
there are no hover states and no pointer simulations.
there are no hover states and no pointer simulations; the hero still enters, and the moments light
enough for a phone run there (`moments.md`, The still rule), so a phone page moves in two places too.
## Never
- A card, row or tile that reveals, staggers, slides or fades in as the page scrolls; parallax or a
scroll scrub outside the tech page's dealt moments and scene.
- A card, row or tile that reveals, staggers, slides or fades in as the page scrolls; parallax on
text; a scroll scrub outside the dealt moment's section (and the tech page's scene); a shader or
WebGL moment on a place, product or service page.
- A section heading that rises on every section, a second hero entrance, an entrance longer than
about 1.1 s, or an entrance that hides text a visitor could already be reading.
- A card that tilts, flips, scales, follows the pointer or glows at the cursor; a magnetic button.
- A carousel, a slider that advances by itself, timed tabs, a rotating notice, a logo marquee.
- Blinking at random: twinkling dots, glitter, flicker, hue cycling, scrambled text; every change
shows a state, a step or real data.
- A loop that plays once (outside a card story's replay), a card loop's cycle over 4 s, two loops in
one panel.
- A hover slower than its token, an overshooting ease, a delay before a button reacts, a second
underline or indicator style.
- A hover slower than its token, an overshooting ease, a delay before a button reacts, a focus ring
that animates, a second underline or indicator style, an animated width, height or margin.
+71 -24
View File
@@ -2,12 +2,12 @@
A place (café, bakery, guesthouse, shop, studio) is its photographs: two or three on every screen, the
client's own. Software leads with its real interface; a photograph there is a real person or the real
product. A game leads with its own art (key art, in-engine shots, the trailer's poster), never library frames; with no art files its key art is a product render of its hero object or world (`sections.md`, Gaming), never a photograph or an effect in its place. A photograph never stands where an effect, a drawing or an empty box would.
product. A game leads with its own art (key art, in-engine shots, the trailer's poster), never library frames; with no art files its name is set large over a stand-in photograph of its setting from a live search (`sections.md`, Gaming), never an effect or a random object in its place; a render of its world only when the brief asks for 3D. A physical product without photographs is shown in photographs too: the client's, else stand-ins (below). A photograph never stands where an effect, a drawing or an empty box would.
## Where pictures come from (PH-01, PH-02, PH-05)
1. **The client's own**, shot for the page or on a phone, each file named in the brief. Every `<img>` carries
`data-source` (`client`, `commissioned`, `phone`, `supplier`, `illustration`, `library-057`) and
`data-source` (`client`, `commissioned`, `phone`, `supplier`, `illustration`, `library-057`, `stand-in`) and
`data-subject`.
2. **The library** (`scripts/photos.mjs`): Wikimedia Commons and Unsplash frames, vetted one by one. They are
stock of another place, so they fill **detail slots only**: hands, tools, materials, a process (an oven, dough, a
@@ -20,8 +20,12 @@ product. A game leads with its own art (key art, in-engine shots, the trailer's
`weak` (kept with its credit and its why, no file on disk) and is never dealt; a rating below 7 is never dealt either (`quality`): a whole meeting room, lounge or breakfast room is the same fault. The thin trades get at most as many
frames as they hold: wine 0, bar 0, office 3 (a notebook, stacked books, sheets of card), florist 3, farm 3, shop 3, wellness 3, bakery 4, and a
workshop by its craft (smith 4, instrument 1, weaving 0, letterpress 0). The rest of the page is the client's own photographs, the object, the real UI or type.
3. **Nothing**: a product's own render (`effects-objects.md`, Product renders), the real UI, or type. Never a near
fit, never mood in place of the subject.
3. **A live search** (`photos.mjs search`, Live search below): licensed Wikimedia Commons photographs of what the
library lacks (a product's kind, a yoga studio, a bike workshop's bench, a hotel's building and rooms, a wedding, a
game's setting). Each one is a stand-in, looked at full size and listed in the report; `fetch` prints
`data-source="stand-in"`.
4. **Nothing**: the real UI, or type; a product's own render only when the brief asks for 3D (`effects-objects.md`,
Product renders). Never a near fit, never mood in place of the subject.
With no client photographs, say so plainly in the report: strong pages run on a half-day shoot with a local
photographer, then a one-morning phone shoot, then products on one backdrop; the library covers details only. The
@@ -36,8 +40,9 @@ cafe's bar). A place whose trade leads with land (a guesthouse, a lodge, a remot
names as `--place "<region>"` (or the brief itself, `--brief BRIEF.md`, which reads the region from it) and leads with that region's landscape: a place hero always carries a photograph when
any frame fits, and the `statement` hero is left for a place the library holds no frame of (`sections.md` section 4). Every frame is listed in the report as a stand-in to replace; nothing on the page says so
(no caption, no note). **The count decides, not the trade:** when `pick`, with each word repeated until it says "no new
motif left", still returns under eight frames, the page is the short page (PH-07: 2,700 px at most at 1440; `sections.md`
section 4, The short page), whatever the trade: a bakery (4), florist, wellness, office, pottery or a restaurant are examples. No drawn box, grid
motif left", and a live search (`search --trade <trade>`, below) has added what passes the full-size look, still returns
under eight frames, the page is the short page (PH-07: 2,700 px at most at 1440; `sections.md`
section 4, The short page), whatever the trade: a bakery (4 in the library), florist, wellness, office, pottery or a restaurant are examples. No drawn box, grid
or effect takes the place of a photograph.
Two exceptions, both for a draft or the short page and both named in the report as stand-ins to replace:
@@ -51,20 +56,52 @@ Two exceptions, both for a draft or the short page and both named in the report
## A product without photographs (PH-04)
A maker of a physical thing (a bike, a chair, a lamp, a stroller, a device) with no photographs of its own gets **no
stock photographs**: no forest for "the ride", no groceries for "what it carries", no other trade dealt to fill the page.
`pick --product-without-photos` (or a maker's, brand's or company's word: "backpack brand", "electric scooter")
deals nothing and says so. **The product is still shown, never hidden:** it is rendered with the objects kit's
product-render helper (`assets/objects/product-render`, `effects-objects.md` Product renders), one crafted studio
render composed from a JSON spec of its own parts, sizes and materials, and the page is the full product page
(`sections.md`, Product: hero, the product, what it carries, how it is used, specs as rows, a live order card), never
the short page (in testing: a cargo bike's page that never showed the bike scored 3 of 10). The render is the product's
picture on a flat ground, not an effect, on a product of site-kind other as on hardware. Never a drawn SVG, mood
stock, another maker's product or an object of another kind (a wheel is not a bicycle). A render or CAD view the
brief supplies comes first; renders it names but does not supply count as missing. The report lists the shots to
A maker of a physical thing (a bike, a chair, a lamp, a stroller, a device) with no photographs of its own is dealt
**no library frame** and **no mood stock**: no forest for "the ride", no groceries for "what it carries", no other
trade dealt to fill the page. `pick --product-without-photos` (or a maker's, brand's or company's word: "backpack
brand", "electric scooter") says so and prints the search. **The product is still shown, never hidden, in
photographs** (in testing: a cargo bike's page that never showed the bike scored 3 of 10): a picture the brief
supplies first (a cut-out, a CAD view, its own render; one it names but does not supply counts as missing), else
**stand-ins** from `photos.mjs search "<the thing in plain words>"`: the same kind of thing whole and in use (a cargo
bicycle for a cargo bike brand, never a wheel for a bicycle), close-ups of the parts the page names, where it lives;
no maker's name, logo or badge readable, no face passed off as a customer, one light and grade across the set. The
page is the full product page (`sections.md`, Product), never the short page. Every stand-in is listed in the report
with the client's photograph to shoot in its place, and nothing on the page says so. A render
(`effects-objects.md`, Product renders) only when the brief asks for 3D or a render. The report lists the shots to
take: the product on one plain backdrop, three angles, a detail of each part the page names, the product in use. A
shop, workshop or rental for every make is a place (`--trade "bike shop"`): it is dealt parts and a basket load for
those sections only.
those sections only, and its workshop bench and whole bikes come from a live search.
## Live search (stand-ins for what the library lacks)
`photos.mjs search` asks Wikimedia Commons for photographs (JPEG, 1600 px wide or more; CC0, CC BY, CC BY-SA or public
domain; files named as paintings, drawings, maps, logos or posters left out; one file of a series) and keeps the
results in `.taste-skill/found.json` under `--out`, so `fetch` and `credits` take their ids (`f` and a number) like
library ids. With `--trade` and no words it runs the trade's own search words:
| Trade | search words |
|---|---|
| yoga, pilates, spa, wellness | yoga studio, yoga class, pilates studio, sauna interior |
| pottery, ceramics | pottery wheel, pottery studio, ceramics workshop |
| a studio (art, design, photography) | artist studio, design studio, photography studio |
| bike shop or workshop | bicycle workshop, bicycle repair stand, bicycle mechanic, city bicycle, cargo bicycle |
| hotel, guesthouse, inn | hotel facade, guesthouse, hotel room, hotel bedroom, inn exterior (the building and the rooms: never the library) |
| wedding | wedding ceremony, wedding table, wedding bouquet, wedding reception, wedding rings |
| restaurant | restaurant kitchen, restaurant interior, chef plating |
| workshop by craft | woodworking workshop, blacksmith forge, workshop tools |
| bakery, cafe, florist, salon | the trade's own: bakery bread, bakery counter, baker dough; coffee shop interior, espresso machine; florist shop, flower market; barber shop, hair salon |
A product searches its own words (`"cargo bicycle"`, `"desk lamp"`, then `"... detail"` and the place it lives); a
game its setting in plain words (`"flooded coastal town"`, `"desert mining town"`, `"city at night in rain"`); anything
else the subject plainly, two or three words (long phrases find nothing). `--count`, `--min 1200` for a row picture,
`--orientation portrait`.
Every result is a **candidate**: open its page and look at the file full size. Keep it only when it shows the subject
sharp, in plain colour; no readable name, sign or logo of another business (a hotel's name on its facade, a club's
name on its wall: drop it or crop it out); no face shown as the client's staff, guests or customers; no landmark
passed off as the client's place; one light and grade with the rest of the page. Write its alt text new (the file's
title names someone else's place). It is a **stand-in**: the report lists it with the photograph to shoot in its
place; nothing on the page says so; its credit goes in the one footer line like any other.
## Pick and fetch
@@ -77,6 +114,8 @@ node <skill>/scripts/photos.mjs pick --trade cafe --sections hero,bar,bar,coffee
--history .taste-skill/history.json # no client photographs
node <skill>/scripts/photos.mjs pick --trade guesthouse --sections hero,rooms,rooms --pictures 12 \
--draft-lead --own-land --place "Lofoten, Norway" --history .taste-skill/history.json # a draft only
node <skill>/scripts/photos.mjs search --trade "yoga studio" --out . # what the library lacks: stand-ins
node <skill>/scripts/photos.mjs search "cargo bicycle" --count 8 --out . # a product without photographs
node <skill>/scripts/photos.mjs fetch <ids> [--hero <lead id>] --out . [--big id] [--lqip] # run from the project root
node <skill>/scripts/photos.mjs credits --out . --write # the footer line and credits.html; --text for a report
```
@@ -89,7 +128,8 @@ node <skill>/scripts/photos.mjs credits --out . --write # the footer line an
- `--trade`: cafe, bakery, hotel, bike (--shop only), food, florist, workshop, studio, office, shop, books,
outdoors, wellness, farm, wine, bar; a brief's word works (roastery, bread, inn, bookshop, yoga studio, blacksmith). No trade for a
restaurant, a ceramics studio, a patisserie, a salon, a jeweller, a goldsmith, a locksmith: its own photographs, else type. A **workshop**
restaurant, a ceramics studio, a patisserie, a salon, a jeweller, a goldsmith, a locksmith: its own photographs, else
stand-ins from a live search (`search --trade restaurant`, the table above), else type. A **workshop**
names its craft (`--trade blacksmith`, `weaving`, `letterpress`): only that craft's frames are dealt, so a smith's page
never shows a weaver's loom; no craft named, nothing is dealt.
- `--sections`: the page's sections in order, in plain words (bar, roastery, oven, dough, rooms, breakfast, workshop,
@@ -121,7 +161,7 @@ node <skill>/scripts/photos.mjs credits --out . --write # the footer line an
- `--tone light|dark`, `--mono` (black and white, one deliberate frame), `--shop` (every make).
- A place page's `pick` prints its hints for a place: words **under** the photograph on the ground, no band, no tab, no
closing text. Product and service pages get the band and calm-side hints.
- When `pick` says the library is short, search the open collections with the queries it prints (CC0, CC BY, CC BY-SA,
- When `pick` says the library is short, run `photos.mjs search` (Live search above: CC0, CC BY, CC BY-SA, each
looked at full size) before leaving the slot to the client's own or type. Detail frames held by default (`list` prints them live, after the full-size review that dropped 27 frames and merged 4; the 3 office rooms of the second merge were then set `weak`, as whole rooms; the third merge looked at 8 more at full size and kept none: a shopfront with its sign, two bars' counters and back bars, a breakfast room, a made-bed room, two breakfast tables rated below 7 and a half-eaten breakfast with a diner in the frame are all `weak`): cafe 19,
bakery 4 (oven 3, dough 1), hotel 9 (7 details and 2 breakfast frames; its land only with `--own-land`), bike 6 (parts and a load, --shop only), workshop 5
(smith 4, instrument 1, weaving 0, letterpress 0: a craft is dealt only its own), studio 5, books 5 (desk only: a pencil on a notebook, a binding, stacked books, pencils; the hands frames beside a laptop are not dealt, and the bookbinder's gilding is dealt only to a bindery, `craft: bookbinding`), food 5, shop 3, florist 3, farm 3, wellness 3, office 3 (2 desk, 1 card),
@@ -129,7 +169,7 @@ node <skill>/scripts/photos.mjs credits --out . --write # the footer line an
holds 3 or fewer gives a page at most that many; the rest is the client's own. `list` marks those trades `thin`.
- **Sections and subjects:** `hands`, `writing`, `notebook`, `paper` deal a hands or desk frame; `detail`, `texture`, `surface`, `linen`, `material` a detail, texture or desk frame; `breakfast` a hotel's breakfast frame (never a deli's);
`rooms` a detail of a room, never the room. When a section's frames exist but these options keep them out (no `--own-land`, another craft, a motif already dealt), `pick` says so instead of "nothing in the library shows it".
- **Variety:** the seed ranks the deal (a frame rated 8 or more has a head start of 0.3 in a 0-1 draw, not a place at the top), so a cafe's or a bike shop's deals differ from seed to seed; a trade with only a few frames (bakery 4, office 3, the two breakfast frames) returns the same set in every seed, in a different order. `--history` is the only way to vary those: pass it every time, and a frame dealt before goes behind the fresh ones. A frame marked `soft` (the rusty chain on a cassette, bike) is dealt only when no sharper frame of its subject is left, and never leads.
- **Variety:** the seed ranks the deal (a frame rated 8 or more has a head start of 0.3 in a 0-1 draw, not a place at the top), so a cafe's or a bike shop's deals differ from seed to seed; a trade with only a few frames (bakery 4, office 3, the two breakfast frames) returns the same set in every seed, in a different order. `--history` varies the deals of one site: pass it every time, and a frame dealt before goes behind the fresh ones. **Across folders**, set `TASTE_SKILL_RECENT=<file>` to share recent frames and palettes: `pick` and `fetch` then keep the frames of every build in that one small file (unset, the default, nothing is written outside the project; `--no-recent` leaves it out for one run), and a frame another build used lately goes behind the fresh ones of its subject, the most recent furthest back, so two cafes built in two folders show different frames where the library has them; when it has none left `pick` says so, and a live search fills the rest. A frame marked `soft` (the rusty chain on a cassette, bike) is dealt only when no sharper frame of its subject is left, and never leads.
## Place them
@@ -169,8 +209,12 @@ node <skill>/scripts/photos.mjs credits --out . --write # the footer line an
PH-27) is not in the library**: the library hero is the largest file within 400 KB (1200 to 1800) and looks soft on a
retina desktop. Another reason to replace it with the client's own.
- A hero (`--hero id`): `sizes="100vw"` (Recipe 3's top photograph is full width; never a vh term, which sends a tablet
and a 3x phone to the largest file), `fetchpriority="high"`, eager, and a 780x584 crop for the phone in a `<picture>`
`<source media="(max-width: 639px)">` (needs `ffmpeg`; subject inside the middle 342 px via the frame's `focus`).
and a 3x phone to the largest file), `fetchpriority="high"`, eager, and a 780x975 (4:5) crop for the phone in a `<picture>`
`<source media="(max-width: 639px)">` (needs `ffmpeg`; subject inside the middle 342 px via the frame's `focus`;
without `ffmpeg` the img's sizes get a phone term that covers the same 4:5 box). Both carry `data-phone-fill`: keep it,
so a place hero's photograph grows on a phone into what its words leave (`layout-v4.md`, the place hero). A hero
photograph of your own gets it only when its phone source covers 4:5 at 390 (else it stays 4:3: shown taller, a 4:3
file is blown up and FAILs `photo-upscale`).
Every other picture is `loading="lazy"` and `decoding="async"`, `width` and `height` set, `sizes` set to the shown
width. `--big id` adds the largest file to a full-bleed band only when it is within the 250 KB cap; `--lqip` puts the
picture's 24px colours behind it.
@@ -213,4 +257,7 @@ prints the same line and list as plain text for a report. Every word on a page i
- Mood stock (a curtain, plant shadows, a sky, grass, a forest, fog, an aurora, a waterfall), an animal or a vehicle in
the frame, a recognisable viewpoint, AI images, composites, Street View, a sight far from the place, a gradient,
grain, pattern or shader standing in for a photograph.
- The library's frames repeated across builds: pass `--history` every time, so a frame dealt before is not dealt again.
- The library's frames repeated across builds: pass `--history` every time, and when one session builds in several
folders set `TASTE_SKILL_RECENT` to one file, so a frame another build used lately goes behind the fresh ones.
- A live search result used without looking at it full size, with another business's name or logo readable, or left
out of the report's stand-ins.
+3 -3
View File
@@ -13,9 +13,9 @@ object render for the two object scenes.
`software`, `tech` and `gaming`.
- **At most one a page,** and only when the brief has what the scene `needs` (its own photographs,
its own screen, its own words; a game's key art and in-engine shots are its photographs). No need
met, no scene: never a stand-in for the client's pictures. A game without art files renders its
key art with the product-render helper (its hero object or a scene of its own objects) on the
`product` stage and in its world section, never an effect in its place (`sections.md`, Gaming).
met, no scene: never a stand-in for the client's pictures. A game without art files sets its name
large over stand-in photographs of its setting (the `photo` hero and its world section; a render
only when the brief asks for 3D), never an effect in its place (`sections.md`, Gaming).
- **Deal at random** among the scenes whose `fits` has the section, whose `types` has the page's
type and whose `needs` the brief meets, weighted by `weight`, away from the last three in
`.taste-skill/scenes-history.json` (the command is in `effects-usage.md`). Name the scene and its
@@ -176,7 +176,7 @@ pictures. What the recipes add:
width over height), two or three dimension lines (`.dim`) with their figures as
`span.drawing__dim`. Never the hero, never the product's only picture, never on hardware (a
technical product shows its photographs or the client's render), never on a page without
photographs: that page shows its product render (`sections.md`, Product; in testing: a drawn SVG
photographs: that page shows stand-in photographs of its kind (`sections.md`, Product; in testing: a drawn SVG
arm as a robot maker's hero).
# Proof
@@ -261,8 +261,10 @@ the rest in `<span class="muted">`. 48px below, `.frame.frame--hero` on `span-12
below, three facts on `span-4`: a 1px hairline, the value at 28px with its unit, a grey line. A
technical product may show the client's render or its object of its own kind instead (`.frame--lit`
with `<div data-print="print-…">`, its `hero` preset large and right of centre, the facts in
`div.frame__text` at the lower left). A product or hardware with no photograph shows its product render
(`data-product`, place `stage`) the same way, on the page's flat ground (the lit ground on hardware) (`sections.md`, Product; `effects-objects.md`, Product renders).
`div.frame__text` at the lower left). A product or hardware with no photograph shows a stand-in
photograph of its kind in the frame (`sections.md`, Product; `photos.md`); its product render
(`data-product`, place `stage`, on the page's flat ground, the lit ground on hardware) only when
the brief asks for 3D (`effects-objects.md`, Product renders).
**At 390.** The h2 at 30px; the frame square, facts under it as a list.
@@ -720,7 +722,7 @@ the map.
**At 1440.** The page's split: the text (h2, the `<address>` as street and district (no street in the brief: the district, `copy.md`), the hours as
`dl.hours` with `data-tz`, the phone and email only when the brief gives them, the main action and
Get directions); on the right the street map: `fx-map` baked from OpenStreetMap
Get directions (with a street from the brief; a district only: "See the area"; a town only: no map link)); on the right the street map: `fx-map` baked from OpenStreetMap
(`effects-creative.md`) only for a street the brief gives, the pin on the street line, still on
other kinds; otherwise a photograph of the shopfront.
@@ -740,7 +742,10 @@ books through a system, its link is the button instead.
and email only when the brief gives them); on the right the form on `--g-alt` (48px padding, never
over a photograph, texture or glass), beside or under a photograph of the room or counter on a
place (`sections.md`, Booking): dates, guests, name, email, at most six fields, the `.btn-row` with
the primary. `method="post"`, `action` the client's booking address. Text fields, not
the primary, the status line and the reply line: `starter.html` `booking-form`, run by system.js
(`system.md`, Forms). It posts to `data-endpoint`, the client's form address, when the brief gives
one; else its `action` is a `mailto:` on `.example`, a stand-in the report lists. Labels you can
see, each error under its field, the type and autocomplete each field needs. Text fields, not
`type="date"`, on a page not in English.
**At 390.** Text first, the form full width, one field a row.
@@ -748,16 +753,19 @@ the primary. `method="post"`, `action` the client's booking address. Text fields
**Copy.** Labels say what goes in ("Arrival", "Guests"); the button is the main action word for
word; a line says when the reply comes ("We reply within a day").
**Mistakes.** A form that sends nowhere; more than six fields; a fake success message; a modal.
**Mistakes.** A form that sends nowhere; more than six fields; a success message with no send
behind it; a placeholder as the only label; an error shown only in red or only at the top; a modal.
## client-slots
**Job and use.** The client's own trailer, key art, in-engine screenshots, CAD renders, exploded
model or motion video, when the brief says the client has them (`sections.md` section 1): each
gets a frame that reads finished now and takes the client's file later with one line changed. Tech
pages (software, gaming, hardware); a product page's CAD frame too. The stand-in is always from the
kit (a product-render still or scene of the client's own spec), never stock, a drawing or an empty
or grey frame; nothing on the page says it is a stand-in.
pages (software, gaming, hardware); a product page's CAD frame too. The stand-in is a photograph
kept from a live search (`photos.mjs search`, `photos.md`: the game's setting, the product's kind
in use), or a product-render still of the client's own spec when the brief asks for 3D; never mood
stock, a drawing or an empty or grey frame; nothing on the page says it is a stand-in, and the
report lists it.
**The comment.** Every slot opens with one comment the client's team can search for:
`<!-- CLIENT FILE trailer: assets/client/trailer.mp4 (1920x1080, H.264, the 60 s trailer) and
@@ -770,14 +778,15 @@ the wrap is about 700px tall at 1440x900, taller than one screen with its headin
screen leaves under the heading, 720px at most, `auto` under 1024px so a tablet and a phone keep 16:9) or a `max-height` on the
frame keeps the section one screen; the contained poster then sits letterboxed on the dark ground)
holding `<video class="frame__img" playsinline preload="none" poster="<the stand-in still>">`, the
poster a kit still that already reads as the film's first frame (a game: the world's widest view;
hardware: the use scene, the arm at the bench mid-move). The play control is real or absent, never
poster a stand-in photograph that already reads as the film's first frame (a game: the widest
photograph of its setting; hardware: the same kind of machine at work). The play control is real or absent, never
dead. With the file: `controls`, `preload="metadata"`, `<source src="assets/client/trailer.mp4"
type="video/mp4">`, the client's poster. Without it: no `<source>` and no `controls` (a missing
file or a dead control bar is a broken frame); the stand-in still is transparent, so the video
file or a dead control bar is a broken frame); a photograph poster covers the frame. A render
still (only when the brief asks for 3D) is transparent, so the video
also takes `.slot__poster` and page CSS `.frame > .frame__img.slot__poster { object-fit: contain }`
(the kit's `cover` crops the still to a fragment of the product), the frame the world's or the
page's dark ground, its stills made on that ground (`make-posters.mjs --grounds dark`: a light-ground still keeps a pale floor patch on dark); the play control is `<a
page's dark ground, its stills made on that ground (`make-posters.mjs --grounds dark`: a light-ground still keeps a pale floor patch on dark). The play control is `<a
class="ly-play" href="<the trailer's page the brief gives: the store page>"
aria-label="Watch the trailer">` with its `.ly-play__disc`: the `.frame` is a centring flex box, but system.css's rule `.frame > :not(canvas, .stage, .loop-pause, .frame__fx, .frame__img, [data-print], [data-scene], .print3d)` makes the
control `position: relative`, so `.ly-play`'s `left` and `bottom` (`--s-6` each) move the disc 32px right and up of dead centre, not
@@ -789,27 +798,30 @@ button (`motion.md`), the poster alone under reduced motion and on a phone; neve
**Screenshot strip.** The world shown as the game is played: three to five frames framed like
captures, 16:9, the same size (a `trio` or a row of four on the `.wrap`; a phone: one column), square
corners or the page radius, no device chrome, no shadow, no caption. With the files: each
`<img class="frame__img">` in `figure.frame.frame--photo`. Without them: each frame holds one job
scene of the world spec (`sections.md`, Gaming), `data-product-place="stage"`,
the frame itself the `[data-product]` element (`div.frame.slot`, page CSS `.frame.slot[data-product]
{ --ratio: 56.25%; background: <the world's water, fog or night colour> }`: `layout.css` clears a
`.frame[data-product]`'s ground, so a weaker selector leaves the render on white), the scene's props spread so it spans
the frame, a still, no two the same scene. Never `data-product-fill` over 1 (in testing the
frame drew empty). As the "what you do" cards it is three to five open cards, each a
verb over its capture.
`<img class="frame__img">` in `figure.frame.frame--photo`. Without them: each frame holds one
stand-in photograph of the game's setting from a live search (`sections.md`, Gaming; `photos.md`,
Live search), one grade across the strip, no two of one place or one series, cropped 16:9 with
`object-fit: cover`. When the brief asks for 3D, each frame instead holds one job scene of the
world spec, the frame itself the `[data-product]` element (`div.frame.slot`, page CSS
`.frame.slot[data-product] { --ratio: 56.25%; background: <the world's water, fog or night colour> }`:
`layout.css` clears a `.frame[data-product]`'s ground, so a weaker selector leaves the render on
white), a still, never `data-product-fill` over 1 (in testing the frame drew empty). As the "what
you do" cards it is three to five open cards, each a verb over its capture.
**CAD or render frame.** The client's CAD render of the whole product: the product section's
split frame (`frame--split`) or a `bleed`, the render on a light flat ground, square corners. Its
stand-in is the product-render view no other section uses (`front` or `top`), so it counts as one of
the object's three sections. **Exploded frame:** the parts section's picture, the client's exploded
model, else the spec's `exploded` scene (`sections.md`, Product, section 4).
stand-in is a stand-in photograph of the product's kind no other section uses (a render view only
when the brief asks for 3D), and it counts as one of the object's three sections. **Exploded
frame:** the parts section's picture, the client's exploded model, else close-up photographs of the
parts (`sections.md`, Product).
**Key art.** The hero's stage (the `product` stage, or the `film` hero's poster) and the world's
band: the stand-in render there, the comment naming `assets/client/key-art.jpg`.
**Key art.** The hero's picture (the `photo` hero's photograph, the `film` hero's poster, or the
`product` stage) and the world's band: the stand-in photograph of the setting there (a render only
when the brief asks for 3D), the comment naming `assets/client/key-art.jpg`.
**Mistakes.** A slot the brief names and the page leaves out; an empty, grey or black frame; a
play button that does nothing; a `<source>` pointing at a file that is not there; a caption saying
the file is coming; the hero object as the stand-in in every slot.
the file is coming; one stand-in photograph in every slot.
# Price and questions
@@ -976,7 +988,8 @@ and the hours, so a visitor needs nothing else.
**At 1440.** `footer.closing.footer--visit` as `starter.html` (`links-visit`): on `span-7`
`address.footer__address` (street and district on two lines, the district alone when the brief names no street, 32/40 on a place, 48px elsewhere, in the
display face), one line on the way there (the stop, minutes on foot) and `a.link` "Get directions"
(the OpenStreetMap search, `copy.md`); on `span-4 start-9` "Opening hours" over `dl.hours` with
(the OpenStreetMap search, `copy.md`; "See the area" with a district only, none with a town only); on
`span-4 start-9` "Opening hours" over `dl.hours` with
`data-tz` (system.js marks today). Then the nav row (the logo, the nav's words, Contact), the legal
row with the credits line last; then on a place the name set wide across the `.wrap` in the display
face at 400, except on `colour-band`. No button.
@@ -1031,17 +1044,20 @@ height 65 to 130 cm, delivery in 3 weeks), line-up (three sizes from €1,290),
oak top: a photograph of the grain), photo-band ("Built to order in Porto"), split-text-left again
(the handset with its presets: a photograph, since a product never shows UI it does not have),
specs (`rows--spec` beside a photograph of the frame), proof (a review the brief gives, merged),
faq (four questions), footer.closing. Flat grounds, no effect, no moment.
faq (four questions), footer.closing. Flat grounds, no effect; the hero's entrance and one dealt moment in its own section.
**With no photographs yet** (*Pellum, a desk lamp*): the lamp is rendered, never hidden. The hero
(`deal.mjs --type product --has object`: `product` or `split`) holds its product render whole on
the stage, switched on; product-statement shows the dial close up with three facts (parts);
what it does as a split (the desk scene: the warm pool of light on the books and the mug across
the frame, the lamp cut by its edge; use); how it is used as three steps; specs as `rows--spec` with the real icons of the component brands the brief names;
ordering as a live order card (finish swatches that recolour the render, delivery, the price):
the lamp whole twice, never in every section;
faq; footer.closing. Flat grounds, no effect, no drawing, no line about missing photographs; the
report lists the shots to take.
**With no photographs yet** (*Pellum, a desk lamp*): the lamp is shown in photographs, never
hidden and never a render by default. `photos.mjs search "desk lamp"` and `"desk lamp detail"`
give the stand-ins: a desk lamp of the same build whole, its dial or joint close up, a lamp lit on
a desk at dusk, no maker's name readable, one grade. The hero (`deal.mjs --type product --has
photo`) holds the lamp whole; product-statement shows the dial close up with three facts (parts);
what it does as a split (the warm pool of light on a desk, the lamp cut by the frame's edge; use);
how it is used as three steps; specs as `rows--spec` with the real icons of the component brands
the brief names; ordering as a live order card (the finishes as named swatches, delivery, the
price): the lamp whole twice, never in every section; faq; footer.closing. Flat grounds, no
effect, no drawing, no line about missing photographs; the report lists every stand-in and the
shots to take. Only a brief that asks for 3D (a configurator, "show it turning") gets its product
render (`effects-objects.md`, Product renders) on the `product` stage instead.
## Gaming
@@ -1060,25 +1076,20 @@ h2 on its calm sky), platforms and editions (the real Steam, PlayStation and Epi
type, never a drawn mark; prices from), faq, footer.closing. No UI fragment, dashboard or widget
anywhere.
**With no art files** (named but not supplied, or none): the key art is rendered, never an effect
in its place. Two product-render specs in the game's palette (`effects-objects.md`, Product
renders): the salvage sloop (hull and deck as rounded boxes and panels, mast and boom as tubes,
rigging as paths, a furled sail as a tube, two `light` lanterns; about 40 parts) and the world
scene (the drowned pier and the sunken town's roofs: posts as cylinders, planks as beams, crates
as boxes, a crane's rope as a path, the sloop small at the pier, under 90 parts; one scene per
job: a hull cut open under a `light` lantern, a crate swung up on the crane, the harbour rebuilt). Dealt `deal.mjs --type software --kind gaming --has
object`: hero `product`, the effect `aurora-curtains` dealt for the hero slot (dark, read as a
dusk sky over water) as the stage's ground behind the turning sloop (`data-product-mode="live"`), h1 "Kelpmoor, a co-op sailing
game", "Coming to Steam in spring." and "Add to Steam wishlist" in the head row. Then the
trailer frame (client-slots: the pier scene's widest still as the poster, the play control to
the trailer on the store page the brief gives); the world: the pier scene from above (its `top`
view, the map of the coast), full bleed on the dark band, the h2 above it; what you do: the
screenshot strip as three open cards, each a verb over one job scene of the world, 16:9, filled
to the edges on the world's water (client-slots); a section holding the dealt moment; platforms
with the real store icons and the sloop's second whole view; faq; footer.closing. The sloop whole
twice; the world carries the rest. One render turns, the rest are stills; nothing says "render";
each slot names the client's file in its comment. The
report names the art to replace.
**With no art files** (named but not supplied, or none): the hero is type over a photograph of the
setting, never an effect or a random object in its place. `photos.mjs search "flooded village"`,
`"harbour at dusk"`, `"fishing boat at night"` give the stand-ins, kept at full size: one grade,
nothing readable, no faces. Dealt `deal.mjs --type software --kind gaming --has setting`: hero
`photo`, the widest calm frame (water at dusk under a dark sky) full bleed, h1 "Kelpmoor, a co-op
sailing game" set large in the box, "Coming to Steam in spring." and "Add to Steam wishlist" under
it. Then the trailer frame (client-slots: the harbour frame as the poster, the play control to the
trailer on the store page the brief gives); the world: a second frame full bleed on the dark band,
the h2 above it; what you do: the screenshot strip as three open cards, each a verb over one
stand-in frame, 16:9 (client-slots); a section holding the dealt moment; platforms with the real
store icons; faq; footer.closing. The one effect, when dealt, stands in a section of its own,
never on a photograph. Nothing says "stand-in"; each slot names the client's file in its comment.
The report names every stand-in and the art to replace it. A rendered sloop and pier
(`effects-objects.md`, Product renders) only when the brief asks for 3D.
## Place
@@ -1094,7 +1105,7 @@ two open groups beside `tall-small`: a class at the wheel tall, a glazed bowl sm
its class), the place (`trio`: the workshop, a shelf of drying pots, a hand trimming a foot ring,
tops on one line), booking (a photograph of the class room beside the form), footer.closing
(`footer-visit`, the name capped at 12vw, the one credits line, `data-tz="Europe/Berlin"`).
Fourteen photographs, all the studio's own; no effect, no moment; every linked photograph zooms
Fourteen photographs, all the studio's own; no effect; the hero's entrance and one dealt moment; every linked photograph zooms
1.03 under the pointer.
## Service
+124 -136
View File
@@ -13,35 +13,35 @@ section 2) and, on tech, the section that holds the effect, the scene or a momen
- **Every section shows the real thing:** a photograph, a portrait, real icons, the client's UI, a
diagram card or (tech only) the effect. A section without a picture is allowed only for a price
list, an FAQ, a specs table, a booking form or a dealt statement moment's own section, once each,
and two picture-less sections are never neighbours (the short page's statement hero and its one
section are the one exception, section 4). A quote needs a face or merges into the section above. The picture takes a real share:
and two picture-less sections are never neighbours (the short page with no photograph, its
statement hero, its statement moment and its one section, is the one exception, section 4). A quote needs a face or merges into the section above. The picture takes a real share:
half a tile or more, 50/50 in a split, a whole screen as a layer. The close is its own picture (a
photograph, the big button or the name set wide).
- **One object never carries the page** (in testing: a robot arm in six sections and a game's
salvage rig in six, each "one object six times", both capped at 4 to 4.5). The object shown whole
(the product, its photograph or render, a game's hero object) leads the hero and **at most two
(the product's photograph, a game's key art) leads the hero and **at most two
more sections**; every other picture section is carried by another kind of visual the brief
supports, from its row below, no kind more than twice. A new angle, a recolour, a crop of the
whole, or the object on its floor with one prop beside it, is the object again; a use scene counts
as use only when its setting fills the frame (a `desk` as the bench across the frame, trays,
parcels and sheets on it, half the picture or more; the object one thing among them). The check counts a declared scene as use, so whether one prop makes it the object again is the builder's call. Write each plan line's kind; three object
lines at most.
supports, from its row below, no kind more than twice. A new angle, a recolour or a crop of the
whole is the object again; a use photograph counts as use only when its setting fills the frame
(the bench, the street, the room, half the picture or more; the object one thing in it). Write
each plan line's kind; three object lines at most.
| Kind | the object whole: hero + 2 | carries the other sections |
|---|---|---|
| Software | the `app-frame` | UI fragments of its subject on cards, a diagram card, real tool icons, a type moment, a named quote with its face, the effect or scene |
| Gaming | the hero object (ship, rig, mech) or the client's key art | the world (a scene of the world's own objects, the hero object small or absent; its `top` view as a map), the crew at work (the client's screenshots, else one scene a job), the trailer frame, the screenshot strip, a type moment, the real platform marks |
| Tech hardware | the product (photograph, CAD render or its render) | its use (the arm at a bench among trays and parts, the device in its place), an exploded or parts view, the motion video frame, a diagram card held still, a type moment (one figure set large), a context photograph of the trade (a workshop, a lab) |
| Product | the product (photograph or render) | its use (loaded, carried, at home), people with it, close-ups of its parts, a diagram card held still, a type moment, a context photograph of where it lives |
| Gaming | the client's key art (none: the setting's photograph under the game's name) | the world (in-engine shots, else photographs of its setting), the crew at work (the client's screenshots), the trailer frame, the screenshot strip, a type moment, the real platform marks |
| Tech hardware | the product (photograph, the client's CAD render; a render only when the brief asks for 3D) | its use (the device at a bench or in its place), parts close up, the motion video frame, a diagram card held still, a type moment (one figure set large), a context photograph of the trade (a workshop, a lab) |
| Product | the product (photograph; a render only when the brief asks for 3D) | its use (loaded, carried, at home), people with it, close-ups of its parts, a diagram card held still, a type moment, a context photograph of where it lives |
- **The client's own assets get designed slots.** When the brief says the client has a trailer,
key art, in-engine screenshots, CAD renders, an exploded model or a motion video, each gets a slot
that reads finished now: the frame at the file's final ratio, holding a stand-in from the kit
(a still of the world or the product in use), named in a comment for the client's file, never an
that reads finished now: the frame at the file's final ratio, holding a stand-in photograph (a
kept live-search frame of the game's setting or of the product's kind in use, `photos.md`; a kit
render still only when the brief asks for 3D), named in a comment for the client's file, never an
empty, grey or broken frame and never a line saying the file is missing. The slots
(`sections-library.md`, client-slots): the **trailer or motion video frame** (a real `<video>`
with a kit poster, and a play control only when there is a page or file to play), the **screenshot strip** (three to five frames of the
world composed from the kit, framed like captures), the **CAD or render frame**, the **exploded
with a stand-in poster, and a play control only when there is a page or file to play), the **screenshot strip** (three to five
stand-in photographs of the setting, framed like captures), the **CAD or render frame**, the **exploded
frame**; key art fills the hero's stage. The report lists every slot, its file and its stand-in.
- **One idea per section,** one h2, one action at most besides the main one (a pair only in the
hero, or where a product needs "compare" beside "buy"). A two-screen section holds one list, table
@@ -90,7 +90,7 @@ comment at the top of `<body>`, and build exactly those. Deal one part again onl
again from its own list without them, never drawn: `nav-cards` (and any nav panel of photograph
cards) to `nav-split` with no panel, or `nav-centre`; `close-photo` to the head row of `close-word`
without the name (the h2 and one line on the left, the main button on the right, both on the
`.wrap` lines); `media-band` to `space` (a game without art keeps it: its world render is the layer); a place's hero to the `statement` hero on `.ground-brand`.
`.wrap` lines); `media-band` to `space` (a game without art keeps it: a photograph of its setting is the layer); a place's hero to the `statement` hero on `.ground-brand`.
Write the change and its reason on plan line 2.
- **Nav** (one fixed, opaque bar on the `.wrap`; `system.md`): `nav-mega` (link columns and one
@@ -142,7 +142,7 @@ the main action (Add to Steam wishlist, Pre-order). In testing, a game page that
and showed an abstract effect for key art lost 3.5 to 5.5 to one with a launch date, a scene of the
game's world, feature cards and real platform icons. So:
- **The hero names the game:** the h1 is its name and genre ("Northline, a co-op survival game",
40 characters at most on the `product` stage), or its wordmark (the client's logo file) over an
40 characters at most on the `product` stage, 32 on the `photo` hero), or its wordmark (the client's logo file) over an
h1 naming the genre. The lead or a line under it carries the launch date or status the brief
gives ("Out 12 March on Steam", "Early access now"); with no date, the status ("Coming to PC"),
never an invented date.
@@ -154,29 +154,19 @@ game's world, feature cards and real platform icons. So:
the trailer frame and the screenshot strip carry the rest. Art the brief names gets its slot
either way (section 1, client slots): a trailer frame, the screenshot strip, key art on the
stage.
- **No art files** (the brief names art it does not supply, or none): the key art is **rendered**,
never replaced by an effect. Write a product-render spec (`effects-objects.md`, Product renders)
of the game's hero object (its ship, crane barge, mech, car) or a scene of its world composed from
its own objects on one floor (the barge at the quay with its crates, lanterns and cables; under 90
parts), in the game's palette, `light` material for its fires, lamps and screens; make its
posters. Write two: the hero object and the world (its quay, wrecks with hulls cut open, cranes,
crates, lanterns, the hero object small in it or left out), the world with one scene per job
(`"scenes"`: the torch's `light` in a cut hull, the winch over a crate stack, the helm lanterns
by night). Deal `deal.mjs --type software --kind gaming --has object` (the `product` stage): the
render whole on the stage, turning slowly (`data-product-mode="live"`: the page's one live render;
every other frame a still). The one effect (`effects.mjs pick --page gaming
--palette dark --slots hero --exclude ruled-grounds,grid-pulse,contour-lines,dot-terrain,dotted-globe,ridgeline,bayer-dither,halftone-screen,ascii-shade,line-engraving,duotone-grade`,
again until it reads as water, fog, embers or light, or the product hero's `spotlight-cone`,
`edge-glow`) may sit behind it as the stage's ground, the
render drawn over it (every placement is transparent over its frame's ground), never instead of it. The world
section: the world spec's widest view (or its `top` view as the map), `data-product-place="stage"` in a
full-bleed `media-band` or `dark-band` frame, the h2 above the render, never on it. The feature
cards are the screenshot strip: each card one job scene of the world spec, 16:9, its props
spread so the scene spans the frame, on the world's own ground, never the hero object alone
again (a `data-product-fill` over 1 drew an empty frame in testing: widen the scene instead). The trailer frame's poster is the world's widest still. One
render turns; the rest are stills. The helper cannot model characters, faces, creatures, cloth
or glass: compose the world's machines, buildings and props, never a character. The report
names the art to replace.
- **No art files** (the brief names art it does not supply, or none): the hero is **type-led over
a photograph of the game's setting**, never an effect or a random object in its place. Search the
place its world is set in, in plain words (`photos.mjs search "flooded coastal town at dusk"`, a
harbour at night, a desert mining town, a city in rain), keep three to five frames of one light
and grade with nothing readable on them, and deal `deal.mjs --type software --kind gaming --has
setting`: it prints the `photo` hero, the game's name as the h1 set large in the box (or straight
on a dark, empty part of the frame, `data-box="none"`), the status line under it. The world
section takes the widest of the frames full bleed in a `media-band` or `dark-band`, the h2 above
it; the trailer frame's poster and the screenshot strip are client slots holding the other
frames (section 1, client slots), each named in a comment for the client's file. One shader
effect may sit behind the words in a section of its own, never on the photographs. Every frame
is a stand-in: the report lists each one and the art to replace it. A rendered hero object or
world (`effects-objects.md`, Product renders) only when the brief asks for 3D or a render.
- **Platforms:** the real Steam, PlayStation and Epic Games marks (`icons.mjs apps --tag gaming`);
Xbox and Nintendo named in type, never a drawn mark.
Nav `nav-mega|nav-centre|nav-cards` · footer `footer-dark|footer-statement` · close
@@ -193,8 +183,10 @@ comparison-table), proof (a review or press line the brief gives), [visit-map fo
product's world, two or three photographs a screen (one per feature section of the part it names;
a grid of 4 to 8 cut-outs on one backdrop): the product whole in three at most (section 1, One
object), its use, its parts and the people with it in the rest. Prices are "from" prices on cards or in a table, never a plan
table or a monthly switch. A technical product (site-kind tech) may take its render or the effect;
any other product takes no effect, and its render only when it has no photographs (below).
table or a monthly switch; only prices the brief gives (none given: "Price on request" or no price, `copy.md`, What may
be assumed). Photographs first: the client's, else stand-ins (below). A technical
product (site-kind tech) may take the effect; any other product takes no effect; a render on
either only when the brief asks for 3D or a render (below).
- **The product is the hero, not every frame.** The bar is Figure and 1X: where the product is
shown whole it fills the frame at large scale, and its use, parts and place carry the rest. In testing, a rendered product page scored 5.5,
creativity 4.5 ("a safe grey template"), because every visual was a mid-size product in a grey
@@ -224,7 +216,7 @@ any other product takes no effect, and its render only when it has no photograph
the promise.
4. **Dealt compositions, one contrasting ground.** Each picture section deals its composition
from section 4's five (`bleed`, `pair`, `trio`, `strip`, `tall-small`), plus `split` (text
beside one big picture, once a page: on the full product page below, section 2) and the order
beside one big picture, once a page, never right after a `bleed`) and the order
section (the order card beside the product, last, a form, never counted as the split);
neighbours never the same, no structure (heading,
panel, picture) twice. For the product: `bleed` the product at anchor scale across the
@@ -246,70 +238,39 @@ any other product takes no effect, and its render only when it has no photograph
7. **Buttons step on hover and press on click.** The motion layer does it (`motion.md`,
Button); no page CSS sets a `.btn`'s `transform` or `transition`. A feature card that leads
somewhere is a linked card (it lifts 2px, its picture zooms 1.03).
- **No photographs: the product is shown, never hidden** (in testing: a cargo bike's page that never
showed the bike scored 3 of 10). A picture the brief gives (a render, a CAD view, a cut-out) goes
on the hero's stage. Else the product is **rendered**: one crafted studio render of this product,
composed from its parts with the objects kit's product-render helper (`assets/objects/
product-render`; `effects-objects.md`, Product renders): write a short JSON spec of its frame,
wheels, box, arm or shade, sized from the brief's figures, in its own materials and colours (the
kit's `specs/` show the format; never ship a kit spec as the client's product), make its posters
with `make-posters.mjs`, and mount it as `<div class="frame" id="<name>-<view>" data-product="assets/objects/product-render/specs/<name>.json"
data-product-place="stage" data-product-view="<view>">` with `product-render/auto.js`. A place
is only the share of its frame the product's bounds fill, centred in it (`build.js`): `stage`
0.86, `hero` 0.88, `tile` 0.84, `card` 0.9; the page's CSS gives the frame its size and the
words always stand outside the frame. In the hero the frame goes straight inside the `product`
hero's `.hero__stage` (`layout.css` 3b sizes it); `stage` in a `split` frame or a full-width
one, `tile` in a square or portrait tile. The render is the product's picture, not an effect:
**stills by default, live only when asked (`data-product-mode="live"`), at most one live
context**, on the page's flat ground, no print, shader ground, moment or scene beside it; the same
on a product of site-kind other and on hardware. The pictures of rule 2 come from the one spec
(`effects-objects.md`, Views): the views its `supports` lists of `three-quarter`, `side`,
`front`, `rear`, `top`, `low`, views of its own (`views`) and `detail:<part>` (a close-up), the
spec's `scenes` (loaded or in use, from the props `crate`, `bag`, `child-seat`, `bench`,
`parcel`, `desk`, `book`, `mug`, `sheet`, `tray`) and its `colors`; `make-posters.mjs` prints
every `make.variants` entry as a still per colour. `deal.mjs --type product --has object`
(hardware adds `--kind tech`) always prints the `product` hero: its full-width stage, never a
`split` column. Then the **full product page**, never the short page, one picture a section,
the product whole in sections 1, 2 and 6 only (put every view and scene in `make.variants`):
1. hero, view **`side`** (the widest view; a tall product, a lamp or an arm: `three-quarter`)
whole on the `product` stage, the h1 naming the product and its buyer, the order action;
2. the product, view **`three-quarter`** (a tall product: `side`), the page's one `split`:
the frame on 8 columns, the three selling figures on the other 4 (rule 6; `bleed` is never
right after the hero);
3. what it does or carries, its **use**: a scene (`three-quarter+loaded`, `work`) whose
setting fills the frame (a `desk` as the bench across it, `tray`s, `parcel`s, `sheet`s,
crates; the kit's arm with one tray reads as the arm again), the headline's promise
readable (rule 3): `bleed` across the wrap with the uses in a row of three under it, or
`tall-small` with a close-up; a motion video the brief names: its frame here, the poster this
scene (client slots);
4. the **parts**, views **`detail:<part>`**: a `strip` or `trio` of close-ups, one per part the
section names (the box, the motor, the gripper, the stand), the part filling its frame; or an
exploded view (a scene `exploded` that hides the assembled parts and adds them again as
plain `parts` spaced along their axis, shot as `three-quarter+exploded`: a close-up view
frames only the spec's own parts, so the spaced ones fall out of it; an exploded model the
brief names: its slot); or how
it is used: three steps or a lanes diagram card held still (charge, ride, park);
5. specs as `rows--spec` (only the facts the strip does not hold) beside a diagram card held
still or one figure set large (a type moment), and the real colour icons of the component
brands the brief names; this or section 2 is the contrasting ground;
6. ordering, view **`rear-quarter`**, a view of the spec's own (`"views": { "rear-quarter":
{ "from": "three-quarter", "angle": <the spec's angle + 180> } }`, listed in `supports`): a
live order card (model or size, colour as swatches, delivery slot or town, the price that
updates, the main action) beside the product whole in the chosen colour, which the swatch
changes (rule 5); the order composition, not the split; [faq, five questions at most];
footer.closing.
Every view is a still. The heading and the text
say what the thing is; no line calls it a render, model or illustration (house rule 1). The report lists the
photographs to shoot (`photos.md`). Never a drawn SVG, a model of loose primitives the helper did
not compose, mood stock, a photograph of another maker's product or an object of another kind (a
wheel is not a bicycle). **Hardware without photographs or renders goes here too** (in testing: a
robot arm's page with a silk shader and an order form lost; CAD renders or a film the brief names
but does not supply count as missing, and each gets its slot: the CAD frame is section 2's
picture, the motion video section 3's, the exploded model section 4's): a robot arm follows the kit's worked
`robot-arm` spec, rebuilt to the client's own sizes, joints and colours; a drone is a rounded box body, beam arms, cylinder motors and disc rotors; a camera a
rounded box and a cylinder lens; a curved shell is rounded boxes and fillets (`round`, `blend`,
`seams`) at its real measurements. Never a `split` hero holding only an order form or the
effect in place of the product; the order card is its own section (6).
- **No photographs: the product is shown, never hidden, in photographs** (in testing: a cargo
bike's page that never showed the bike scored 3 of 10, and pages carried by renders and their
props read as random 3D objects). A picture the brief gives (a cut-out, a CAD view, its own
render) goes on the hero's stage. Else **stand-ins**: `photos.mjs pick --product-without-photos`
prints the search, and `photos.mjs search "<the thing in plain words>"` (a cargo bicycle, a desk
lamp, a robot arm), then its parts and the places it is used, finds licensed photographs. Keep a
frame only when it shows the same kind of thing whole or in use (a cargo bike for a cargo bike,
never a wheel for a bicycle), with no maker's name, logo or badge readable (crop it out or drop
the frame), no face passed off as a customer, and one light and grade across the set. Deal the
hero with `deal.mjs --type product --has photo` (hardware adds `--kind tech`). The page is the
**full product page**, never the short page: the plan gives each section one picture of its own
kind (the product whole, its use, parts close up, people with it, where it lives), the product
whole in three sections at most (rule 2), the hero first and the order card last, no other fixed
order. A colour the stand-ins cannot show stays a named swatch in the order card and goes on the
report's shot list (rule 5 waits for the client's photographs). Every frame is a stand-in: the
report lists each one with the photograph to shoot in its place (`photos.md`); nothing on the page
says so. **Hardware without photographs goes here too** (in testing: a robot arm's page with a
silk shader and an order form lost): stand-ins of the same kind of machine at work; CAD renders,
an exploded model or a film the brief names but does not supply each get their slot (section 1,
client slots). Never a `split` hero holding only an order form or the effect in place of the
product; the order card is its own section, last.
- **A render only when the brief asks for 3D or a render** (a configurator, "show it in 3D", a
product that exists only as a model): one crafted studio render of this product with the objects
kit's product-render helper (`effects-objects.md`, Product renders): a short JSON spec of its own
parts, sized from the brief's figures, in its own materials and colours (never a kit spec shipped
as the client's), its posters made with `make-posters.mjs`, mounted on the `product` stage
(`deal.mjs --type product --has object`), each section a different view (`effects-objects.md`,
Views). Stills by default, live only when asked, at most one live context, on the page's flat
ground; props only where a headline's promise needs them (the four crates of "takes four
crates"), never a set added for mood. The heading and the text say what the thing is; no line
calls it a render, model or illustration (house rule 1). Never a drawn SVG, a model of loose
primitives the helper did not compose, mood stock, a photograph of another maker's product
passed off as the client's, or an object of another kind.
Nav `nav-cards|nav-split|nav-mega` · footer `footer-columns|footer-dark` · close
`close-photo|close-type|close-word` · rhythm `dark-band|brand-band` (one contrasting ground,
rule 4) · buttons `arrow|solid|block|pill`.
@@ -319,7 +280,7 @@ product whole in more than three sections; one
view twice; a promise not shown; a colour offered and never shown; a fact twice.
**Service** (clinics, firms, agencies, studios, trades). nav, hero, services-list (each with one line
and a price from; a peek-list when each service has its own photograph), work (a grid of 3 to 6
and a price from when the brief gives one; a peek-list when each service has its own photograph), work (a grid of 3 to 6
project photographs, or photo-pair, one per case), steps, people-row, proof (a quote the brief
gives), [pricing-two or a `rows` price list], [faq], booking-form with the address, footer.closing.
Pictures: photographs of the work, the team at work and the rooms, one or two a screen; real icons
@@ -340,8 +301,9 @@ hairlines and one button style. Where a recipe and this section differ on a plac
wins. Open these references first for the feel (never copy one): Daunt Books, Prufrock, Bourke
Street Bakery, Gail's, Tartine, Bonanza (a still where Bonanza has film).
**Never on a place:** an effect, WebGL, canvas, CSS gradient, grain, glass, print, scene, moment,
loop, count, UI fragment or diagram; feature or plan cards, tabs, accordions, switches, logo rows,
**Never on a place:** an effect, WebGL, canvas, CSS gradient, grain, glass, print, scene, a moment
other than the one (two on a long page) `moments.mjs pick --page place` deals into its own section,
a loop, count, UI fragment or diagram; feature or plan cards, tabs, accordions, switches, logo rows,
timelines, fact strips (address or hours as labels), a calendar card, a map of a street the brief
does not give; a rating, review, figure or quote the brief does not give; words on a photograph, a
rounded or filtered photograph, a see-through or blurred nav; outline, pale, blurred or rotated
@@ -372,9 +334,11 @@ The nav is not a screen; `footer.closing` is one; a 2-screen section counts two.
guesthouse shown as a type band scored 3.5). The `statement` hero on `.ground-brand` only when
the library holds no frame of the region and no lead. Every frame is listed in the report as a
stand-in to replace; nothing on the page says so. **The count decides, not the trade:** after
the words are repeated until `pick` answers "no new motif left", under 8 frames in all (the
client's own included) is the short page, whatever the trade (a bakery holds 4, a florist 3,
pottery or a restaurant none: examples, not a list); 8 to 12 is the compact page, 12 or more the full page.
the words are repeated until `pick` answers "no new motif left" and a live search
(`photos.mjs search --trade <trade>`, `photos.md`) has added the frames it finds that pass the
full-size look, under 8 frames in all (the client's own included) is the short page, whatever
the trade (the library holds a bakery 4, a florist 3, pottery or a restaurant none: examples,
not a list); 8 to 12 is the compact page, 12 or more the full page.
These three names mean only these lengths everywhere in the skill.
- **Photographs by section,** all distinct: hero 1; visit 1 (0 with a map); menu or prices 1; rooms
@@ -437,7 +401,8 @@ to 4.5 because every section was text beside one picture and nothing answered th
(`motion.md`). Each section after the hero holds at least one such link.
- **One main action a screen:** a transaction (Book a room, Order a cake) is the nav's button and
the hero's, as on any page, and nowhere else but the booking or close and the phone bar. Get
directions is never repeated: the hero's one button and the phone bar's; the nav says "Visit"
directions is never repeated: the hero's one button and the phone bar's (and the nav's button on a desktop: two on a
screen at most, `action-repeat`); the nav says "Visit"
as a word to `#visit`, the visit block adds no second button for it, and the footer gives the
address linked to the map, never a button (in testing: Get directions in the bar, the hero and the
footer read as clutter).
@@ -457,15 +422,19 @@ to 4.5 because every section was text beside one picture and nothing answered th
room from the door, 6/6, the panel left. With the `address` type moment the h2 and the street
set large run across the `.wrap` above the split (the photograph then 4:3) and the panel opens
with the open-now line; else the panel's top line is the h2 "Visit", the open-now line
(`[data-open-now]`) at its right, never a 13px eyebrow. 16px below, two columns 24 apart: the address on two lines at
(`[data-open-now]`) at its right, never a 13px eyebrow; the open-now line only with hours the brief gives (assumed
hours carry `data-assumed` and no such line, `copy.md`). 16px below, two columns 24 apart: the address on two lines at
20/30, linked to the map search, the phone as a `tel:` link only when the brief gives one, then
one secondary button: "Get directions" when the main action is something else, "Call" only when
one secondary button: "Get directions" when the main action is something else and the brief gives the street ("See
the area" with a district only; no map link with only a town), "Call" only when
the brief gives a phone, else none; and
the week's hours (days left, times right in tabular figures, one line per day or range, one size;
a holiday change as one line above). Under both, at most two 44px rows: access, a florist's
delivery (area, price, cut-off), a guesthouse's check-in and check-out, a bike shop's repair
turnaround, or "By appointment" for a studio. At 390 the panel first (24px padding, address
18/28), then the photograph at 342. Hours, address and directions are never only in the footer.
18/28), then the photograph at 342, which grows from its ratio to 4:5 at most into what the
panel leaves of the screen (system.css), so the screen ends in the photograph, not an empty band:
keep it a `.pl-photo`, the last child of the split. Hours, address and directions are never only in the footer.
- **Map** only when the brief gives the street: `fx-map` baked still (`effects-creative.md`) in the
photograph's frame, the pin on the street line; never an embed, a contour or a dot pattern.
- **The offer** opens with a head row: the h2 on the left line, one line, 40 characters at most
@@ -522,7 +491,9 @@ to 4.5 because every section was text beside one picture and nothing answered th
`footer-columns` (Visit, Contact as given, the nav's words, delivery or check-in or the
newsletter; the legal row).
- **Phone bar** (`nav.actionbar`, required): the order or booking as one full-width button; without
either, Get directions (primary) and Call (secondary, only with a phone the brief gives).
either, Get directions (primary; "See the area" when the brief gives no street) and Call (secondary, only with a
phone the brief gives). system.js keeps it off the phone's first screen until the hero's button has scrolled away,
and away from any screen that already shows one of its actions (`system.md`, Nav; `copy.md` rule 5).
- **Notice line** (a florist's delivery cut-off, changed or holiday hours): one still 14px line with
a number above the h1 (`layout-v4.md`), repeated where the fact belongs; never rotating.
- **Splits** 6/6, text left, top-aligned, never mirrored; on a phone the text first.
@@ -549,26 +520,37 @@ Each starts hero, visit; the rest in this order, 4 to 6 screens:
### Look
- **Grounds:** the page ground from the place's walls, paper or shopfront: warm off-white by default
(`#F7F3EC`; white for a shop that sells mostly online), the surface one step darker (`#EDE6DA`),
ink `#2A2622`, muted `#665E55` (5.8:1 on the ground), hairline `#DAD3C8`; then
`start.mjs brand --bg "#F7F3EC" --surface "#EDE6DA" --ink "#2A2622" --muted "#665E55" --accent "#<hex>" --type <pairing>` (colours quoted: a bare `#` starts a shell comment).
One full surface band at most; one brand band (`backgrounds.md`); no dark section except that
band (the nav bar may take the dark ink). Flat colour only (`backgrounds.md`).
- **Accent:** one, from the place (the shopfront paint, the tiles, a bag label) on the primary
button, the focus ring and the `colour-band` close; darken it in steps until its label passes
4.5:1 and it passes 3:1 on both grounds.
- **Type:** a pairing from `start.mjs deal --page place`, display 400, text 400 and 500: a serif head
with a plain sans for a bakery, roaster, guesthouse or bookshop; one plain sans in two weights for
a shop, bike shop, florist or studio; a soft sans for a cafe. Sizes in `layout-v4.md` section 5.
- **Palette, dealt by trade:** `start.mjs deal --page place --trade <trade>` deals three palettes
made for the trade (`library/palettes.json`, `trades`: a cafe, a bakery, a guesthouse, a yoga
studio and a bike shop each have their own), away from the last builds, so two cafes never share
a ground and an accent. Only grounds that fit a place come (light, low saturation: the `--g-main`
rule in `backgrounds.md`). Take the first, then `start.mjs css --page place --palette <id> --type
<pairing>`; the next only when the first clashes with the client's own logo or photographs.
Plan line 2 names it with one sentence on why it fits the trade, from what the palette answers
in it ("flour-terracotta: a flour-white ground and the terracotta of a bread oven's tiles"),
never a fact the brief does not give. The client's own colours (a painted shopfront, a logo, a
bag label the brief gives) replace the deal: `start.mjs brand --bg "#.." --ink "#.." --accent
"#.." --type <pairing>`, the ground from its walls, paper or shopfront (colours quoted: a bare `#`
starts a shell comment).
- **Grounds:** the palette's ground and surface, one full surface band at most; one brand band
(`backgrounds.md`); no dark section except that band (the nav bar may take the dark ink). Flat
colour only (`backgrounds.md`).
- **Accent:** the palette's one accent on the primary button, the focus ring and the `colour-band`
close, as `start.mjs` prints it (`--p-action` and its label). A client's own colour through
`brand`: darken it in steps until its label passes 4.5:1 and it passes 3:1 on both grounds.
- **Type:** the first pairing `start.mjs deal --page place --trade <trade>` prints (it puts the
trade's group first), display 400, text 400 and 500: a serif head with a plain sans for a bakery,
restaurant or guesthouse; one plain sans in two weights for a shop, bike shop, florist or studio;
a soft sans for a cafe or a yoga studio. Sizes in `layout-v4.md` section 5.
- **Motion:** the hover layer only (`motion.md`), and it must show: the nav words and links draw
their underline, a linked photograph zooms 1.03 inside its frame while its card lifts 2px,
buttons step and press, arrows move 3px. Nothing moves by itself, on load or on scroll.
buttons step and press, arrows move 3px. On load the hero enters once (`motion.md`); on scroll only
the dealt moment's own section moves (`moments.md`); nothing else moves by itself.
### The short page (under 8 photographs)
Under 8 usable, whatever the trade (the count in Plan and length): three screens, 2,700px at most at 1440 (the tablet
runs longer, below): hero, one `--h: 1` section holding the
runs longer, below; four with no photograph, Its one moment below): hero, one `--h: 1` section holding the
visit and the offer, footer.closing. With one or two good photographs, the best is the hero's and
the other is the section's, in one row with the groups (With its photograph, below). With none, the hero is a statement on `.ground-brand` (`deal.mjs --hero statement`: the
h1, the sub-line, the inverted button) and the section has no picture on purpose (the one
@@ -597,7 +579,14 @@ exception to "two picture-less sections are never neighbours").
- **Re-dealt without pictures** (section 2): `nav-split` with no panel, the close as the head row
of `close-word` without the name. Either the brand band (the close on `colour-band`) or the name
set wide in the footer, not both.
- **What the deal drops.** The page has no place section, no second offer and no room for a moment:
- **Its one moment** (the gate holds `moment-none` on every page). With a photograph in the section,
it is `picture-develops` on that photograph: `data-moment="picture-develops"` on its
`<figure>` (the moment wants half the wrap or more: two groups and the `span-6` photograph), dealt with `moments.mjs pick --page place --count 1 --fits section --photo`, and dealt
again with `--exclude photo-chapters` when it gives `photo-chapters` (three or four screens of its
own, past the short page). With no photograph, add one statement section of one screen
(`--h: 1`) after the hero for `word-highlight` (`--fits statement`): one statement of 12 to 40
words from the brief, so the page is four screens, 3,600px at 1440.
- **What the deal drops.** The page has no place section and no second offer:
of the printed deal build the nav, footer, rhythm (`space`), buttons, mark and the offer's
groups, and skip the place's composition, the type moment and the rooms layout (the `address`
moment is not built; the visit block's three columns are the address). Write "not used" beside
@@ -624,9 +613,8 @@ exception to "two picture-less sections are never neighbours").
No mood stock, code ground, drawing, empty box or note. The report lists the shots to take
(`photos.md`). At 8 good photographs it grows to the compact page, at 12 to the full page. A place
with no photographs of its own takes the draft path first (Plan and length); the short page is for
a trade the library cannot fill (`photos.mjs` names no trade for it: pottery, a restaurant, a salon)
or whose frames, counted as in Plan and length, total under 8.
with no photographs of its own takes the draft path and a live search first (Plan and length); the
short page is for a place whose frames, counted as in Plan and length, total under 8.
## 5. Visuals
+85 -9
View File
@@ -14,8 +14,13 @@ and loop number: `motion.md`.
- Spacing scale 4, 8, 12, 16, 24, 32, 48, 64, 96, 128 (`--s-1` to `--s-10`) for every margin, gap
and padding you write. Heading to content 48px (32 on a phone); cards 24px apart (12).
- One height per card row and per tile kind, page-wide; sections at H or 2H.
- The skip link is the first child of the header's `.wrap`, `position: absolute; width: 1px;
height: 1px; clip-path: inset(50%)` until focused, so it never pushes the logo off the left line.
- The skip link is the first child of the header's `.wrap`, as `starter.html` has it:
`<a class="skip-link" href="#top">Skip to content</a>` (`main id="top"`), in the page's language.
`.skip-link` keeps it out of the layout until the first Tab, then shows it over the logo, so it
never pushes the logo off the left line [skip-link].
- `<html lang>` is the language the page is written in, from the brief, or its market when the
brief does not say ("de" for a German page). Screen readers read in it and the headings hyphenate
by it (`hyphens: auto` for words of 10 letters or more) [lang].
## Rhythm tokens
@@ -29,7 +34,7 @@ On `:root`, used everywhere (desktop / under 640px):
| `--gut` | 80 / 24 (place 120 from 1024) | the `.wrap`'s side margin: 1280 wide, 1200 on a place |
| `--hgap` | 48 / 40 (place 40 / 24) | the header to the hero's words, under `--photo` on a place |
| `--hpadb` | 64 / 24 | the hero's bottom padding (plus `--bar`) |
| `--bar` | 0, or `calc(64px + env(safe-area-inset-bottom))` with `nav.actionbar` under 640 | added to every bottom padding; content is never cut to hide behind the bar |
| `--bar` | 0, or `calc(64px + env(safe-area-inset-bottom))` with `nav.actionbar` under 640 | added to every bottom padding (the hero's only without script: the bar is never on the first screen); content is never cut to hide behind the bar |
Grid: `.grid` is 12 columns with a 24px gap (12 under 640); the column numbers are in
`layout-v4.md` section 4. A picture wall is its own grid with a gap of 0 or 2px (page CSS
@@ -120,7 +125,15 @@ under the h2). A place sets `--t-h2: clamp(24px, .762vw + 21px, 32px)`.
- Under 640: the menu opens a full-screen sheet (56px rows, closes on any tap; a place adds the
street and the week's hours at its foot). `nav.actionbar` (required on a place, optional
elsewhere) is fixed at the foot, on the ground with a 1px top hairline: one 342x48 main action or
two 165x48 buttons; `--bar` is then its height. It never covers a label or link.
two 165x48 buttons; `--bar` is then its height. It never covers a label or link. With the bar
the nav's button moves into the sheet (system.css). system.js shows the bar only once the hero's
main button has scrolled off the top, and hides it again while that button, or a link or button
that does what one of the bar's does (the same address or the same words, such as the close's
booking button or the footer's directions), is on the screen: one screen never shows an action
twice [action-repeat]. Hidden, it is off the screen and inert, so nothing in it takes the focus;
it never hides while the focus is in it. The hero does not pad for it then. Without script the
bar is simply always at the foot and every section, the hero too, pads for it, so it covers
nothing. Write it as the starter does, a child of `body` after the footer, and add no script of your own.
## Footer
@@ -141,14 +154,77 @@ unless the brief gives the accounts.
`data-tz="Europe/Berlin"` on the `dl.hours` itself (the venue's zone; system.js reads it only there): system.js marks today's row (weight 500) and
fills `[data-open-now]` ("Open now, until 16:00"; "Closed, opens Tuesday at 7:30"); never
`.is-today` by hand. Without `data-tz` the rows read as plain text, and the panel must read right
without the script.
- Forms: fields 48px with 13px labels and 16px text (on the phone too), tap targets 44px, six fields
at most, `method="post"` to the client's address, the button repeats the main action, a line says
when the reply comes, fields checked on submit; never a fake success or a modal. A booking system
the brief names replaces the form with its link.
without the script. Hours the brief does not give carry `data-assumed` on the `dl` and get no
open-now line: system.js never fills it from them [open-now].
- Forms: the recipe below.
- A `[bracketed placeholder]` or "(sample)" tag left in visible text is outlined in red by
system.js, as is a visual that fails to draw: fix both.
## Forms
One recipe for every form (booking, enquiry, call request, newsletter); `starter.html` has it as
`booking-form`, system.js runs it. A booking system the brief names replaces the form with its link.
- **Where it sends:** `data-endpoint` holds the address of the client's form service when the
brief gives one; system.js posts to it. Without one the form's `action` is a `mailto:` on
`.example` (`hello@brand.example`, `copy.md`): a stand-in, listed in the report as the address
the client must supply. Never `action="#"`, an empty action or a submit a page script swallows.
- **Size:** fields 48px with labels at 14px and 16px text (on the phone too, so the browser never
enlarges the page), tap targets 44px, six fields at most, one column under 640.
- **Labels you can see:** every field has a `<label for>` above it, in words that say what goes in
("Arrival", "Guests"). A placeholder is never the label, and an `aria-label` alone is not one
[field-label].
- **The right field:** `type="email"` with `autocomplete="email"`, `type="tel"` only for a phone
the brief asks to collect, `autocomplete="name"`, `inputmode="numeric"` for a count, text fields
for dates on a page not in English. A visitor may paste into any field, and the button stays live
until it is pressed [form-fields].
- **Errors under their fields:** checked on submit, never while typing. Each field that fails gets
`aria-invalid="true"` and its reason in the `.field__error` under it, named by
`aria-describedby`; the reason names the field and what to type ("Write an email address with an
@"). The first field that failed takes the focus, and the status line says how many there are.
- **One status line:** `<p class="form__status" role="status" data-form-status>` under the button,
read out as it changes: "Sending" after 300ms, then the confirmation or the failure. A failure
keeps everything typed.
- **Success only after a real send:** `data-sent` shows only once `data-endpoint` answered. A
mailto stand-in shows no success line, and no page script writes "Thank you" on submit
[form-send]. Never a modal.
- **The button names the action:** the button is the main action word for word ("Request a
table"), the sent line names what was sent in the button's own words ("Your table request is in.
We reply within a day."), and a line under the form says when the reply comes.
## Access
The page works by keyboard and screen reader as it ships; consistency.mjs FAILs each of these.
- **Every control has a name:** a link, a button, a field, a tab, a toggle; an icon-only button gets
`aria-label` in the page's language [a11y-name].
- **Pictures:** a content photograph has `alt` saying what it shows; a decorative one `alt=""`;
a drawn mock `role="img"` with an `aria-label` [img-alt].
- **Headings step down one level at a time** in `main` (h1, h2, h3); a footer's column heads are
`h3` [heading-order].
- **Tab order is the reading order:** no `tabindex` above 0 [tabindex].
- **Focus shows at once:** the kit's ring (`:focus-visible`, 2px in `--action`, 3:1 on the
ground) never animates; a page that removes it puts a change of the same strength in its place.
A focused control is never hidden under the fixed header [focus-visible, focus-hidden].
- **The menu button says its state:** `aria-expanded` on `.nav__toggle`, which system.js flips
[menu-toggle].
## Head
Every page's `<head>` holds, as `starter.html` has it:
- `<title>` "Name: what it is for whom or where" and a `meta description` of one sentence; never
the same words [page-meta].
- `og:title`, `og:description`, `og:type` and `og:image`, with `twitter:card`
`summary_large_image`. The picture is a still of the page's own hero: copy the hero's tile from
`shoot.mjs` (`tiles/desktop-sec-01-a.jpg`) beside the page as `share.jpg`.
- `link rel="icon"`: the client's logo, else the dealt mark as `favicon.svg` (the mark's 24x24 path
from `system.css` `.mark[data-mark]`, filled with `--p-ink`; for `initial` the letter on a
rounded square in `--p-ink`).
- `meta name="theme-color"`: the page ground, `--p-bg` as `start.mjs` prints it.
- `link rel="canonical"` only with the client's domain; until then it stays a comment and the
report lists it [share-meta].
## Motion
`motion.md`.
File diff suppressed because it is too large. Load diff
+463
View File
@@ -0,0 +1,463 @@
#!/usr/bin/env node
/*
taste-skill copy-lint.mjs - counts the countable tells of model-written website copy. Node 22, no packages.
The rules it counts are in references/copy-tells.md.
node <skill>/scripts/copy-lint.mjs <page.html | folder | .md | .txt> [more files] [--kind <kind>] [--json] [--year 2026]
It reads the copy a visitor reads: headings, text, buttons, the footer, the <title> and the meta description. It
skips scripts, styles, svg, code, <template>, <noscript>, hidden and aria-hidden parts, and the UI mocks (ui-*
fragments, [data-ui], [data-fx], [data-diagram], role="img", app-frame, device): their words are the product's
screen, not the page's copy. Names are still counted inside the mocks (a default-cast name is a tell anywhere), and
a slop word or stock phrase inside a skipped part is a WARN (copy-unread). A part whose end tag is left out ends
where the HTML rules end it. Words literal for a trade pass (a climbing harness, leverage up to 1:30, a powerful
2,000 W motor), and so do literal virtue words (a quiet room, calm water, genuine parts).
--kind (tech, software, gaming, product, place, service) lets a game keep its own nouns (realm, beacon); without it
the kind the page declares is read (<meta name="site-kind">, page-kind, page-type, <html data-type>), as the gate does.
A .md or .txt draft: the voice card lines (Voice:, Pairs:, Person:, Never: ...), fenced blocks and part labels
(## Hero, ## Footer) are notes, not copy; the footer is what stands under a heading named Footer.
Levels:
FAIL fix it: chatbot residue, a left [NEED] or markdown, the old slop words and stock phrases, and any device
used twice its budget plus one times or more (a tic, not a device). The gate blocks on these.
WARN over budget, or a weak spot: fix it, or keep it on purpose and say why in the report.
INFO look at it.
Each finding has an id (copy-slop-word, copy-two-fragment, ...; the checker's copy-headline, copy-fact and the
rest are its own), a count, examples, a fix and, in --json, every hit with its sentence (the gate makes one
finding of each slop word, stock phrase and residue hit).
With several files it also prints the first headings side by side and the heading phrases they share.
--json prints {files: [...], cross: {...}} and nothing else. --year sets the year the copyright line should
carry (default: this year).
Exit 0 no FAIL, 1 a FAIL in any file, 2 usage or an unreadable file.
*/
import fs from 'node:fs';
import path from 'node:path';
const args = process.argv.slice(2);
const die = (m) => { console.error('copy-lint: ' + m); process.exit(2); };
const files = [];
let asJson = false, KIND = '', YEAR = new Date().getFullYear();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--json') asJson = true;
else if (a === '--kind' || a === '--page') { KIND = String(args[++i] || '').toLowerCase(); if (!/^(tech|software|gaming|product|place|service)$/.test(KIND)) die('--kind takes tech, software, gaming, product, place or service'); }
else if (a === '--year') { YEAR = parseInt(args[++i], 10); if (!(YEAR > 1990 && YEAR < 2200)) die('--year needs a year'); }
else if (a.startsWith('--')) die('unknown option ' + a);
else files.push(a);
}
if (!files.length) die('usage: copy-lint.mjs <page.html | folder | .md | .txt> [more] [--kind <kind>] [--json] [--year 2026]');
// ---------------------------------------------------------------- reading the copy
const ENT = { nbsp: ' ', amp: '&', mdash: '—', ndash: '–', rsquo: "'", lsquo: "'", ldquo: '"', rdquo: '"', bdquo: '"', middot: '·', hellip: '…', quot: '"', apos: "'", lt: '<', gt: '>', times: '×', deg: '°', euro: '€', pound: '£', copy: '©', shy: '', thinsp: ' ', ensp: ' ', emsp: ' ', bull: '•', rarr: '→', larr: '←', trade: '™', reg: '®', hairsp: '', zwj: '', zwnj: '' };
const decode = (s) => s
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n); } catch { return ' '; } })
.replace(/&#x([0-9a-f]+);/gi, (_, n) => { try { return String.fromCodePoint(parseInt(n, 16)); } catch { return ' '; } })
.replace(/&([a-z]+);/gi, (m, n) => ENT[n.toLowerCase()] ?? ' ');
// curly quotes and odd spaces read as their plain forms, so one rule matches both
const tidy = (s) => s.replace(/[‘’ʼ′]/g, "'").replace(/[“”„″]/g, '"').replace(/[    ]/g, ' ');
const squash = (s) => s.replace(/\s+/g, ' ').trim();
const BLOCK = new Set('address article aside blockquote body br dd details dialog div dl dt fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hr html li main nav ol p section summary table tbody td tfoot th thead tr ul option caption legend'.split(' '));
// a link or a button inside a sentence stays in its sentence, but is a line of its own (a label, a price)
const APART = new Set(['a', 'button', 'label']);
const LINE = new Set(['span', 'strong', 'small', 'b', 'em', 'i', 'mark', 'sup', 'sub', 'time', 'abbr', 'cite', 'q', 'data']);
const VOID = new Set('area base br col embed hr img input link meta param source track wbr'.split(' '));
// the tags that shut an element whose end tag may be left out (the HTML rules for <p>, <li>, <dt>, <option>, table rows)
const SHUTS = { p: /^(address|article|aside|blockquote|details|dialog|div|dl|fieldset|figcaption|figure|footer|form|h[1-6]|header|hgroup|hr|main|menu|nav|ol|p|pre|search|section|table|ul)$/, li: /^li$/, dt: /^(dt|dd)$/, dd: /^(dt|dd)$/,
option: /^(option|optgroup)$/, optgroup: /^optgroup$/, tr: /^tr$/, td: /^(td|th|tr)$/, th: /^(td|th|tr)$/, thead: /^(tbody|tfoot)$/, tbody: /^(tbody|tfoot)$/ };
const attrOf = (a, k) => { const m = a.match(new RegExp(`(?:^|\\s)${k}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return m ? (m[1] ?? m[2] ?? m[3]) : null; };
const hasAttr = (a, k) => new RegExp(`(?:^|\\s)${k}(?=[\\s=/>]|$)`, 'i').test(a);
// a UI mock, a drawn diagram or a part nobody sees: its words are not the page's copy
function hiddenOrMock(tag, a) {
if (hasAttr(a, 'hidden') || /^true$/i.test(attrOf(a, 'aria-hidden') || '') || /^(img|presentation)$/i.test(attrOf(a, 'role') || '')) return true;
if (hasAttr(a, 'data-ui') || hasAttr(a, 'data-fx') || hasAttr(a, 'data-fx-mounted') || hasAttr(a, 'data-diagram')) return true;
const cls = (attrOf(a, 'class') || '').split(/\s+/);
return cls.some((c) => /^ui(-|$)/.test(c) || c === 'app-frame' || c === 'device');
}
const EYEBROW = /^(eyebrow|kicker|overline|pretitle|section-label|section-kicker|tagline|tag-line)(__|--|$)/;
function readHtml(raw) {
const title = tidy(decode(((raw.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '').replace(/<[^>]+>/g, ' ')));
const metaTag = [...raw.matchAll(/<meta\b[^>]*>/gi)].map((m) => m[0]).find((t) => /name\s*=\s*["']?description/i.test(t));
const meta = metaTag ? tidy(decode(attrOf(metaTag.slice(5), 'content') || '')) : '';
// the full text, mocks included, for the names
const strip = (h) => h
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<![^>]*>|<\?[^>]*>/g, ' ')
.replace(/<head\b[\s\S]*?<\/head>/gi, ' ')
.replace(/<(script|style|svg|template|noscript|code|pre|kbd|samp|textarea|select|canvas|math|iframe|object)\b[\s\S]*?<\/\1>/gi, ' ');
const html = strip(raw);
const all = tidy(decode(html.replace(/<[^>]+>/g, ' ')));
const blocks = [], lines = [], headings = [], unread = [];
let block = '', line = '', head = null, foot = 0, skip = null, eyebrows = 0;
const flushLine = () => { const t = squash(line); if (t) lines.push({ text: t, footer: foot > 0 }); line = ''; };
const flushBlock = () => { flushLine(); const t = squash(block); if (t) blocks.push({ text: t, heading: head ? head.level : 0, footer: foot > 0 }); block = ''; };
const endSkip = () => { const t = squash(skip.text); if (t) unread.push(t); skip = null; };
for (const m of html.matchAll(/<(\/?)([a-zA-Z][\w:-]*)([^>]*)>|([^<]+)|</g)) {
if (m[4] !== undefined || m[0] === '<') {
const t = tidy(decode(m[4] ?? '<'));
if (skip) { skip.text += t; continue; }
block += t; line += t; if (head) { head.text += t; if (!head.grey) head.main += t; }
continue;
}
const close = m[1] === '/', tag = m[2].toLowerCase(), a = m[3] || '';
const selfClose = VOID.has(tag) || /\/\s*$/.test(a);
// a skipped part ends at its end tag, at an end tag of a part around it, or where the next tag shuts it (an <li>
// or a <p> whose end tag is left out): a missing end tag never hides the rest of the page
if (skip) {
if (selfClose) { if (BLOCK.has(tag)) skip.text += ' '; continue; }
if (!close) {
if (!skip.stack.length && SHUTS[skip.tag] && SHUTS[skip.tag].test(tag)) endSkip();
else { skip.stack.push(tag); if (BLOCK.has(tag)) skip.text += ' '; continue; }
} else {
const i = skip.stack.lastIndexOf(tag);
if (i >= 0) { skip.stack.length = i; if (BLOCK.has(tag)) skip.text += ' '; continue; }
const own = tag === skip.tag;
endSkip();
if (own) continue;
}
}
if (!close && hiddenOrMock(tag, a)) { if (!selfClose) { flushBlock(); skip = { tag, stack: [], text: '' }; } continue; }
if (!close && (attrOf(a, 'class') || '').split(/\s+/).some((c) => EYEBROW.test(c))) eyebrows++;
// a heading's grey second half (<span class="muted">, system.md) is the line under a name, not a second fragment
if (head && !selfClose) {
if (head.grey) { if (tag === head.grey.tag) head.grey.depth += close ? -1 : 1; if (head.grey.depth <= 0) head.grey = null; }
else if (!close && (attrOf(a, 'class') || '').split(/\s+/).includes('muted')) head.grey = { tag, depth: 1 };
}
if (/^h[1-6]$/.test(tag)) {
flushBlock();
if (!close) head = { level: +tag[1], text: '', main: '', grey: null };
else if (head) { const t = squash(head.text); if (t && head.level <= 4) headings.push({ level: head.level, text: t, main: squash(head.main), footer: foot > 0 }); head = null; }
continue;
}
if (tag === 'footer') { flushBlock(); foot = Math.max(0, foot + (close ? -1 : 1)); continue; }
if (BLOCK.has(tag)) { flushBlock(); if (head) { head.text += ' '; head.main += ' '; } continue; }
if (APART.has(tag)) { flushLine(); block += ' '; if (head) { head.text += ' '; head.main += ' '; } continue; }
if (LINE.has(tag)) { flushLine(); if (!/\s$/.test(block)) block += ' '; }
}
flushBlock();
if (skip) endSkip();
return { title: squash(title), meta: squash(meta), blocks, lines, headings, eyebrows, all: squash(all), unread, kind: declaredKind(raw), brands: brandsOf(raw, squash(title)) };
}
// the client's name as the page writes it: og:site_name or application-name, the logo's text (a class ending in logo,
// brand or wordmark) and the title before its first ":", "|" or dash; one to five words each
function brandsOf(raw, title) {
const out = new Set();
const keep = (s) => { s = squash(tidy(decode(String(s || '').replace(/<[^>]+>/g, ' ')))).replace(/^[\s"'.,:;|·–—-]+|[\s"'.,:;|·–—-]+$/g, ''); const n = wc(s); if (n >= 1 && n <= 5 && /[a-z]/i.test(s)) out.add(s); };
for (const m of raw.matchAll(/<meta\b[^>]*>/gi)) if (/(property|name)\s*=\s*["']?(og:site_name|application-name)["'\s>]/i.test(m[0])) keep(attrOf(m[0].slice(5), 'content'));
const body = raw.replace(/<head\b[\s\S]*?<\/head>/i, ' ').replace(/<(script|style|svg|template)\b[\s\S]*?<\/\1>/gi, ' ');
for (const m of body.matchAll(/<(a|div|span|p|strong|b|h1)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
if ((attrOf(m[2], 'class') || '').split(/\s+/).some((c) => /(^|[_-])(logo|brand|wordmark)$/i.test(c))) keep(m[3]);
}
const t = title.split(/\s*[:|]\s*|\s+[–—·-]\s+/)[0];
if (t && t !== title) keep(t);
return [...out];
}
// the kind the page declares, read as gate.mjs and the checker read it (site-kind, page-kind, page-type, <html data-type>)
function declaredKind(raw) {
const meta = (n) => { const t = [...raw.matchAll(/<meta\b[^>]*>/gi)].map((m) => m[0]).find((x) => new RegExp(`name\\s*=\\s*["']?${n}["'\\s>]`, 'i').test(x)); return t ? String(attrOf(t.slice(5), 'content') || '').trim().toLowerCase() : ''; };
const site = meta('site-kind');
if (/^(software|gaming|tech)$/.test(site)) return site;
if (meta('page-kind') === 'place') return 'place';
const t = meta('page-type') || (((raw.match(/<html\b[^>]*>/i) || [''])[0].match(/data-type\s*=\s*["']?([\w-]+)/i) || [])[1] || '').toLowerCase();
return /^(software|product|place|service)$/.test(t) ? t : '';
}
// a draft in a .md or .txt file: the voice card (copy-voice.md section 2: Voice:, Pairs:, Person:, Never: ...) and
// fenced blocks are notes, not copy; the footer is what stands under a heading named Footer
const DRAFT_LABEL = /^((the )?(hero|footer|prices?|faq|about|story|close|closing|nav|header|buttons|microcopy|meta)|(copy )?draft\b.*)$/i;
const CARD = /^(voice|register|pairs|person|sentences|humou?r|house words|never|tone|reader|notes?)\s*:/i;
function readText(raw) {
const ls = []; let fence = false;
for (const l0 of tidy(raw).split(/\r?\n/)) {
const l = l0.trim();
if (/^(```|~~~)/.test(l)) { fence = !fence; continue; }
if (fence || !l || CARD.test(l.replace(/^[-*]\s+/, ''))) continue;
ls.push(l);
}
const blocks = []; let foot = false;
for (const l of ls) {
const lv = (l.match(/^#{1,6}(?=\s)/) || [''])[0].length;
if (lv) foot = /^#+\s*(the\s+)?footer\b/i.test(l);
blocks.push({ text: l.replace(/^#+\s*/, '').replace(/^[-*]\s+/, ''), heading: lv <= 4 ? lv : 0, footer: foot && !lv });
}
// a heading that only labels a part of the draft (## Hero, ## Footer, # Copy draft: ...) is not copy
const copy = blocks.filter((b) => !(b.heading && DRAFT_LABEL.test(b.text)));
const headings = copy.filter((b) => b.heading).map((b) => ({ level: b.heading, text: b.text, footer: false }));
return { title: '', meta: '', blocks: copy, lines: copy.map((b) => ({ text: b.text, footer: b.footer })), headings, eyebrows: 0, all: copy.map((b) => b.text).join(' '), unread: [], kind: '', brands: [] };
}
// ---------------------------------------------------------------- the rules (references/copy-tells.md)
// the first generation: one is a tell. A word that is literal for a trade keeps it (a climbing harness, bespoke
// tailoring, leverage up to 1:30, a powerful 2,000 W motor): a regex on the words after it, or a test of (after, before).
const SLOP = ['delve', 'delves', 'tapestry', 'testament to', 'realm', 'embark', 'beacon', 'multifaceted', 'paradigm', 'myriad', 'plethora', 'meticulous', 'meticulously', 'intricate', 'interplay', 'garner',
'elevate', 'elevates', 'elevated', 'elevating', 'unlock', 'unlocks', 'unleash', 'unleashes', 'empower', 'empowers', 'empowering', 'harness', 'supercharge', 'supercharged', 'turbocharge', 'seamless', 'seamlessly', 'effortless', 'effortlessly',
'cutting-edge', 'revolutionary', 'revolutionize', 'revolutionise', 'transformative', 'holistic', 'next-level', 'world-class', 'best-in-class', 'game-changer', 'game changer', 'game-changing', 'state-of-the-art', 'bespoke', 'synergy', 'leverage',
'reimagine', 'reimagined', 'reimagining', 'next-generation', 'next-gen', 'powerful', 'robust', 'nestled', 'boasts', 'unparalleled', 'unrivaled', 'unrivalled'];
const SPEC = String.raw`\d[\d,.]*\s?(w|kw|hp|ps|v|nm|mah|wh|kwh|lumens?|lm|watts?|volts?|pa|bar|cc|l|litres?|liters?|kg|rpm)\b`;
const LITERAL = {
harness: /^(?!(es|ed|ing)?\s+(the|your|our|its|their|this|that|every|all|ai|data|power)\b)/i,
unlock: /^s?\s+(the |your |a )?(door|doors|bike|bikes|car|phone|scooter|scooters|locker|lockers|gate|box|room)\b/i,
unlocks: /^\s+(the |your |a )?(door|doors|bike|car|phone|scooter|locker|gate|box|room)\b/i,
elevated: /^\s+(train|track|tracks|walkway|platform|terrace|deck|heart rate|heart-rate|position|ground|site|plot|bed|beds|garden|view|views|railway|line)\b/i,
seamless: /^\s+(knit|knitted|knitting|garments?|socks?|tights|leggings|bras?|tubes?|pipes?|steel|gutters?|flooring|floors?|stockings|underwear)\b/i,
bespoke: /^\s+(tailoring|tailor|tailors|suits?|shirts?|shoes|boots|jackets?|trousers|joinery|kitchens?|furniture|cabinetry|wardrobes?)\b/i,
// finance: the ratio a broker allows ("leverage up to 1:30", "with leverage")
leverage: (a, b) => /^\s+(of\s+\d|up to|ratios?|limits?|\d)/i.test(a) || /^[^.]{0,24}\b\d+:\d+\b/.test(a) || /\b(with|max(imum)?|margin|financial|high|low|no)\s+$/i.test(b),
tapestry: (a, b) => /^\s+(weaving|weavers?|workshops?|class(es)?|courses?|looms?|museum|studio|restoration|conservation|kits?|wool|yarn|frames?)\b/i.test(a) || /\b(woven|wool|silk|hand-woven|medieval|antique)\s+$/i.test(b),
intricate: /^\s+(lace|beadwork|embroidery|carvings?|inlays?|marquetry|filigree|knotwork|cutwork|quilting|stitch(ing|es)?|engravings?|joints?|dovetails?|smocking|plasterwork|ironwork|woodwork|tracery)\b/i,
// hardware: a figure with its unit right after the word ("a powerful 2,000 W motor")
powerful: new RegExp(String.raw`^\s+(and\s+)?${SPEC}`, 'i'),
robust: /^\s+(steel|stainless|aluminium|aluminum|alloy|oak|ash|beech|timber|cast[- ]iron|iron|carbon|frames?|chassis|casing|housing|hinges?|axles?|welds?|tyres?|tires?|zips?|stitching|canvas|leather)\b/i,
realm: () => KIND === 'gaming', embark: () => KIND === 'gaming', beacon: () => /^(gaming|tech|software)$/.test(KIND),
};
const literal = (w, after, before) => { const l = LITERAL[w]; return !!l && (typeof l === 'function' ? l(after, before) : l.test(after)); };
const STOCK = [/in today'?s (fast-paced|digital|modern|ever-changing|busy)/i, /unlock the (power|potential)/i, /harness the power/i, /the future of [a-z]+ is (here|now)/i, /say goodbye to/i,
/whether you'?re an? /i, /look no further/i, /join (thousands|millions|hundreds) of/i, /trusted by (teams|businesses|companies|brands|people) (worldwide|everywhere|around the world)/i, /imagine a world/i,
/ready to (transform|take your|elevate|level up)/i, /ever-evolving/i, /one-stop shop/i, /take (it|your [a-z]+|things) to the next level/i,
/\b(it'?s|this is|we'?re|we are) not just (a|an|another)\b/i, /\bnot only\b[^.!?]{1,60}\bbut also\b/i, /\bhere'?s the thing\b/i, /\blet that sink in\b/i, /\blet'?s dive in\b/i,
/\bit'?s (important|worth) (to note|noting)\b/i, /\b(in|with) just a few clicks\b/i, /\bwe'?ve got you covered\b/i, /\bthe possibilities are endless\b/i, /\bat your fingertips\b/i, /\b[a-z]+ made (simple|easy)\b/i];
const RESIDUE = [/lorem ipsum/i, /\[(your |company |brand |business )?(company|brand|business|product) name\]/i, /\bcertainly!/i, /\bi hope this helps\b/i, /\bas an ai\b/i, /\[insert\b/i, /\[NEED\b[^\]]*\]?/i,
/\{\{\s*[\w.]+\s*\}\}/, /\bgreat question!/i];
// markdown that leaked into a rendered page (a .md draft keeps its own); "** 4242" on a card mask is not bold
const MARKDOWN = [/\*\*(?=[^\s*])[^*\n]{1,60}(?<=[^\s*])\*\*/, /(^|\s)#{2,3} [A-Z]/];
const VIRTUE = ['honest', 'honestly', 'calm', 'calmly', 'quiet', 'quietly', 'actually', 'genuine', 'genuinely', 'truly', 'simply', 'thoughtful', 'thoughtfully', 'carefully', 'intentional', 'intentionally', 'steady', 'dependable'];
// a virtue word that is literal: a quiet room, calm water, genuine parts, a motor that runs quietly, a bay that stays calm
const VIRTUE_THING = /^\s+(rooms?|bedrooms?|hours?|streets?|lanes?|roads?|corners?|carriages?|coach|zones?|side|end|areas?|nights?|mornings?|evenings?|seasons?|months?|days?|weeks?|times?|tables?|spots?|places?|gardens?|courtyards?|villages?|valleys?|bays?|beach(es)?|coves?|waters?|seas?|lakes?|loch|rivers?|harbou?r|conditions|weather|winds?|mode|motors?|fans?|pumps?|engines?|compressors?|keyboards?|keys|switches|cabins?|wards?|floors?|carriage|neighbou?rhoods?|parts?|spares?|leather|wool|silk|antiques?|replacements?|oem|flow|current|supply|pace|state|climb|descent|rain|breeze)\b/i;
const VIRTUE_LIT_WORDS = new Set(['calm', 'quiet', 'steady', 'genuine']);
const VIRTUE_MACHINE = /\b(runs?|running|hums?|closes?|shuts?|opens?|spins?|turns?|rolls?|glides?|operates?|works?|starts?|drives?|flows?|sits?)\s+$/i;
const VIRTUE_SELF = /^(team|service|staff|approach|process|brand|company|studio|design|support|people|tone|voice|work|product|app|software|advice|prices?|pricing|way|style|we|i)$/i;
const VIRTUE_PLACE = /\b(the|a|this|that|its|their|each|every|our)\s+([a-z-]+)(\s+[a-z-]+)?\s+(is|are|stays?|gets?|was|were|feels?|remains?|keeps?|lies|lie|turns?|goes|go|falls?)\s+(\w+\s+)?$/i;
function virtueLiteral(w, after, before) {
if (/ly$/.test(w)) return VIRTUE_MACHINE.test(before);
if (!VIRTUE_LIT_WORDS.has(w)) return false;
if (VIRTUE_THING.test(after)) return true;
const p = before.match(VIRTUE_PLACE);
return !!p && !VIRTUE_SELF.test(p[3] ? p[3].trim() : p[2]) && p[1].toLowerCase() !== 'our';
}
const CAST = ['Priya', 'Okafor', 'Raman', 'Natarajan', 'Sarah J', 'Elara', 'Aisha', 'Lena M', 'Marcus', 'Dana', 'Tomas', 'Nora', 'Mara', 'Isla', 'Maya', 'Ines', 'Jess'];
const CAST_CO = ['Fieldnote', 'Kestrel', 'Halcyon', 'Brightloop', 'Acme', 'Northwind', 'Lumen', 'Nimbus', 'Zenith', 'Apex', 'Vertex'];
// the names copy.md says never to coin: as the page's own brand (its title or h1) they FAIL
const COINED = new Set(['Fieldnote', 'Kestrel', 'Halcyon', 'Lumen', 'Nimbus']);
const NUM = '(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\\d+)';
const PUFF = [/\bserves as\b/i, /\bstands as\b/i, /\bpivotal\b/i, /\bcrucial\b/i, /\bplays an? (key|vital|crucial|pivotal|central) role\b/i, /\bin the heart of\b/i, /\bwhere [a-z]+(?: [a-z]+)? meets [a-z]+/i, /\bas seen in\b/i, /\bthe world of\b/i];
const REVEAL = [/\b(the result|the best part|the catch|the secret|the difference|the twist|the answer)\?\s/i, /\bwhy does (this|it) matter\?/i, /\b(one|a single) (thing|change|idea|question|rule)[^.:!?\n]{0,30}: /i, /\b(this section|below,? (we|you'?ll)|here are \d+ reasons|we'?ve added)\b/i];
// an abbreviation's full stop does not end a sentence or a heading fragment ("Rooms in St. Ives", "Breakfast at No. 7")
const ABBR = /\b(St|Ste|Dr|Mt|Mr|Mrs|Ms|Co|No|Nos|Nr|Ave|Rd|Ln|Ltd|Inc|Jr|Sr|Ft|Pt|Prof|Rev|Capt|Sgt|Lt|vs|approx|est|incl|excl|dept|tel|fig|vol|ca|cf|e\.g|i\.e|a\.m|p\.m)\./gi;
const unabbr = (s) => s.replace(ABBR, (m) => m.replace(/\./g, '․'));
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordRe = (w) => new RegExp(`\\b${esc(w).replace(/ /g, '\\s')}\\b`, 'gi');
const sentencesOf = (text) => unabbr(text).split(/(?<=[.!?])\s+(?=[A-Z"'(])/).map((s) => s.trim().replace(/․/g, '.')).filter((s) => /[a-z]/i.test(s));
const clip = (s, n = 110) => (s.length > n ? s.slice(0, n - 3) + '...' : s);
const wc = (s) => s.split(/\s+/).filter(Boolean).length;
// the sentence of a block that holds the character at i
function sentenceAt(b, i) {
const t = unabbr(b);
let s = 0; for (const m of t.matchAll(/[.!?](?=\s+[A-Z"'(])/g)) { if (m.index >= i) break; s = m.index + 1; }
const e = t.slice(i).search(/[.!?](\s+[A-Z"'(]|\s*$)/);
return b.slice(s, e < 0 ? b.length : i + e + 1).trim();
}
function lint(file) {
const raw = fs.readFileSync(file, 'utf8');
const isHtml = /\.html?$/i.test(file);
const R = isHtml ? readHtml(raw) : readText(raw);
// --kind wins; else the kind the page declares (as the gate reads it), so a game linted by hand keeps its nouns
const kindNow = KIND || R.kind || '';
const saved = KIND; KIND = kindNow;
try { return { ...lintRead(R, isHtml), file, kind: kindNow || null }; } finally { KIND = saved; }
}
function lintRead(R, isHtml) {
const body = R.blocks.map((b) => b.text);
const text = body.join('\n');
const prose = R.blocks.filter((b) => wc(b.text) >= 5).map((b) => b.text).join(' ');
const proseBlocks = R.blocks.filter((b) => wc(b.text) >= 5).map((b) => b.text);
const words = wc(text);
const out = [];
// hits are { m: what matched, s: the sentence it is in } (or plain strings); the gate makes one finding of each
const hitOf = (h) => (typeof h === 'string' ? { m: h, s: h } : h);
const exOf = (h) => { const x = hitOf(h); return x.m === x.s ? x.s : `"${x.m}" in: ${x.s}`; };
const add = (sev, id, hits, msg, fix, extra = {}) => out.push({ sev, id, count: Array.isArray(hits) ? hits.length : hits, msg, fix,
examples: Array.isArray(hits) ? [...new Set(hits.map(exOf))].slice(0, 4).map((e) => clip(e)) : [],
hits: Array.isArray(hits) ? hits.map(hitOf).map((x) => ({ match: clip(x.m, 80), sentence: clip(x.s, 160) })) : [], ...extra });
// every match of a pattern, with its sentence; checked against the copy and, for the old slop, the title and meta too
const hitsIn = (re, src = body) => src.flatMap((b) => [...b.matchAll(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'))].map((m) => ({ m: m[0].trim(), s: sentenceAt(b, m.index) })));
const withMeta = [...body, R.title, R.meta].filter(Boolean);
// ---- FAIL: residue, the old slop, stock phrases
const residue = [...RESIDUE, ...(isHtml ? MARKDOWN : [])].flatMap((re) => hitsIn(re, withMeta));
if (residue.length) add('FAIL', 'copy-residue', residue, 'chatbot residue, a template token, markdown or a [NEED] left on the page', 'write the real words from the brief, or leave the element out and list what is missing in the report');
// a listed word inside the client's name (Elevate Pilates, Beacon Dental) is the name, not copy: the name as the
// page writes it (same case, whole words) is left out and counted once below
const brandRes = (R.brands || []).filter((n) => SLOP.some((w) => wordRe(w).test(n))).map((n) => new RegExp(`(?<![\\w-])${esc(n).replace(/ /g, '\\s+')}(?![\\w-])`, 'g'));
const inBrand = (b, i, len) => brandRes.some((re) => [...b.matchAll(re)].some((x) => x.index <= i && i + len <= x.index + x[0].length));
const branded = [];
const slopIn = (src, mark) => {
const found = [];
for (const w of SLOP) for (const b of src) for (const m of b.matchAll(wordRe(w))) {
const after = b.slice(m.index + m[0].length, m.index + m[0].length + 40), before = b.slice(Math.max(0, m.index - 40), m.index);
if (literal(w, after, before)) continue;
if (inBrand(b, m.index, m[0].length)) { if (mark) branded.push({ m: m[0].toLowerCase(), s: sentenceAt(b, m.index) }); continue; }
found.push({ m: m[0].toLowerCase(), s: sentenceAt(b, m.index) });
}
return found;
};
const slop = slopIn(withMeta, true);
if (slop.length) add('FAIL', 'copy-slop-word', slop, `old slop words (${[...new Set(slop.map((s) => s.m))].slice(0, 6).join(', ')})`, 'replace each word with the fact it stands in for: the step count, the figure, the material, the place (a word in the client\'s own name is not counted when the logo, the title before its ":" or og:site_name writes the name)');
if (branded.length) add('WARN', 'copy-brand-word', branded, `the brand name holds a listed word (${[...new Set(branded.map((s) => s.m))].join(', ')}): read as the name, not counted`, 'keep it when the brief gives this name and say so in the report; a name you coined gets another (copy.md, names.json)');
const stock = STOCK.flatMap((re) => hitsIn(re, withMeta));
if (stock.length) add('FAIL', 'copy-stock-phrase', stock, 'stock phrases that fit any business', 'say what this business does, for whom, with one fact from the brief');
// words in a part the lint skips (a UI mock, a hidden or aria-hidden part): a product's own screen may hold them,
// but copy moved into such a part to get past the lint is still copy
const unread = R.unread || [];
const hid = [...slopIn(unread), ...STOCK.flatMap((re) => hitsIn(re, unread))];
if (hid.length) add('WARN', 'copy-unread', hid, `${hid.length} slop word${hid.length > 1 ? 's or phrases' : ' or phrase'} inside a UI mock or a hidden part (not counted above)`, 'if a visitor reads it as the page\'s copy, it is copy: fix it there; a real product screen\'s own strings may stay, say so in the report');
// ---- budgeted devices: WARN over the budget, FAIL at twice the budget plus one or more
const budget = (id, hits, max, msg, fix, failAt = max * 2 + 1) => {
if (hits.length >= failAt) add('FAIL', id, hits, `${msg}: ${hits.length}, budget ${max} (a tic, not a device)`, fix, { budget: max });
else if (hits.length > max) add('WARN', id, hits, `${msg}: ${hits.length}, budget ${max}`, fix, { budget: max });
else if (hits.length) add('INFO', id, hits, `${msg}: ${hits.length} of ${max}`, '', { budget: max });
};
const hText = R.headings.filter((h) => !h.footer).map((h) => h.text);
const hMain = R.headings.filter((h) => !h.footer).map((h) => h.main ?? h.text);
budget('copy-two-fragment', hMain.filter((h) => /^[^.?!]{2,48}[.!] [^.?!]{2,55}[.!]?$/.test(unabbr(h)) && wc(h) <= 14), 1, 'two-fragment headings "X. Y."', 'spend it once where it lands hardest; give the other headings a plain label, a claim or the thing they name');
budget('copy-number-pair', hMain.filter((h) => new RegExp(`^${NUM}\\b[^.]*?[,.]\\s+${NUM}\\b`, 'i').test(unabbr(h))), 1, 'number-pair headings "Four X, one Y"', 'say what the section holds ("Recent buildings") and let the first line carry the claim');
// a question in the buyer's words (an FAQ entry) is not the device
const contrast = hitsIn(/, not (?!only|just)[a-z]|\bisn't\b|\bnot just\b|\brather than\b|\binstead of\b|\bwithout the\b|\bnot by\b/i, body.filter((b) => !/\?\s*$/.test(b)));
budget('copy-contrast', contrast, 2, 'contrast frames ("X, not Y", isn\'t, rather than, instead of, without the)', 'state the positive fact on its own; count all the forms together');
budget('copy-negation', hitsIn(/\bNo [^.,\n]{1,30}(, no |\. No )[^.\n]{0,40}/), 1, 'negation runs "No X, no Y"', 'keep one, and only if each item is a refusal a buyer can check');
const virtue = [];
for (const b of proseBlocks) for (const w of VIRTUE) for (const m of b.matchAll(wordRe(w))) {
const word = m[0].toLowerCase(), after = b.slice(m.index + m[0].length, m.index + m[0].length + 30), before = b.slice(Math.max(0, m.index - 50), m.index);
if (virtueLiteral(word, after, before)) continue;
virtue.push({ m: word, s: sentenceAt(b, m.index) });
}
budget('copy-virtue', virtue, 2, 'virtue words about the business (honest, calm, quiet, actually, simply...; a quiet room or calm water is literal and not counted)', 'show it with a fact or a limit from the brief instead of saying it');
const riders = hitsIn(/, (ensuring|enabling|allowing|empowering|helping|letting|giving|making it|creating|delivering|fostering)\b[^\n]*/i);
budget('copy-ing-rider', riders, 1, '-ing riders (", ensuring...", ", allowing...")', 'end the sentence at its claim, or make the rider its own sentence with a subject', 99);
// one fact repeated word for word where it belongs (copy.md) is one use
const phrases = (re) => [...new Set(body.flatMap((b) => [...b.matchAll(new RegExp(re.source, 'gi'))].map((m) => m[0].toLowerCase())))];
budget('copy-since', phrases(/\bsince (19|20)\d\d\b/), 1, '"since [year]"', 'say it once, where it matters', 99);
budget('copy-response-time', phrases(/\b(within|in under) (one|two|three|24|48|\d+) (working |business )?(days?|hours?)\b/), 1, 'response-time promises', 'keep one, only if the brief gives it', 99);
const puff = PUFF.flatMap((re) => hitsIn(re));
budget('copy-puffery', puff, 0, 'puffery and dead metaphors (serves as, pivotal, in the heart of, where X meets Y, as seen in)', 'name the street, the figure or the outlet; plain "is" and "has" read as a person', 99);
budget('copy-reveal', REVEAL.flatMap((re) => hitsIn(re)), 0, 'staged reveals and process talk ("The result?", "One thing changed: X", "this section")', 'make the point directly; the page never talks about itself', 99);
const proofy = hitsIn(/\b\d+(\.\d+)?\s?x (faster|more|better|cheaper|quicker)\b|\b\d[\d,.]*\s?(k|m)?\+ (teams|customers|users|companies|businesses|clients|people|members|creators|developers|happy)\b|\b(9\d|100)% (of )?(customers|clients|users|satisfaction|satisfied)\b/i);
if (proofy.length) add('WARN', 'copy-proof-figure', proofy, `${proofy.length} proof figure${proofy.length > 1 ? 's' : ''} ("3x faster", "10,000+ teams", "98% satisfied")`, 'keep a figure only when the brief gives it, with its unit and in a sentence; never round, never invented');
// ---- punctuation
const emd = (prose.match(/—/g) || []).length;
if (emd > 2) add('WARN', 'copy-em-dash', emd, `${emd} em dashes in the body (budget 2)`, 'use a comma, a colon, parentheses or a full stop where each is natural');
const bang = [...body.filter((b) => b.includes('!') && /[a-z]/i.test(b))];
if (bang.length) add('WARN', 'copy-exclaim', bang, `${bang.length} line${bang.length > 1 ? 's' : ''} with an exclamation mark`, 'end on a full stop: copy.md bans exclamation marks');
// ---- numbers in strips, markers, labels
// a run of short number lines with no sentence between: a stat strip. Prices and times are a menu or a timetable,
// and step numbers set apart (01, 02) are counted as step numbers (copy-numbered), not as figures.
const PRICE = /^([$€£]\s?)?(\d{1,6}|\d{1,3}([.,' ]\d{3})+)([.,]\d{1,2})?\s?(€|£|\$|eur|kr|chf|zł|,-)?$/i;
const TIME = /^\d{1,2}[:.]\d\d\s?(am|pm|a\.m\.|p\.m\.)?(\s?[–-]\s?\d{1,2}[:.]\d\d\s?(am|pm)?)?$/i;
const STAT = /^[~<>≈+−-]?\s?[$€£]?\d[\d,.]*\s?(%|\+|k|kg|m|ms|min|x|×|★|\/5|\/10| ?of \d+| ?days?| ?hrs?| ?hours?| ?years?| ?weeks?)?$|^\d+(\.\d)?\s?\/\s?5$/i;
let run = { s: 0, t: 0, l: [] }; const strips = [];
// one unit down a column (75 min, 60 min, 45 min) is a timetable or a spec list, not a strip of claims
const unit = (l) => (l.match(/[^\d\s.,]+\s*$/) || [''])[0].trim().toLowerCase();
const endRun = () => {
const units = new Set(run.l.filter((l) => !TIME.test(l)).map(unit));
if ((run.s >= 3 || (run.s >= 2 && run.s + run.t >= 3)) && !(units.size === 1 && !units.has(''))) strips.push(run.l.join(' / '));
run = { s: 0, t: 0, l: [] };
};
for (const { text: l } of R.lines) {
if (/^0\d\.?$/.test(l)) continue;
if (PRICE.test(l) && /[.,]\d{2}\b|[$€£]|eur|kr|chf/i.test(l)) continue;
if (TIME.test(l)) { run.t++; run.l.push(l); } else if (STAT.test(l)) { run.s++; run.l.push(l); } else if (wc(l) > 9) endRun();
}
endRun();
if (strips.length) add('WARN', 'copy-stat-strip', strips, `stat strip x${strips.length}`, 'a strip only of figures the brief gives (a price, hours, a count); otherwise put each number in the sentence it proves, or leave it out');
const numbered = R.lines.filter((l) => /^0\d\.?$/.test(l.text)).length;
if (numbered >= 6) add('WARN', 'copy-numbered', numbered, `${numbered} step numbers`, 'number only steps that happen in order');
if (R.eyebrows >= 5) add('WARN', 'copy-eyebrows', R.eyebrows, `${R.eyebrows} eyebrow or kicker labels`, 'one or two a page; a heading that names the thing needs no label over it');
// ---- people and names, mocks included
const people = CAST.filter((n) => new RegExp(`\\b${esc(n)}\\b`).test(R.all));
if (people.length) add('WARN', 'copy-cast', people, `default-cast names: ${people.join(', ')}`, 'person names come only from the brief; a mock\'s names fit its locale and are not the names every model picks');
const brandText = [R.title, ...R.headings.filter((h) => h.level === 1).map((h) => h.text)].join(' ');
const cos = CAST_CO.filter((n) => new RegExp(`\\b${esc(n)}\\b`).test(R.all));
const brandCos = cos.filter((n) => COINED.has(n) && new RegExp(`\\b${esc(n)}\\b`).test(brandText));
if (brandCos.length) add('FAIL', 'copy-cast-company', brandCos, `the brand is a name every model coins: ${brandCos.join(', ')}`, 'coin another name and search it (copy.md, names.json); if the brief gives this name, waive it with the brief as the reason');
const otherCos = cos.filter((n) => !brandCos.includes(n));
if (otherCos.length) add('WARN', 'copy-cast-company', otherCos, `default fictional company names: ${otherCos.join(', ')}`, 'coin another name (copy.md, names.json)');
const quotes = body.filter((b) => /^["“][^\n]{20,}["”]$/.test(b)).length;
if (quotes >= 3) add('INFO', 'copy-quotes', quotes, `${quotes} quoted testimonials`, 'only quotes the brief gives, with name and role or town; none is fine');
// ---- the footer: facts, not a sign-off
const footLines = R.lines.filter((l) => l.footer).map((l) => l.text);
const tailLines = footLines.length || !isHtml ? footLines : R.lines.slice(-10).map((l) => l.text);
const signoff = tailLines.filter((l) => /^(made|designed|built|crafted|roasted|grown|brewed|baked|written|fired|poured|stitched|shot|printed|sewn|cooked)\b[^.]{0,40}\b(in|by|on|with|at|since)\b/i.test(l) || /^(every|since) [^.]{3,30}\.$/i.test(l));
if (signoff.length) add('WARN', 'copy-footer-signoff', signoff, 'a sign-off line in the footer', 'the footer holds facts: the brand, the year, Privacy, Terms, the credits line');
const cy = [...(R.all + ' ' + R.title).matchAll(/©\s*(?:[^\d\n]{0,30}?)((?:19|20)\d\d)(?:\s*[–-]\s*((?:19|20)\d\d))?/g)].map((m) => +(m[2] || m[1]));
if (cy.length && Math.max(...cy) < YEAR) add('WARN', 'copy-stale-year', cy.map(String), `the copyright line says ${Math.max(...cy)}`, `write the current year (${YEAR})`);
// ---- lists of three and rhythm: information only, unless the rhythm is a drumbeat
const triads = [...prose.matchAll(/(?:^|[.:;!?] |, )([a-z][\w'-]*(?: [\w'-]+){0,2}), ([a-z][\w'-]*(?: [\w'-]+){0,2}),? (?:and|or) ([\w'-]+)/gi)].map((m) => m[0].replace(/^[.:;!?,] /, ''));
if (triads.length) add('INFO', 'copy-triads', triads, `${triads.length} list${triads.length > 1 ? 's' : ''} of three (about one in ${Math.round(words / triads.length)} words)`, 'fine when there are three things; never back to back');
const sents = sentencesOf(prose);
if (sents.length >= 15) {
const lens = sents.map(wc), mean = lens.reduce((a, b) => a + b, 0) / lens.length;
const sd = Math.sqrt(lens.reduce((a, b) => a + (b - mean) ** 2, 0) / lens.length), frag = lens.filter((n) => n <= 4).length / lens.length;
add('INFO', 'copy-rhythm', 0, `${sents.length} sentences, mean ${mean.toFixed(1)} words, spread ${sd.toFixed(1)}, ${(frag * 100).toFixed(0)}% of 4 words or fewer`, '');
if (sd < 4) add('WARN', 'copy-rhythm', 0, `every sentence about ${mean.toFixed(0)} words long (spread ${sd.toFixed(1)})`, 'let length follow meaning: one sentence a section may run long');
if (frag > 0.3) add('WARN', 'copy-rhythm', 0, `${(frag * 100).toFixed(0)}% of sentences are 4 words or fewer`, 'join the fragments that carry one thought');
}
const order = { FAIL: 0, WARN: 1, INFO: 2 };
out.sort((a, b) => order[a.sev] - order[b.sev]);
return { words, headings: hText, findings: out };
}
// ---------------------------------------------------------------- across files
function crossFile(results) {
if (results.length < 2) return { heroes: [], shared: [] };
const shape = (h) => h.toLowerCase().replace(/\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d+)\b/g, '#').replace(/[^a-z# ]/g, '').trim();
// each file by its path below the folder all of them share (pages/x and pages/mobile/x are two files)
const dirs = results.map((r) => path.resolve(path.dirname(r.file)).split(path.sep));
let k = 0; while (dirs.every((d) => k < d.length && d[k] === dirs[0][k])) k++;
const name = (f) => path.relative(dirs[0].slice(0, k).join(path.sep) || path.sep, path.resolve(f)).split(path.sep).join('/');
const grams = new Map();
for (const r of results) {
const seen = new Set();
for (const h of r.headings) {
const toks = shape(h).split(/\s+/);
for (let i = 0; i + 3 <= toks.length; i++) {
const g = toks.slice(i, i + 3).join(' ');
if (/^(the|a|of|and|to|in|for|your|our|we|you)( |$)/.test(g) && /( the| a| of| and| to)$/.test(g)) continue;
if (!seen.has(g)) { seen.add(g); grams.set(g, (grams.get(g) || []).concat(name(r.file))); }
}
}
}
const shared = [...grams].filter(([, fs_]) => fs_.length >= 2).map(([g, fs_]) => `"${g}" in ${fs_.length} files: ${fs_.join(', ')}`).slice(0, 25);
return { heroes: results.map((r) => `${name(r.file)}: ${r.headings[0] || '(no heading)'}`), shared };
}
// ---------------------------------------------------------------- run
const paths = files.map((f) => { try { return fs.statSync(f).isDirectory() ? path.join(f, 'index.html') : f; } catch { return f; } });
const results = [];
for (const f of paths) { try { results.push(lint(f)); } catch (e) { die(`cannot read ${f}: ${e.message}`); } }
const cross = crossFile(results);
const failed = results.some((r) => r.findings.some((f) => f.sev === 'FAIL'));
if (asJson) console.log(JSON.stringify({ files: results, cross }, null, 1));
else {
for (const r of results) {
const n = (s) => r.findings.filter((f) => f.sev === s).length;
console.log(`\n== ${r.file} (${r.words} words) ${n('FAIL')} FAIL, ${n('WARN')} WARN`);
for (const f of r.findings) {
console.log(` ${f.sev.padEnd(4)} ${f.id.padEnd(20)} ${f.msg}`);
for (const e of f.examples) console.log(` - ${e}`);
if (f.fix && f.sev !== 'INFO') console.log(` fix: ${f.fix}`);
}
}
if (cross.heroes.length) {
console.log('\n== across files: the first headings side by side');
for (const h of cross.heroes) console.log(' ' + h);
if (cross.shared.length) { console.log('== heading phrases shared across files'); for (const s of cross.shared) console.log(' ' + s); }
}
}
process.exit(failed ? 1 : 0);
+14 -1
View File
@@ -15,6 +15,9 @@
node scripts/effects.mjs shot --url URL --out file.png --profile DIR [--w 1440 --h 900 --dpr 1
--wait 1500 --full --reduced --gpu]
node scripts/effects.mjs perf --url URL --profile DIR [--seconds 5 --w 1440 --h 900 --gpu]
(DIR: a folder of its own in the OS temp folder or the project's .taste-skill
folder, never outside the project; a relative one that leads out of the current
folder is replaced, with a warning, by .taste-skill/chrome-fx)
pick: one effect per slot (card = a card group, hero = the hero frame or a large split frame, band =
a full-width band or the closing call to action), at random, weighted against the history file's last
@@ -408,10 +411,20 @@ class CDP {
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
async function launch(a) {
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'fx-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-fx-' + process.pid);
const profile = path.resolve(a.profile && a.profile !== true ? a.profile : defaultProfile());
const profile = a.profile && a.profile !== true ? inProject(a.profile, 'fx') : path.resolve(defaultProfile());
fs.mkdirSync(profile, { recursive: true });
const portFile = path.join(profile, 'DevToolsActivePort');
try { fs.rmSync(portFile); } catch (_) { /* none */ }
+891
View File
@@ -0,0 +1,891 @@
#!/usr/bin/env node
/*
taste-skill gate.mjs - the finish gate: a build may not end while fixable FAILs remain.
node <skill>/scripts/gate.mjs --start <page.html | http://localhost:5173/> [--root dist] [--kind <kind>]
[--category "<what is sold>"] [--primary "<main action>"] [--rounds 3] [--new]
arm the gate for a build in this folder; on a build that is still open it only updates what is checked
(page, url, root, kind, category, primary) and keeps its rounds; --new starts a new build anyway
node <skill>/scripts/gate.mjs [page.html | url] [--root ..] [--kind ..] [--category ..] [--primary ..]
the manual gate: run the checks, print the open FAILs (a page or flag given here is stored for the hook)
node <skill>/scripts/gate.mjs --hook claude|codex|antigravity the same as an agent's Stop hook (stdin: the hook JSON)
[--quick] [--profile <chrome dir>]
node <skill>/scripts/gate.mjs --done "<what the user said>"
only when the user tells the agent to stop or to ship the page as it is: closes the build with their words
(status done, build.json renamed to build.done.json), so every later stop is allowed; GATE: DONE (...)
--profile is the folder the checks' Chrome profiles go under: the OS temp folder (the default) or the project's
.taste-skill folder, never outside the project; a relative one that leads out of the current folder (../prof) is
replaced, with a warning, by .taste-skill/chrome-gate.
A build is active while .taste-skill/build.json exists in the project folder (the hook's cwd, else the current
folder, or one or two folders below it), or while .taste-skill/gate.json says its rounds are still open (a deleted
build.json does not end a build). start.mjs writes the marker at a deal in a folder with no open build.
No build: exit 0 at once in hook mode, but once for a tasteskill page (one with a page-type meta, changed in the
last day, never armed or finished) in the folder or one below: it says how to arm it. A manual run says so
(GATE: OFF) and arms nothing.
The page: the one named, else the marker's page or url, else index.html, else the only .html file, else the
index.html of the only folder below that has one. A url (a dev server) and --root (a built site's folder) go
to both checkers as they are.
The checks, one at a time, each in its own headless Chrome with its own temp profile:
consistency.mjs --quick --widths 1440,390 --json (with the marker's --page kind, --category, --primary, --root;
it walks the page at 320x640 too: overflow, collide, tap-size)
assets/layout/check.mjs --sizes 1440x900,390x844 --json
and two static ones: kit-edited (a file the page loads from assets/ differs from the skill's copy, whitespace,
quotes and semicolons aside) and checker-gaming (the page's own code looks for a headless browser or an automation
tool). gaming-suspect, waivable: a user-agent test for headless, or aria-hidden stripped from many pictures.
And the copy: scripts/copy-lint.mjs --json on the page's file, or, when the file holds no sections (a built app, a
dev server), on the page as the browser built it (consistency.mjs saves it as rendered.html); copy-lint's ids (copy-slop-word,
copy-virtue, ...; the checker's copy-headline, copy-fact and the rest are its own); its FAILs
block (the old slop words, stock phrases, residue, a device used as a tic), its WARNs do not. A file whose text is a
kit file's, wherever it sits, is the kit's and is skipped. The two checkers' ids for the
same fault on the same section (section-height / screen-height / screen-short, one-section / neighbour) are one
finding. WARNs never block, except two the checker WARNs and the gate holds as FAILs: hero-enter (the hero does not
enter on load) and moment-none (the page deals no moment).
Only a local page is checked: a file, or a URL on localhost, 127.0.0.1, [::1], *.localhost or *.test.
Waivers: a FAIL judged false is waived in .taste-skill/waivers.json, an array of
{"check": "<id>", "target": "<part of where it is>", "reason": "<12+ characters>"}. A waiver covers exactly one
finding: a target that matches several is ignored and says so. A target is 3+ characters, or the whole of a
short where. Each copy word, stock phrase or residue hit is its own finding (where: the word and its sentence); a
copy device used as a tic is one finding, waived only by its whole where (its count and hits), so a word added
later is never covered. kit-edited and checker-gaming cannot be waived;
more than 5 waivers is itself a FAIL. Waivers are printed so the report can list them.
Rounds (.taste-skill/gate.json): a run whose page or waivers changed since the last round is a round, but a hook
stop before the gate's first run by hand in the build is not (it lists the FAILs and blocks; that first run by
hand counts the page's round). After a hook block, the next stop on the unchanged page is allowed once without a
round (a question to the user: the block says "just stop again"); other stops on it block twice without a round
(nothing was fixed), then each counts as one. A changed page starts that count again.
Below --rounds (3, set with --start): FAIL, fix and finish again. At the last round: OPEN, told once; every
later stop is allowed and the open FAILs go in the report. PASS and OPEN rename build.json to build.done.json.
The last line of a manual run is exactly one of:
GATE: PASS | GATE: FAIL <n> (round <k> of <max>) | GATE: OPEN <n> (rounds used up) | GATE: QUICK <n> (...)
| GATE: DONE (<the user's words>), after --done
or GATE: SETUP (...) when the armed target cannot be checked yet (no page, a framework's source shell, a URL that
is not local): arm the right one and run again; GATE: ERROR (...) when the checks themselves could not run;
GATE: OFF when no build is active.
Exit 0 PASS, OFF or DONE, 1 FAIL or OPEN, 2 ERROR, 3 SETUP.
--quick is a look between rounds: the checker at 1440 and 390 (and the 320 pass), the layout check at 1440, not a round, never closes the build (GATE: QUICK <n>).
Hook mode blocks with exit 0 and JSON on stdout: {"decision":"block","reason":...} for claude and codex,
{"decision":"continue","reason":...} for antigravity; it allows by printing nothing. It never traps an agent:
- every block is counted in .taste-skill/gate-blocks.json, which --start never clears; past rounds + 4 blocks
for one build (the blocks before the first run by hand have as many of their own), or 30 in 6 hours in the
folder, every stop is allowed;
- no page, a framework shell, or crashed checks block once each with what to do, then allow; a crashed or
timed-out run is stored for its page, so a stop on the unchanged page reuses it (one retry at most);
- after --done (the user ended the build) every stop is allowed;
- it allows when builds are active in two or more folders below (an orchestrator, not a builder), when the
build has been idle for 24 hours, in a session other than the one it first held, and when it cannot record;
- stdin is read for 2 seconds at most; the whole run stops at 9.5 minutes. Each check runs with a watchdog that
kills that check (and its Chrome) if the gate itself is killed; nothing else is ever killed.
Every hook call, an allowed one too, leaves one entry in gate.json's history: {"mode": "hook", "host", "decision":
"allow" or "block", "reason"} (with the round's status and FAIL count when it ran the checks). With no build armed it
goes in the newest gate.json in the folder or one or two below (a finished build's), else in the folder's own
.taste-skill when there is one; never in a new folder, nor in a build below that this session does not hold. An
entry alone keeps the file's time, so the 24-hour idle rule still reads the build's own last change.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const SELF = fileURLToPath(import.meta.url);
const here = path.dirname(SELF);
const SKILL = path.join(here, '..');
const GATE = `node "${SELF}"`;
const args = process.argv.slice(2);
const VALUE = new Set(['hook', 'rounds', 'profile', 'root', 'kind', 'page', 'category', 'primary', 'done']);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i < 0 ? d : (args[i + 1] !== undefined && !args[i + 1].startsWith('--') ? args[i + 1] : true); };
const str = k => { const v = opt(k); return typeof v === 'string' && v.trim() ? v.trim() : null; };
const has = k => args.includes('--' + k);
const positional = args.filter((a, i) => !a.startsWith('--') && !VALUE.has((args[i - 1] || '').replace(/^--/, '')))[0] || null;
const isUrl = s => typeof s === 'string' && /^https?:\/\//i.test(s);
// the gate opens only a page on this machine: a file, or a dev server on a local host
const isLocalUrl = s => { try { const h = new URL(s).hostname.toLowerCase(); return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(h) || /\.(localhost|test)$/.test(h); } catch { return false; } };
const NOT_LOCAL = 'the gate checks a local page or dev server only (localhost, 127.0.0.1, [::1], *.localhost, *.test)';
const HOOK = opt('hook', null);
const MODES = { claude: 'block', codex: 'block', antigravity: 'continue' };
if (HOOK !== null && !MODES[HOOK]) { console.error('gate: --hook takes claude, codex or antigravity'); process.exit(HOOK === true ? 2 : 0); }
const QUICK = has('quick'), START = has('start');
const T0 = Date.now(), BUDGET = 570000; // 9.5 min: under the 10 min a hook may take
const MAX_WAIVERS = 5, TOP = 8, IDLE = 2, EXTRA = 4, RECENT = 30, DAY = 864e5;
const KINDS = /^(tech|software|gaming|product|place|service)$/;
// ---------------------------------------------------------------- hook plumbing: never trap the agent
const children = new Set();
// the hook's trace: one history entry per hook call in gate.json (T.file), written once, on the way out
const T = { file: null, state: null, entry: null, saved: false, done: false, off: false };
function traceEntry(decision, reason) {
if (!HOOK || T.done || T.off) return null;
T.done = true;
const file = T.file || traceFile();
if (!file) return null;
let s = T.state;
if (!s) { s = readJson(file, {}); if (!s || typeof s !== 'object' || Array.isArray(s)) s = {}; }
const why = String(reason || '').split('\n')[0].replace(/\s+/g, ' ').trim().slice(0, 160);
if (T.entry) Object.assign(T.entry, { decision, reason: why });
else s.history = [...(Array.isArray(s.history) ? s.history : []), { at: new Date().toISOString(), mode: 'hook', host: HOOK, decision, reason: why, stopHookActive: !!input.stop_hook_active }].slice(-20);
return { file, s };
}
function trace(decision, reason) {
try {
const e = traceEntry(decision, reason);
if (!e) return;
let st = null;
if (!T.saved) { try { st = fs.statSync(e.file); } catch { /* a new file */ } }
writeJson(e.file, e.s);
if (st) fs.utimesSync(e.file, st.atime, st.mtime);
} catch { /* a trace never holds a stop */ }
}
// with no build armed: the newest gate.json here or one or two folders below (a finished build's), else this folder's
// .taste-skill when it exists; a new .taste-skill folder is never made for it
function traceFile() {
const found = [base, ...below(1), ...below(2)].map(d => path.join(dotOf(d), 'gate.json')).filter(f => fs.existsSync(f));
found.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
return found[0] || (fs.existsSync(dotOf(base)) ? path.join(dotOf(base), 'gate.json') : null);
}
const allow = (note, quiet) => { trace('allow', note || 'allowed'); if (note && !quiet) process.stderr.write('gate: ' + note + '\n'); process.exit(0); };
// anything unexpected in hook mode lets the agent stop: a broken gate must not hold a session
if (HOOK) for (const ev of ['uncaughtException', 'unhandledRejection']) process.on(ev, e => allow('crashed, stop allowed: ' + (e && e.message)));
// every way out takes the gate's own checks (and their Chromes) with it
process.on('exit', () => { for (const c of children) killTree(c); });
for (const s of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) { try { process.on(s, () => process.exit(HOOK ? 0 : 2)); } catch { /* not on this platform */ } }
// a host that leaves stdin open must not hold the hook until its timeout
const readStdin = ms => new Promise(res => {
if (process.stdin.isTTY) return res('');
let s = '';
const done = () => { clearTimeout(t); try { process.stdin.pause(); process.stdin.destroy(); } catch { /* closed */ } res(s); };
const t = setTimeout(done, ms);
process.stdin.setEncoding('utf8');
process.stdin.on('data', d => { s += d; if (s.length > 1e6) done(); });
process.stdin.on('end', done); process.stdin.on('error', done);
});
let input = {};
if (HOOK) { try { const o = JSON.parse((await readStdin(2000)).replace(/^\uFEFF/, '') || '{}'); input = o && typeof o === 'object' ? o : {}; } catch { input = {}; } }
const base = path.resolve(typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd());
// a PowerShell write puts a BOM before the JSON
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8').replace(/^\uFEFF/, '')); } catch { return d; } };
const writeJson = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 1) + '\n'); };
const slash = p => p.split(path.sep).join('/');
// ---------------------------------------------------------------- the project folder
const dotOf = d => path.join(d, '.taste-skill');
const OPEN_STATES = /^(active|fail|error)$/;
// build.json, or a round state still open: deleting or renaming the marker mid-build does not end the build
function activeIn(d) {
if (fs.existsSync(path.join(dotOf(d), 'build.json'))) return true;
const s = readJson(path.join(dotOf(d), 'gate.json'), null);
return !!(s && typeof s.start === 'string' && OPEN_STATES.test(s.status || ''));
}
const lastTouch = d => Math.max(0, ...['build.json', 'gate.json'].map(f => { try { return fs.statSync(path.join(dotOf(d), f)).mtimeMs; } catch { return 0; } }));
const SKIP_DIR = /^(node_modules|\.git|\.taste-skill|\.next|\.astro|\.svelte-kit|\.cache)$/;
function findProject() {
if (positional && !isUrl(positional)) { const d = path.dirname(path.resolve(base, positional)); if (activeIn(d)) return { dir: d, others: 0 }; }
if (activeIn(base)) return { dir: base, others: 0 };
// one folder below, else two (a build in site/web/ of the session's folder)
let found = below(1).filter(activeIn);
if (!found.length) found = below(2).filter(activeIn);
found.sort((a, b) => lastTouch(b) - lastTouch(a));
return { dir: found[0] || null, others: Math.max(0, found.length - 1) };
}
// the folders one or two levels below the session's folder, at most 400, never node_modules, .git or a hidden one
function below(depth) {
let level = [base];
for (let i = 0; i < depth; i++) {
const next = [];
for (const d of level) {
try { for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory() && !SKIP_DIR.test(e.name) && !(i > 0 && e.name.startsWith('.')) && next.length < 400) next.push(path.join(d, e.name)); } catch { /* unreadable folder: no build */ }
}
level = next;
}
return level;
}
// a tasteskill page (it declares its page-type) in this folder or one below, with no build armed and none finished:
// the hook says once how to arm it, so a build that skipped --start is still checked
function unarmedPage() {
for (const d of [base, ...below(1)]) {
const f = path.join(d, 'index.html');
try {
const st = fs.statSync(f);
if (st.size > 4e6 || Date.now() - st.mtimeMs > DAY) continue;
if (['build.done.json', 'unarmed.json'].some(x => fs.existsSync(path.join(dotOf(d), x)))) continue;
if (/<meta\b[^>]*\bname\s*=\s*["']?page-type\b/i.test(fs.readFileSync(f, 'utf8'))) return { dir: d, file: f };
} catch { /* no page here */ }
}
return null;
}
// ---------------------------------------------------------------- the page and what it declares
const attrs = tag => Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g)].map(m => [m[1].toLowerCase(), m[3] ?? m[4] ?? m[5]]));
function metas(html) {
const o = {};
for (const m of html.matchAll(/<meta\b[^>]*>/gi)) { const a = attrs(m[0]); if (a.name) o[a.name.toLowerCase()] = (a.content || '').trim().toLowerCase(); }
o.dataType = ((html.match(/<html\b[^>]*>/i) || [''])[0].match(/data-type\s*=\s*["']?([\w-]+)/i) || [])[1]?.toLowerCase() || '';
return o;
}
// consistency.mjs --page: the site-kind when it names a tech kind, else the page type it states
function kindOf(html) {
const m = metas(html), site = m['site-kind'] || '';
if (/^(software|gaming|tech)$/.test(site)) return site;
if (m['page-kind'] === 'place') return 'place';
const t = m['page-type'] || m.dataType;
return /^(software|product|place|service)$/.test(t) ? t : '';
}
const fileAt = f => { try { if (fs.statSync(f).isDirectory()) f = path.join(f, 'index.html'); return fs.statSync(f).isFile() ? f : null; } catch { return null; } };
// { url } or { file }, or null
function findPage(proj, marker) {
if (isUrl(positional)) return { url: positional };
if (positional) { const f = fileAt(path.resolve(base, positional)); if (f) return { file: f }; }
if (isUrl(marker.url)) return { url: marker.url };
for (const p of [marker.page, 'index.html']) { if (typeof p !== 'string' || !p) continue; const f = fileAt(path.resolve(proj, p)); if (f) return { file: f }; }
let names = [];
try { names = fs.readdirSync(proj).filter(n => /\.html?$/i.test(n) && !/^credits/i.test(n)); } catch { /* none */ }
if (names.length === 1) return { file: path.join(proj, names[0]) };
// the page one folder down (site/index.html): only when one folder has one
const subs = [];
try { for (const e of fs.readdirSync(proj, { withFileTypes: true })) if (e.isDirectory() && !SKIP_DIR.test(e.name) && !/^(src|assets|shots)$/.test(e.name)) { const f = path.join(proj, e.name, 'index.html'); if (fs.existsSync(f)) subs.push(f); } } catch { /* none */ }
return subs.length === 1 ? { file: subs[0] } : null;
}
// every local file the page loads (and what those load in turn), for the fingerprint and the static checks
const local = r => r && !/^(https?:|data:|blob:|mailto:|tel:|#|javascript:|\/\/)/i.test(r.trim());
const stripRef = r => decodeURIComponent(r.trim().split(/[?#]/)[0]);
function refsOf(file, text, root) {
const out = [], dir = path.dirname(file), ext = path.extname(file).toLowerCase();
const add = r => { if (!local(r)) return; try { const s = stripRef(r); if (s) out.push(s.startsWith('/') ? path.join(root, s) : path.resolve(dir, s)); } catch { /* bad escape */ } };
if (ext === '.html' || ext === '.htm') {
for (const m of text.matchAll(/<(link|script|img|source|video|audio|iframe|image|use)\b[^>]*>/gi)) {
const a = attrs(m[0]);
if (m[1].toLowerCase() === 'link' && !/stylesheet|modulepreload|preload|icon|manifest/i.test(a.rel || '')) continue;
for (const k of ['src', 'href', 'poster', 'xlink:href']) if (a[k]) add(a[k]);
if (a.srcset) for (const s of a.srcset.split(',')) add(s.trim().split(/\s+/)[0]);
}
for (const m of text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) if (!/\bsrc\s*=/.test(m[1])) for (const r of jsRefs(m[2])) add(r);
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) for (const r of cssRefs(m[1])) add(r);
} else if (/^\.(m?js)$/.test(ext)) for (const r of jsRefs(text)) add(r);
else if (ext === '.css') for (const r of cssRefs(text)) add(r);
return out;
}
const jsRefs = t => [...t.matchAll(/\b(?:import|export)\s*(?:[\w*{}\s,$]+\s*from\s*)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)].map(m => m[1] || m[2]).filter(r => /^\.{0,2}\//.test(r));
const cssRefs = t => [...t.matchAll(/@import\s+(?:url\()?\s*["']?([^"')\s;]+)|url\(\s*["']?([^"')]+)["']?\s*\)/g)].map(m => m[1] || m[2]);
function walk(page, root) {
const seen = new Set([page]), queue = [page];
while (queue.length && seen.size < 600) {
const f = queue.shift();
if (!/\.(html?|m?js|css)$/i.test(f)) continue;
let t; try { t = fs.readFileSync(f, 'utf8'); } catch { continue; }
for (const r of refsOf(f, t, root)) if (!seen.has(r) && fs.existsSync(r) && fs.statSync(r).isFile()) { seen.add(r); queue.push(r); }
}
return [...seen];
}
// a framework project's own source: the static checks read it, the fingerprint covers it (a dev server serves it)
const SRC_EXT = /\.(m?js|cjs|jsx|tsx?|vue|svelte|astro|css|scss|html?|json|mdx?)$/i;
function sourceFiles(proj) {
const out = [];
const go = (d, depth) => {
if (out.length >= 3000 || depth > 8) return;
let es; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
for (const e of es) {
const p = path.join(d, e.name);
if (e.isDirectory()) { if (!SKIP_DIR.test(e.name) && !/^(dist|build|out|coverage|shots|\.vercel|\.output)$/.test(e.name)) go(p, depth + 1); }
else if (SRC_EXT.test(e.name) && !/^(package-lock|pnpm-lock|yarn)\b/.test(e.name)) out.push(p);
}
};
for (const d of ['src', 'app', 'pages', 'components', 'lib', 'public']) go(path.join(proj, d), 0);
try { for (const n of fs.readdirSync(proj)) if (/\.(html?|m?[jt]s|json)$/i.test(n) && !/lock/i.test(n)) out.push(path.join(proj, n)); } catch { /* none */ }
return out;
}
// ---------------------------------------------------------------- the kit, by content
// a format-on-save pass changes whitespace, quotes and semicolons, never what a kit file does
const norm = t => t.replace(/^\uFEFF/, '').replace(/\s+/g, '').replace(/[`']/g, '"').replace(/[;,]/g, '');
const hashOf = t => crypto.createHash('sha1').update(norm(t)).digest('hex');
let KIT = null;
function kitIndex() {
if (KIT) return KIT;
KIT = new Map();
const go = d => { let es; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of es) { const p = path.join(d, e.name); if (e.isDirectory()) go(p); else if (/\.(m?js|css)$/i.test(e.name)) { try { KIT.set(hashOf(fs.readFileSync(p, 'utf8')), p); } catch { /* unreadable */ } } } };
go(path.join(SKILL, 'assets'));
return KIT;
}
// a project file's counterpart in the kit: the path after its first assets/ folder
function kitTwin(file, root) {
const parts = path.relative(root, file).split(path.sep), i = parts.indexOf('assets');
if (i < 0) return null;
const twin = path.join(SKILL, 'assets', ...parts.slice(i + 1));
return fs.existsSync(twin) && fs.statSync(twin).isFile() ? twin : null;
}
// ---------------------------------------------------------------- the static checks
// the code without its strings and comments: a code sample in a string is not the page testing for a tool
function codeOnly(t) {
let out = '', i = 0, prev = '';
while (i < t.length) {
const c = t[i], n = t[i + 1];
if (c === '/' && n === '/') { const j = t.indexOf('\n', i); i = j < 0 ? t.length : j; continue; }
if (c === '/' && n === '*') { const j = t.indexOf('*/', i + 2); i = j < 0 ? t.length : j + 2; out += ' '; continue; }
if (c === '"' || c === "'" || c === '`') {
let j = i + 1;
while (j < t.length && t[j] !== c) { if (t[j] === '\\') j++; else if (c !== '`' && t[j] === '\n') break; j++; }
out += c + c; i = j + 1; prev = c; continue;
}
// a regex literal stays (it is code), but its quotes must not open a string
if (c === '/' && (/^$|[(,=:[!&|?{};+\-*%<>~^]$/.test(prev) || /\b(return|typeof|case)\s*$/.test(out.slice(-8)))) {
let j = i + 1, cls = false;
while (j < t.length && t[j] !== '\n' && (cls || t[j] !== '/')) { if (t[j] === '\\') j++; else if (t[j] === '[') cls = true; else if (t[j] === ']') cls = false; j++; }
out += t.slice(i, j + 1); i = j + 1; prev = '/'; continue;
}
out += c; if (!/\s/.test(c)) prev = c; i++;
}
return out;
}
const HOOKS = /\bnavigator\s*\.\s*webdriver\b|\b(__playwright\w*|__pw_\w+|__puppeteer\w*|__selenium\w*|__webdriver\w*|__nightmare|callPhantom|_phantom|domAutomation\w*|__cc\w*)\b/;
const HOOKS_RAW = /navigator\s*\[\s*["'`]webdriver["'`]\s*\]/;
const UA = /userAgent[\s\S]{0,200}?(HeadlessChrome|headless|webdriver|puppeteer|playwright|lighthouse)|(HeadlessChrome|Headless|Lighthouse)[\s\S]{0,200}?userAgent/i;
const STRIP = /removeAttribute\(\s*[`'"]aria-hidden|setAttribute\(\s*[`'"]aria-hidden[`'"]\s*,\s*(?:[`'"]false|false\b)|\.ariaHidden\s*=\s*(?:null|false|[`'"]false)|toggleAttribute\(\s*[`'"]aria-hidden/;
const VENDOR = /\.min\.m?js$|[\\/](vendor|vendors|third[-_]party)[\\/]/i;
function staticChecks(page, html, proj, files) {
const F = [];
const rel = f => slash(path.relative(proj, f));
const kit = kitIndex(), kitFiles = new Set([...kit.values()].map(p => path.resolve(p)));
const own = [];
for (const f of files) {
if (kitFiles.has(path.resolve(f))) continue; // the gate run inside the skill itself
let text; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
if (/\.(m?js|css)$/i.test(f)) {
if (kit.has(hashOf(text))) continue; // a kit file, unchanged, wherever it sits
const twin = kitTwin(f, proj);
if (twin) {
F.push({ check: 'kit-edited', where: rel(f), msg: `${rel(f)} is not this version's kit copy (edited, or copied from an older version)`, fix: `copy it back from <skill>/assets/${slash(path.relative(path.join(SKILL, 'assets'), twin))} (from this skill version, also when it came from an older one) and make the change in the page's own CSS or markup`, hard: true });
continue;
}
}
if (/\.(m?js|cjs|jsx|tsx?|vue|svelte|astro)$/i.test(f) && !VENDOR.test(f) && text.length < 400000) own.push({ where: rel(f), text });
}
if (html) for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) if (!/\bsrc\s*=/.test(m[1]) && !/json|template|text\/(?!javascript)/i.test(m[1])) own.push({ where: rel(page) + ' (inline script)', text: m[2] });
for (const s of own) {
const code = codeOnly(s.text), m = code.match(HOOKS) || s.text.match(HOOKS_RAW);
if (m) F.push({ check: 'checker-gaming', where: s.where, msg: `the page's own code tests for ${m[0].replace(/\s+/g, '')}: the page must behave the same for the checker as for a visitor`, fix: 'delete that branch; fix what the check found instead', hard: true });
else if (UA.test(s.text)) F.push({ check: 'gaming-suspect', where: s.where, msg: 'the page\'s own script reads the user agent near "headless" or an automation tool\'s name', fix: 'delete the user-agent test; a visitor and the checker get the same page' });
// a menu or tabs toggle aria-hidden on their own panels; stripping it from every picture hides them from the checks
for (const q of s.text.matchAll(/querySelectorAll\(\s*[`'"]([^`'"]*)[`'"]/g)) {
if (!/aria-hidden|\b(img|svg|picture|canvas|figure|video)\b|still|render/i.test(q[1]) || /role|tab|menu|dialog|modal|drawer|accordion|panel|nav/i.test(q[1])) continue;
if (STRIP.test(s.text.slice(q.index, q.index + 240))) { F.push({ check: 'gaming-suspect', where: s.where, msg: `the page's own script strips aria-hidden from every ${q[1].slice(0, 40)} at run time`, fix: 'delete it; a decorative picture keeps aria-hidden, a picture that matters loses it in the markup' }); break; }
}
}
return F;
}
// ---------------------------------------------------------------- the copy: copy-lint.mjs on the words the page shows
// a FAIL blocks like any other (copy-slop-word, copy-stock-phrase, copy-residue, a device past twice its budget);
// a WARN is listed and never blocks. A page file with sections is linted as written; a dev server's page, or a built
// app whose file is a shell, is linted as the browser built it (rendered.html, saved by consistency.mjs); with neither
// the copy is listed as not checked, so the run cannot PASS.
const HAS_COPY = /<(main|section|h1)\b/i;
function copyChecks(P, kind, html, rendered) {
const res = { fails: [], warns: [], crashed: [] };
let file = P.file && HAS_COPY.test(html) ? P.file : null;
if (!file) {
let t = ''; try { t = fs.readFileSync(rendered, 'utf8'); } catch { /* the checker did not save it */ }
if (!HAS_COPY.test(t)) { res.crashed.push('the copy (the page showed no <main>, <section> or <h1> to lint)'); return res; }
file = rendered;
}
const r = spawnSync(process.execPath, [path.join(here, 'copy-lint.mjs'), file, '--json', ...(kind ? ['--kind', kind] : [])], { encoding: 'utf8', timeout: 30000, windowsHide: true, maxBuffer: 1 << 24 });
const rep = parseJson(r.stdout || '');
if (r.error || !rep || !Array.isArray(rep.files) || !rep.files[0]) { res.crashed.push(`copy-lint.mjs could not run (exit ${r.status}${errLine(r.stderr || (r.error && r.error.message))})`); return res; }
// a word or a phrase is one finding per hit, named by the word and its sentence, so a waiver covers that one hit and
// never a word added later; a device used as a tic is one finding whose where carries its count and its hits, and a
// waiver must give that whole where (a new hit changes it, and the waiver no longer matches)
const PER_HIT = /^copy-(slop-word|stock-phrase|residue|unread)$/;
const q = (s, n) => { s = String(s || '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 3) + '...' : s; };
for (const f of rep.files[0].findings || []) {
if (f.sev !== 'FAIL' && f.sev !== 'WARN') continue;
const base = { check: f.id, msg: f.msg || '', fix: (f.fix || '') + ' (references/copy-tells.md)', widths: [], from: 'copy' };
const list = f.sev === 'FAIL' ? res.fails : res.warns;
const hits = Array.isArray(f.hits) ? f.hits.filter((h) => h && (h.match || h.sentence)) : [];
if (PER_HIT.test(f.id) && hits.length) {
for (const h of hits) { const m = q(h.match, 28); list.push({ ...base, where: `copy "${m}" in "${q(h.sentence, Math.max(24, 66 - m.length))}"` }); }
continue;
}
const ex = hits.length ? [...new Set(hits.map((h) => q(h.match === h.sentence ? h.sentence : h.match, 24)))] : (f.examples || []).filter((x) => typeof x === 'string' && x.trim()).map((x) => q(x, 24));
const where = ex.length ? q(`copy x${f.count || ex.length}: ${ex.map((x) => `"${x}"`).join(', ')}`, 80) : '(the copy)';
list.push({ ...base, where, exact: f.sev === 'FAIL' && ex.length > 0 });
}
return res;
}
// ---------------------------------------------------------------- the browser checks, one at a time, own children only
function killTree(c) {
if (!c || c.exitCode !== null || c.signalCode) return;
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(c.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
else { try { process.kill(-c.pid, 'SIGKILL'); } catch { try { c.kill('SIGKILL'); } catch { /* gone */ } } }
}
// a watchdog per check: it holds a pipe from the gate, and when the gate dies without saying "ok" (a host killed the
// hook) it kills that check's tree and removes its profile; Windows does not take a killed process's children with it
const WATCHDOG = `let ok=false;const fin=()=>{if(ok)process.exit(0);const[pid,...dirs]=process.argv.slice(1);const cp=require('child_process'),fs=require('fs');
try{if(process.platform==='win32')cp.spawnSync('taskkill',['/pid',pid,'/T','/F'],{stdio:'ignore',windowsHide:true});else process.kill(-Number(pid),'SIGKILL');}catch{}
setTimeout(()=>{for(const d of dirs){try{fs.rmSync(d,{recursive:true,force:true});}catch{}}process.exit(0);},1500);};
process.stdin.on('data',d=>{if(String(d).includes('ok'))ok=true;});process.stdin.on('end',fin);process.stdin.on('error',fin);process.stdin.resume();`;
const left = () => BUDGET - (Date.now() - T0);
function run(script, argv, cwd, limit, dirs) {
return new Promise(res => {
const ms = Math.max(5000, Math.min(limit, left() - 10000));
const c = spawn(process.execPath, [script, ...argv], { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: process.platform !== 'win32' });
children.add(c);
let wd = null;
try { wd = spawn(process.execPath, ['-e', WATCHDOG, String(c.pid), ...dirs], { stdio: ['pipe', 'ignore', 'ignore'], detached: true, windowsHide: true }); wd.on('error', () => {}); wd.stdin.on('error', () => {}); wd.unref(); } catch { wd = null; }
const settle = () => { try { wd && wd.stdin.end('ok'); } catch { /* gone */ } };
let out = '', err = '', timedOut = false;
c.stdout.on('data', d => { out += d; });
c.stderr.on('data', d => { err += d; });
const t = setTimeout(() => { timedOut = true; killTree(c); }, ms);
c.on('error', e => { clearTimeout(t); children.delete(c); settle(); res({ code: 2, out, err: err + e.message, timedOut }); });
c.on('close', code => { clearTimeout(t); children.delete(c); settle(); res({ code: code ?? 2, out, err, timedOut }); });
});
}
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
const profileBase = () => path.resolve(typeof opt('profile') === 'string' ? inProject(opt('profile'), 'gate') : process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome') : os.tmpdir());
const freshProfile = name => { const d = path.join(profileBase(), `gate-${process.pid}-${name}-${Date.now().toString(36)}`); fs.mkdirSync(d, { recursive: true }); return d; };
// Chrome's helpers can hold the profile's files for a moment after it exits
const rmProfile = d => { for (let i = 0; i < 20; i++) { try { fs.rmSync(d, { recursive: true, force: true }); return; } catch { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250); } } };
const parseJson = t => { try { return JSON.parse(t); } catch { const i = t.indexOf('{'), j = t.indexOf('['); const k = [i, j].filter(x => x >= 0).sort((a, b) => a - b)[0]; try { return k >= 0 ? JSON.parse(t.slice(k)) : null; } catch { return null; } } };
// the line of a child's stderr that says what went wrong: Node's own "XError: ..." line before a quoted source line
const errLine = t => { const l = String(t || '').split('\n').map(x => x.trim()).filter(Boolean); const e = l.find(x => /^\w*Error\b:/.test(x)) || l.find(x => /could not|did not|no such|timed out|not found/i.test(x)) || l[0]; return e ? ': ' + e.slice(0, 160) : ''; };
const LAYOUT_FIX = {
'bleed-one-side': 'bleed the visual to both edges or keep it on the .wrap lines', spill: 'keep it inside the .wrap; nothing past the right edge',
'edge-left': 'start it on the .wrap left line', 'edge-right': 'end it on the .wrap right line', 'one-sided': 'let the row reach both .wrap edges',
lines: 'put the block on the .wrap lines or the grid columns', 'screen-short': 'make the section one full screen (--h: 1, layout-v4.md section 2)',
'screen-height': 'make the section exactly one or two screens (--h: 1 or 2)', sparse: 'fill the section or make it a screen shorter',
neighbour: "keep the next section's heading and picture out of this screen", 'neighbour-ground': 'end the ground with the section',
'hero-fold': 'the hero fills the first screen alone, its button inside it', 'hero-maxchars': 'shorten the h1 or give the hero room for the longest headline',
'hero-lines': "the check fills the h1 to this hero's maxChars, not your own: take the next type pairing the deal printed (start.mjs); when the h1 is final and fits, waive it (target: section.hero h1)", 'overflow-x': 'remove what scrolls sideways', container: 'put the content in one .wrap',
};
// two house rules the checker only WARNs (its own kit demos carry neither) and the gate holds: every hero enters
// once on load, and every page deals a moment (SKILL.md house rules 5 and 8)
const HELD = new Set(['hero-enter', 'moment-none']);
async function browserChecks(target, proj, opts, gateDir) {
const res = { fails: [], warns: [], crashed: [], seconds: {} };
// 390 always, the quick look too (the phone is where sections run into each other); the 320 pass (overflow, collide, tap-size) comes with it
const widths = '1440,390', sizes = QUICK ? '1440x900' : '1440x900,390x844';
const root = opts.root && !isUrl(target) ? ['--root', opts.root] : [];
// consistency.mjs, with what step 6 passes it
{
const prof = freshProfile('consistency'), out = path.join(gateDir, 'consistency'), t = Date.now();
try { fs.rmSync(path.join(out, 'rendered.html'), { force: true }); } catch { /* none */ }
const argv = [target, '--quick', '--widths', widths, '--json', '--profile', prof, '--out', out, ...root,
...(opts.kind ? ['--page', opts.kind] : []), ...(opts.category ? ['--category', opts.category] : []), ...(opts.primary ? ['--primary', opts.primary] : [])];
const r = await run(path.join(here, 'consistency.mjs'), argv, proj, 420000, [prof]);
rmProfile(prof); res.seconds.consistency = Math.round((Date.now() - t) / 1000);
const rep = parseJson(r.out) || readJson(path.join(out, 'report.json'), null);
if (r.timedOut) res.crashed.push('consistency.mjs timed out');
else if (!rep || !Array.isArray(rep.widths)) res.crashed.push(`consistency.mjs gave no report (exit ${r.code}${errLine(r.err)})`);
else {
if (r.code === 2 || (rep.notCheckedAt || []).length) res.crashed.push(`consistency.mjs could not walk ${(rep.notCheckedAt || []).join(', ') || 'every width'}`);
const lists = [...rep.widths.map(w => w.findings.map(f => ({ ...f, width: w.width }))), ...(rep.sweep ? [rep.sweep.findings] : []), ...(rep.narrow ? [rep.narrow.findings] : [])];
for (const f of lists.flat()) {
const item = { check: f.check, where: (f.sel || '(the page)') + (f.text ? ` "${String(f.text).slice(0, 40)}"` : ''), msg: f.msg || '', fix: f.fix || '', widths: [f.width].filter(Boolean), from: 'consistency' };
(f.sev === 'FAIL' || HELD.has(f.check) ? res.fails : res.warns).push(item);
}
}
}
// check.mjs: every finding fails
if (left() > 60000) {
const prof = freshProfile('layout'), t = Date.now();
const r = await run(path.join(SKILL, 'assets', 'layout', 'check.mjs'), ['--url', target, '--profile', prof, '--sizes', sizes, '--json', ...root], proj, 300000, [prof]);
rmProfile(prof); res.seconds.layout = Math.round((Date.now() - t) / 1000);
const rep = parseJson(r.out);
if (r.timedOut) res.crashed.push('check.mjs timed out');
else if (r.code === 2 || !Array.isArray(rep)) res.crashed.push(`check.mjs could not run (exit ${r.code}${errLine(r.err)})`);
else for (const s of rep) for (const f of s.findings || []) res.fails.push({ check: f.rule, where: f.where, msg: f.detail || '', fix: LAYOUT_FIX[f.rule] || 'layout-v4.md', widths: [Number(String(s.size).split('x')[0])], from: 'layout' });
} else res.crashed.push('no time left for check.mjs');
return res;
}
// one finding per check and place, with every width it showed at
function merge(list) {
const m = new Map();
for (const f of list) {
const k = f.check + '|' + f.where;
if (m.has(k)) { const g = m.get(k); for (const w of f.widths || []) if (!g.widths.includes(w)) g.widths.push(w); } else m.set(k, { ...f, widths: [...(f.widths || [])] });
}
return [...m.values()];
}
// the two checkers name one fault of one section twice: one finding, one waiver
const FAMILY = { 'section-height': 'height', 'screen-height': 'height', 'screen-short': 'height', 'one-section': 'neighbour', neighbour: 'neighbour' };
const sectionId = w => (String(w).split('"')[0].match(/#([\w-]+)/) || [])[1];
function mergeFamilies(list) {
const out = [], by = new Map();
for (const f of list) {
const fam = FAMILY[f.check], id = fam && sectionId(f.where), k = id ? fam + '#' + id : null;
const g = k && by.get(k);
if (g) {
if (!g.ids.includes(f.check)) g.ids.push(f.check);
if (!g.wheres.includes(f.where)) g.wheres.push(f.where);
for (const w of f.widths || []) if (!g.widths.includes(w)) g.widths.push(w);
continue;
}
const n = { ...f, ids: [f.check], wheres: [f.where], widths: [...(f.widths || [])] };
out.push(n); if (k) by.set(k, n);
}
return out;
}
// ---------------------------------------------------------------- waivers: one finding each
function applyWaivers(fails, file) {
const raw = fs.existsSync(file) ? readJson(file, undefined) : [];
const out = { open: [], waived: [], waivers: [], problems: [] };
if (raw === undefined || !Array.isArray(raw)) {
out.problems.push({ check: 'waivers', where: '.taste-skill/waivers.json', msg: 'waivers.json is not a JSON array', fix: 'write it as [{"check": "<id>", "target": "<part of where>", "reason": "<why it is false>"}]', hard: true });
out.open = fails; return out;
}
out.waivers = raw.map(w => ({ check: String(w && w.check || ''), target: String(w && w.target || ''), reason: String(w && w.reason || '').trim() }));
if (out.waivers.length > MAX_WAIVERS) {
out.problems.push({ check: 'waivers', where: '.taste-skill/waivers.json', msg: `too many waivers: fix instead (${out.waivers.length}, ${MAX_WAIVERS} at most)`, fix: `fix the findings and keep ${MAX_WAIVERS} waivers at most`, hard: true });
out.open = fails; return out;
}
const taken = new Map();
const ids = f => f.ids || [f.check], wheres = f => f.wheres || [f.where];
for (const w of out.waivers) {
if (!w.check) { w.invalid = 'no check id'; continue; }
// a short target is taken only when it is the whole of a finding's where (a layout finding at "h1")
if (w.target.length < 3 && !fails.some(f => ids(f).includes(w.check) && wheres(f).includes(w.target))) { w.invalid = 'target under 3 characters'; continue; }
if (w.reason.length < 12) { w.invalid = 'reason under 12 characters'; continue; }
// a copy tic (copy-lint's count and hits in its where) is waived only by its whole where
const all = fails.filter(f => ids(f).includes(w.check) && wheres(f).some(x => (f.exact ? x === w.target : x.includes(w.target))));
const hits = all.filter(f => !f.hard);
if (!hits.length) { w.invalid = all.length ? `${w.check} cannot be waived` : fails.some(f => f.exact && ids(f).includes(w.check) && wheres(f).some(x => x.includes(w.target))) ? 'a copy tic is waived only by its whole where, as the gate prints it' : 'matches no open finding'; continue; }
// a target that names several findings would wave a whole check through: it must name one
const exact = hits.filter(f => wheres(f).includes(w.target));
const one = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : null;
if (!one) { w.invalid = `target matches ${hits.length} findings; name one (more of its where)`; continue; }
if (!taken.has(one)) taken.set(one, w);
}
for (const f of fails) { const w = taken.get(f); if (w) out.waived.push({ ...f, reason: w.reason }); else out.open.push(f); }
return out;
}
// ---------------------------------------------------------------- run
const proj = START ? null : findProject();
if (!START && !proj.dir) {
if (HOOK) {
const u = input.stop_hook_active ? null : unarmedPage();
if (!u) allow('no build is active here', true);
// told once per folder: unarmed.json never arms a build
try { writeJson(path.join(dotOf(u.dir), 'unarmed.json'), { page: slash(path.relative(u.dir, u.file)), at: new Date().toISOString() }); } catch { allow('an unarmed page, but its .taste-skill cannot be written', true); }
const rel = slash(path.relative(base, u.file)) || 'index.html';
T.file = path.join(dotOf(u.dir), 'gate.json'); trace('block', `${rel} is a tasteskill page with no build armed: told how to arm it`);
process.stdout.write(JSON.stringify({ decision: MODES[HOOK], reason: `tasteskill gate: ${rel} is a tasteskill page, but no build is armed, so it was never checked. Run ${GATE} --start ${rel}, then ${GATE}, and fix what it lists. If you are stopping to ask the user something, or this page is not one you are building, just stop again.` }));
process.exit(0);
}
// never arm a folder by accident: only --start (or start.mjs) begins a build
console.log(`gate: no build is active in ${base} (no .taste-skill/build.json); begin one with: ${GATE} --start index.html`);
console.log('GATE: OFF (no build active)'); process.exit(0);
}
// builds in two or more folders below: this session runs them (an orchestrator), it does not build one
if (HOOK && proj.others) { T.file = fs.existsSync(dotOf(base)) ? path.join(dotOf(base), 'gate.json') : null; T.off = !T.file; }
if (HOOK && proj.others) allow(`builds are active in ${proj.others + 1} folders below ${base}; not holding this session`);
const projDir = START ? (positional && !isUrl(positional) && activeIn(path.dirname(path.resolve(base, positional))) ? path.dirname(path.resolve(base, positional)) : base) : proj.dir;
const dot = dotOf(projDir);
const markerFile = path.join(dot, 'build.json'), stateFile = path.join(dot, 'gate.json'), ledgerFile = path.join(dot, 'gate-blocks.json');
const waiverFile = path.join(dot, 'waivers.json'), gateDir = path.join(dot, 'gate');
T.file = stateFile;
const roundsArg = () => { const r = opt('rounds'); return typeof r === 'string' && /^\d+$/.test(r) ? Math.max(1, Math.min(10, Number(r))) : 3; };
const kindArg = () => { const k = (str('kind') || str('page') || '').toLowerCase(); return KINDS.test(k) ? k : null; };
// what --start and a manual run may set on the marker; --rounds only on a new build
function applyFlags(m) {
if (positional) {
if (isUrl(positional)) { m.url = positional; m.page = null; }
else { const f = fileAt(path.resolve(base, positional)); m.page = slash(path.relative(projDir, f || path.resolve(base, positional))); m.url = null; }
}
if (str('root')) m.root = slash(path.relative(projDir, path.resolve(base, str('root')))) || '.';
if (kindArg()) m.kind = kindArg();
for (const k of ['category', 'primary']) if (str(k)) m[k] = str(k);
return m;
}
// --start arms the gate: a new build, or new targets for the one still open
if (START) {
if (isUrl(positional) && !isLocalUrl(positional)) { console.log(`gate: ${positional}: ${NOT_LOCAL}; nothing armed`); console.log('GATE: SETUP (not a local page)'); process.exit(3); }
const old = readJson(markerFile, null), st = readJson(stateFile, null);
const live = !!(old && typeof old === 'object' && typeof old.start === 'string' && !(st && st.start === old.start && /^(pass|open|done)$/.test(st.status || '')));
const keep = live && !has('new');
const m = applyFlags(keep ? { ...old } : { page: null, url: null, root: null, kind: null, category: null, primary: null, rounds: roundsArg(), start: new Date().toISOString(), by: 'gate.mjs --start' });
if (!m.kind) { const p = findPage(projDir, m); if (p && p.file) { try { m.kind = kindOf(fs.readFileSync(p.file, 'utf8')) || null; } catch { /* unreadable */ } } }
writeJson(markerFile, m);
const what = m.url || m.page || '(the page, found when checked)';
if (keep) console.log(`gate: a build is already open here (round ${st && st.start === old.start ? st.round || 0 : 0} of ${old.rounds || 3}); now checking ${what}${m.root ? ` with --root ${m.root}` : ''}. Its rounds stay${opt('rounds') !== undefined ? ' (--rounds is set only for a new build: --new)' : ''}.`);
else console.log(`gate: build started for ${what}${m.kind ? ` (${m.kind})` : ''}, ${m.rounds} round${m.rounds > 1 ? 's' : ''}; finish with: ${GATE}`);
process.exit(0);
}
let state = readJson(stateFile, null);
if (!state || typeof state !== 'object' || Array.isArray(state)) state = null;
let marker = readJson(markerFile, null), recovered = false;
if (!marker || typeof marker !== 'object' || typeof marker.start !== 'string') {
// build.json gone or broken mid-build: the open round state carries the build on
if (state && typeof state.start === 'string' && OPEN_STATES.test(state.status || '')) { marker = { ...(state.marker || {}), start: state.start }; recovered = true; }
else {
// a marker with no start cannot be told apart from the next build's, so its rounds could never end
if (HOOK) allow('.taste-skill/build.json is unreadable or has no start; stop allowed');
console.log(`gate: ${markerFile} is unreadable or has no "start"; arm the build again: ${GATE} --start <page.html>`);
console.log('GATE: ERROR (build.json unreadable)'); process.exit(2);
}
}
if (!state || (state.start ?? null) !== marker.start) state = { start: marker.start, round: 0, idle: 0, errors: 0, status: 'active', history: [] };
T.state = state;
const MAX = Math.max(1, Math.min(10, Math.floor(Number(marker.rounds)) || 3));
state.max = MAX;
const save = () => { state.at = new Date().toISOString(); writeJson(stateFile, state); T.saved = true; };
if (!HOOK && !QUICK) {
if (opt('rounds') !== undefined) console.log('gate: --rounds is set when a build starts (--start --rounds N); ignored here');
// a page or flag named here is what the hook checks too
if (positional || str('root') || kindArg() || str('category') || str('primary')) { marker = applyFlags({ ...marker }); if (!recovered) { try { writeJson(markerFile, marker); } catch { /* read-only: this run only */ } } }
}
state.marker = marker;
// a hook stop before the gate's first run by hand in this build: never a round, and its blocks have a ledger of their own
const early = !!HOOK && !state.manual;
if (recovered && !HOOK) console.log('gate: build.json is missing or broken, but the rounds in gate.json are still open: the build goes on');
// --done: the user ended the build (stop now, or ship it as it is). Their words are recorded, the marker is renamed and
// every later stop is allowed. Never the agent's own call: an agent that wants to stop fixing has its rounds
if (has('done') && !HOOK) {
const why = str('done');
if (!why || why.length < 12) { console.error(`gate: --done needs the user's words, 12+ characters: ${GATE} --done "<what they said>"`); process.exit(2); }
const left = Array.isArray(state.open) ? state.open.length : null;
Object.assign(state, { status: 'done', doneReason: why });
state.history = [...(state.history || []), { at: new Date().toISOString(), mode: 'done', status: 'done', reason: why.slice(0, 160), round: state.round || 0 }].slice(-20);
try { save(); } catch (e) { console.error(`gate: could not write ${stateFile}: ${e.message}`); process.exit(2); }
try { fs.renameSync(markerFile, path.join(dot, 'build.done.json')); } catch { try { fs.copyFileSync(markerFile, path.join(dot, 'build.done.json')); fs.rmSync(markerFile, { force: true }); } catch { /* gate.json's status ends it */ } }
console.log(`gate: build closed at the user's word, after round ${state.round || 0} of ${MAX}${left ? `; ${left} FAIL${left > 1 ? 's were' : ' was'} open at the last run (in ${slash(path.relative(base, stateFile))})` : left === 0 ? '' : '; the gate never ran on it'}. Every later stop is allowed. Put this line, the user's words and any open FAILs in your report.`);
console.log(`GATE: DONE (${why.replace(/\s+/g, ' ').slice(0, 100)})`); process.exit(0);
}
// every block counts here, outside gate.json, and --start never clears it
const readLedger = () => { const l = readJson(ledgerFile, []); return Array.isArray(l) ? l.filter(b => b && typeof b.at === 'string') : []; };
function block(reason) {
const L = readLedger(); L.push({ at: new Date().toISOString(), start: marker.start, ...(early ? { early: true } : {}) });
if (typeof input.session_id === 'string' && !state.session) state.session = input.session_id;
traceEntry('block', reason.replace(/^tasteskill gate[:,]?\s*/, ''));
try { writeJson(ledgerFile, L.slice(-100)); save(); } catch (e) { allow('could not record the block (' + e.message + '); stop allowed'); }
process.stdout.write(JSON.stringify({ decision: MODES[HOOK], reason })); process.exit(0);
}
if (HOOK) {
// the session the gate first held is the building one; another session in this project is not held
if (state.session && typeof input.session_id === 'string' && input.session_id !== state.session) allow('a build is active here, held by another session; stop allowed');
// rounds used up and told, or the build closed at the user's word (--done): every later stop is allowed
if (state.status === 'open' && state.openTold) allow('rounds used up and told; every later stop is allowed', true);
if (state.status === 'done') allow('the user ended this build (gate.mjs --done); stop allowed', true);
if (Date.now() - lastTouch(projDir) > DAY) allow('the build has been idle over 24 hours; stop allowed (gate.mjs --start re-arms it)');
const L = readLedger(), now = Date.now();
// the blocks before the first run by hand count apart, so questions mid-build never use up the finish's blocks
const mine = L.filter(b => b.start === marker.start && !!b.early === early).length, recent = L.filter(b => now - Date.parse(b.at) < 6 * 3600e3).length;
if (mine >= MAX + EXTRA || recent >= RECENT) allow(`blocked ${mine} times for this build${early ? ' before the gate was run by hand' : ''} (${recent} in 6 hours here); stop allowed`);
}
const print = HOOK ? () => {} : (...a) => console.log(...a);
const HOW = `${GATE} --start <page.html> (a framework build: --start http://localhost:<port>/ with its dev server running, or --start dist/index.html --root dist after a build), then ${GATE}`;
const P = findPage(projDir, marker);
let html = '', setup = null;
if (!P) setup = `no page was found in ${projDir}`;
else if (P.url && !isLocalUrl(P.url)) setup = `${P.url} is not local: ${NOT_LOCAL}`;
else if (P.file) {
try { html = fs.readFileSync(P.file, 'utf8'); } catch { html = ''; }
const rp = slash(path.relative(projDir, P.file));
const srcs = [...html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)/gi)].map(m => m[1]);
if (srcs.some(s => /\.(jsx|tsx|ts|vue|svelte)([?#]|$)|^\/?src\//i.test(s))) setup = `${rp} is a framework's source shell (its sections exist only once it is served or built)`;
// an agent that stops on a question before the page has sections
else if (HOOK && !/<main\b|<section\b/i.test(html) && !/<script\b[^>]*type\s*=\s*["']?module|<div\b[^>]*\bid\s*=\s*["']?(root|app|__next|svelte)\b/i.test(html)) setup = `${rp} has no <main> or <section> yet`;
}
if (setup) {
if (HOOK) {
if (state.setupTold) allow(setup + '; told once, stop allowed');
state.setupTold = true;
block(`tasteskill gate: a build is active in ${projDir}, but ${setup}, so nothing was checked. If you are stopping to ask the user something, just stop again. Otherwise run ${HOW}, and fix what it lists.`);
}
// not a crash: the armed target is wrong or not there yet; arm the right one and run the gate again
console.log(`gate: ${setup}. Run ${HOW}.`);
console.log(`GATE: SETUP (${!P ? 'no page' : P.url ? 'not a local page' : 'the page cannot be checked as a file'}; arm the right target and run again)`); process.exit(3);
}
const target = P.url || P.file;
const relPage = P.url || slash(path.relative(projDir, P.file));
const root = typeof marker.root === 'string' && marker.root ? path.resolve(projDir, marker.root) : null;
const kind = (html && kindOf(html)) || (KINDS.test(marker.kind || '') ? marker.kind : '') || '';
const opts = { kind, root, category: typeof marker.category === 'string' ? marker.category : '', primary: typeof marker.primary === 'string' ? marker.primary : '' };
const walked = P.file ? walk(P.file, root || path.dirname(P.file)) : [];
const files = [...new Set([...walked, ...(P.url || fs.existsSync(path.join(projDir, 'package.json')) ? sourceFiles(projDir) : [])])];
// the page, what it loads and what the checks are told; waivers are applied after the checks, so a new waiver needs no new run
const fp = crypto.createHash('sha1');
fp.update(JSON.stringify({ target, opts }));
for (const f of files) {
try { const st = fs.statSync(f); fp.update(f + '\0' + (/\.(html?|m?js|css|jsx|tsx?|vue|svelte|astro)$/i.test(f) && st.size < 2e6 ? fs.readFileSync(f) : `${st.size}:${st.mtimeMs}`)); } catch { /* gone */ }
}
const FP = fp.digest('hex').slice(0, 16) + (QUICK ? '-quick' : '');
let waiverText = ''; try { waiverText = fs.readFileSync(waiverFile, 'utf8'); } catch { /* none */ }
const ROUND_KEY = FP + ':' + crypto.createHash('sha1').update(waiverText).digest('hex').slice(0, 8);
const errorReason = what => `tasteskill gate: the checks could not run (${what}), so the page was not fully checked. Do not rerun them: put "GATE: ERROR (${what})" and what was not checked in your report, then finish.`;
// the whole run has a budget: past it, our children die; the first time the agent is told to report it
const guard = setTimeout(() => {
for (const c of children) killTree(c);
state.errors = (state.errors || 0) + 1; state.note = 'the checks ran past 9.5 minutes';
// stored as this page's crashed result, so a stop on the same page counts it toward "crashed twice" and is not run again
state.fp = FP; state.found = { fails: [], warns: [], crashed: ['the checks ran past 9.5 minutes'], seconds: {} };
if (HOOK && !state.errorTold) { state.errorTold = true; block(errorReason('the checks ran past 9.5 minutes')); }
try { save(); } catch { /* best effort */ }
if (HOOK) allow('checks timed out; stop allowed');
console.log('GATE: ERROR (the checks ran past 9.5 minutes)'); process.exit(2);
}, BUDGET);
let found, reused = false;
if (state.fp === FP && state.found && Array.isArray(state.found.crashed) && !state.found.crashed.length) { found = state.found; reused = true; }
else if (state.fp === FP && state.found && Array.isArray(state.found.crashed) && (state.errors >= 2 || (HOOK && state.errorTold))) {
// the checks crashed twice on this very page, or crashed on it and the agent was told to report it: do not spend
// minutes on another try (a crashed run that found FAILs still lists them)
clearTimeout(guard);
if (HOOK && state.errorTold && !(state.found.fails || []).length) allow('the checks crashed on this unchanged page and the agent was told; stop allowed');
found = state.found; reused = true;
} else {
print(`gate: checking ${relPage}${kind ? ` (${kind})` : ''}; this takes a few minutes on a CPU Chrome`);
try {
const stat = staticChecks(P.file, html, projDir, files);
const b = await browserChecks(target, projDir, opts, gateDir);
const c = copyChecks(P, kind, html, path.join(gateDir, 'consistency', 'rendered.html'));
found = { fails: mergeFamilies(merge([...stat, ...b.fails, ...c.fails])), warns: merge([...b.warns, ...c.warns]), crashed: [...c.crashed, ...b.crashed], seconds: b.seconds };
} catch (e) {
for (const c of children) killTree(c);
found = { fails: [], warns: [], crashed: ['the gate itself failed: ' + e.message], seconds: {} };
}
}
clearTimeout(guard);
const W = applyWaivers(found.fails, waiverFile);
const open = [...W.problems, ...W.open];
const n = open.length;
const crashed = found.crashed.length > 0;
// only crashes in a row count toward "crashed twice": a fresh run that crashed counts, with or without FAILs found
if (!crashed) state.errors = 0;
else if (!reused) state.errors = (state.errors || 0) + 1;
if (!HOOK && !QUICK) state.manual = true; // the gate has been run by hand: hook stops count rounds from here
// ---------------------------------------------------------------- the verdict
const idsOf = f => (f.ids && f.ids.length > 1 ? `${f.check} (${f.ids.slice(1).join(', ')})` : f.check);
const line = f => `${idsOf(f)}, ${f.where.slice(0, 80)}${f.widths && f.widths.length ? ` [${f.widths.join(',')}]` : ''}, fix: ${(f.fix || f.msg).slice(0, 160)}`;
const waivedText = () => W.waived.length ? `Waived (put these in your report): ${W.waived.map(w => `${w.check} ${w.where.slice(0, 50)} - ${w.reason}`).join('; ')}.` : '';
const badWaivers = () => { const b = W.waivers.filter(x => x.invalid); return b.length ? `Waivers ignored: ${b.map(w => `${w.check} "${w.target}" (${w.invalid})`).join('; ')}.` : ''; };
const rule = `A FAIL you judge false: add {"check": "<id>", "target": "<its where, enough to name that one finding>", "reason": "<why, 12+ characters>"} to ${slash(path.relative(base, waiverFile)) || waiverFile} (one finding each, ${MAX_WAIVERS} at most, never for kit-edited or checker-gaming); it goes in your report. Never edit the kit's files or hide the page from the checker.`;
function printAll(head) {
print(head);
for (const f of open) print(` FAIL ${idsOf(f).padEnd(16)} ${f.where.slice(0, 90)}${f.widths && f.widths.length ? ` [${f.widths.join(', ')}]` : ''}\n ${f.msg.slice(0, 220)}\n fix: ${(f.fix || '').slice(0, 220)}`);
for (const w of W.waived) print(` WAIVED ${w.check} ${w.where.slice(0, 80)}: ${w.reason}`);
for (const w of W.waivers.filter(x => x.invalid)) print(` waiver ignored (${w.invalid}): ${w.check} "${w.target}"`);
if (found.warns.length) print(` ${found.warns.length} WARN (not blocking), first: ${found.warns.slice(0, 5).map(x => `${x.check} ${x.where.slice(0, 40)}`).join('; ')}`);
if (crashed) print(` not checked: ${found.crashed.join('; ')}`);
}
const record = (status, extra = {}) => {
Object.assign(state, { status, fp: FP, page: relPage, found, open: open.map(({ hard, ...f }) => f), waived: W.waived, warnCount: found.warns.length, ...extra });
const entry = { at: new Date().toISOString(), mode: HOOK ? 'hook' : QUICK ? 'quick' : 'manual', ...(HOOK ? { host: HOOK } : {}), status, fails: n, round: state.round, reused, stopHookActive: !!input.stop_hook_active };
state.history = [...(state.history || []), entry].slice(-20);
if (HOOK && !T.done) T.entry = entry;
try { save(); return true; } catch (e) { process.stderr.write('gate: could not write gate.json: ' + e.message + '\n'); return false; }
};
if (QUICK) {
// a look between rounds: no round, never closes the build
printAll(`gate (quick look at 1440 and 390): ${n} FAIL${crashed ? ', some checks did not run' : ''}`);
if (!HOOK) { console.log(`GATE: QUICK ${n} (not a round; run without --quick to finish)`); process.exit(n ? 1 : 0); }
allow('a --quick run never blocks');
}
if (n === 0 && crashed) {
// no FAIL found, but not everything ran: never a PASS; the agent is told once to report it
const what = found.crashed.join('; ');
if (!record('error', { note: what })) { if (HOOK) allow('could not record the run; stop allowed'); }
if (HOOK) { if (state.errorTold) allow('checks crashed (' + what + '); told once, stop allowed'); state.errorTold = true; block(errorReason(what)); }
printAll('gate: no FAIL in what ran, but not everything ran');
print('Do not rerun it: put the GATE line and what was not checked in your report, then finish.');
console.log(`GATE: ERROR (${what})`); process.exit(2);
}
if (n === 0) {
record('pass');
try { fs.renameSync(markerFile, path.join(dot, 'build.done.json')); } catch { try { fs.copyFileSync(markerFile, path.join(dot, 'build.done.json')); fs.rmSync(markerFile, { force: true }); } catch { /* left armed */ } }
if (HOOK) allow('GATE: PASS', true);
printAll('gate: no open FAIL');
console.log('GATE: PASS'); process.exit(0);
}
// FAILs remain. A run on a changed page (or changed waivers) is a round; a manual rerun of an unchanged one is not.
// Before the gate's first run by hand in this build, a hook stop is not a round (a question to the user mid-build must
// not spend one): it lists the FAILs and blocks, and the first run by hand counts that page's round. After a hook block,
// the next stop on the unchanged page is allowed once without a round (a question to the user: "just stop again");
// other stops on it block twice without a round (nothing was fixed), then each counts as one; a change starts again
const already = state.status === 'open';
let idle = false;
if (!already) {
if (state.roundKey !== ROUND_KEY) {
if (!early) state.round = (state.round || 0) + 1;
Object.assign(state, { roundKey: ROUND_KEY, roundFree: early, idle: 0, idleFree: false, blockKey: null });
} else if (!HOOK) {
// the round a hook stop before this first run by hand did not count
if (state.roundFree) Object.assign(state, { roundFree: false, round: (state.round || 0) + 1, idle: 0, idleFree: false, blockKey: null });
} else if (state.blockKey === ROUND_KEY && !state.idleFree) {
state.idleFree = true; record('fail'); allow('nothing changed since the last block; this stop is allowed once without a round (a question to the user)');
} else if ((state.idle || 0) < IDLE) { state.idle = (state.idle || 0) + 1; idle = true; }
else if (!early) state.round = (state.round || 0) + 1;
}
const k = state.round || 1, last = already || (state.round || 0) >= MAX;
const top = open.slice(0, TOP).map(f => '- ' + line(f)).join('\n') + (n > TOP ? `\n(${n - TOP} more in ${slash(path.relative(base, stateFile)) || stateFile}; crops in .taste-skill/gate/consistency)` : '');
const notChecked = crashed ? ` (not checked: ${found.crashed.join('; ')})` : '';
if (last && !idle) {
const told = !!state.openTold;
if (!record('open', { openTold: true })) { if (HOOK) allow('could not record the round; stop allowed'); }
// a used-up build is closed like a passed one, so a later deal or a build one folder down is not tied to it
try { fs.renameSync(markerFile, path.join(dot, 'build.done.json')); } catch { try { fs.copyFileSync(markerFile, path.join(dot, 'build.done.json')); fs.rmSync(markerFile, { force: true }); } catch { /* gate.json's status ends it */ } }
if (HOOK) {
if (told) allow('rounds used up, told before', true);
block([`tasteskill gate: rounds used up (${MAX} of ${MAX}), ${n} FAIL${n > 1 ? 's' : ''} open${notChecked}:`, top, waivedText(), 'Rounds used up: stop fixing. Put "GATE: OPEN" and these open FAILs in your report (with any waivers and their reasons), then finish.'].filter(Boolean).join('\n'));
}
printAll(`gate: rounds used up; ${n} FAIL open${reused ? ' (page unchanged since the last run)' : ''}`);
print('Stop fixing: put these open FAILs and the waivers in your report, then finish.');
console.log(`GATE: OPEN ${n} (rounds used up)`); process.exit(1);
}
if (HOOK) state.blockKey = ROUND_KEY;
if (!record('fail')) { if (HOOK) allow('could not record the round; stop allowed'); }
if (HOOK) {
const fails = `${n} FAIL${n > 1 ? 's' : ''} remain${notChecked}`;
const left = IDLE - (state.idle || 0);
const head = early
? `tasteskill gate: ${idle ? 'nothing changed, and ' : ''}${fails} (not a round: the gate has not been run by hand in this build yet):`
: idle
? `tasteskill gate: nothing changed since round ${k} of ${MAX}, and ${fails}. Finishing again without a fix is ${left ? `blocked ${left} more time${left === 1 ? '' : 's'}, then ` : ''}counted as a round:`
: `tasteskill gate, round ${k} of ${MAX}: ${fails}:`;
const next = early ? `Fix them; when the page is finished, run ${GATE} by hand (that run is round 1 of ${MAX}).` : `Fix them, then finish again; round ${k} of ${MAX}.`;
// true only while this page's one free stop is unused: the next stop on it is then allowed without a round
const ask = state.idleFree ? '' : 'If you are stopping to ask the user something, just stop again: that one stop is allowed and costs no round.';
const done = `If the user told you to stop or to ship it as it is: ${GATE} --done "<what they said>".`;
block([head, top, waivedText(), badWaivers(), rule, [next, ask, done].filter(Boolean).join(' ')].filter(Boolean).join('\n'));
}
printAll(`gate: ${n} FAIL open${reused ? ' (page unchanged since the last run: results reused, not a new round)' : ''}`);
print(rule);
console.log(`GATE: FAIL ${n} (round ${k} of ${MAX})`); process.exit(1);
+13 -2
View File
@@ -485,7 +485,8 @@ async function checkCmd() {
/* ------------------------------------------------------------ measure: the true drawn box of every colour mark
`measure` renders each curated colour mark once in headless Chrome (CPU WebGL flags, its own profile, closed by its
own PID) and asks the browser for the box the drawing really fills (getBBox, with every transform, use and nested group
own PID; --profile: a folder of its own in the OS temp folder or the project's .taste-skill folder, never outside
the project, else .taste-skill/chrome-icons) and asks the browser for the box the drawing really fills (getBBox, with every transform, use and nested group
resolved: the path-point reader above gives up on those). The boxes go into index.json "boxes" keyed by icon id, and
fetch crops to them. Needs a Chrome (CHROME_PATH or the usual places); run it when the curated list changes. */
async function measureCmd() {
@@ -494,7 +495,17 @@ async function measureCmd() {
const idx = loadIndex();
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'icons-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-icons-' + process.pid);
const profile = path.resolve(opt('profile', defaultProfile()));
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
const profile = opt('profile') ? inProject(opt('profile'), 'icons') : path.resolve(defaultProfile());
const chromePath = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome', '/usr/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find(p => p && fs.existsSync(p));
if (!chromePath) { console.error('icons: no Chrome found (set CHROME_PATH)'); process.exit(1); }
const seen = new Set(), items = [];
+52 -27
View File
@@ -4,30 +4,35 @@
node scripts/moments.mjs index scan assets/moments/<id>/meta.json into index.json
node scripts/moments.mjs list [--kind text] [--fits hero] [--page tech] [--json]
node scripts/moments.mjs pick --page tech|gaming|software --count 1|2 --seed S --history .taste-skill/history.json
node scripts/moments.mjs pick --page tech|gaming|software|place|product|service --count 1|2 --seed S --history .taste-skill/history.json
[--scene <id>] [--effects a,b] [--contexts n]
[--exclude a,b] [--kind text,scroll] [--fits hero,section] [--photo] [--json]
node scripts/moments.mjs check <page.html> [--page tech] [--scene <id>] [--effects a,b] [--contexts n]
audit a built page: its moments, the still rule, cards, the WebGL budget
node scripts/moments.mjs gallery write assets/moments/gallery.html (every demo, one page)
node scripts/moments.mjs serve --port 5720 static server for the taste-skill folder (5720-5729)
node scripts/moments.mjs shoot --out <out-dir> --profile <profile-dir>
node scripts/moments.mjs shoot --out <out-dir> --profile <profile-dir in the OS temp folder or .taste-skill>
[--only a,b] [--widths 1440,390] [--states start,middle,end] [--port 5720] [--reduced]
[--probe "<js expression>"] (its value is printed after every state)
node scripts/moments.mjs sheet --out <out-dir> rebuild index.html from the shots in that folder
(shoot --url assets/moments/_demo/in-card.html shoots one other page of the skill, e.g. a test page)
pick deals by the page's kind (the house overrides). --page is required: tech, gaming or software
(ai and saas are tech and software; "technical product" is software). A place, shop, cafe, service or
any other kind gets none: pick prints an empty deal and says why, exit 0, so the caller simply places
no moment. A moment lists the kinds it fits in meta.pages; a retired moment (meta.retired, weight 0)
is never dealt. The WebGL budget: a page holds at most 4 live contexts (perf.mjs glPeak). The deal
pick deals by the page's kind. --page is required: tech, gaming or software (ai and saas are tech and
software; "technical product" is software), or place, product or service (a cafe, shop or guesthouse is
a place, a studio, firm or clinic a service, a brand's goods a product). Every kind gets its moments:
one on a short page, two on a long one. A tech page deals from the whole kit; a place, product or
service page only from the photo- and type-led moments that list it in meta.pages (no WebGL, no
shader, nothing that reads as an effect), and there two moments may share a page although both
follow the scroll when one is a statement and the other a section, or when they are of two kinds
(a photograph that develops on entering beside a scrubbed section). A retired moment (meta.retired, weight 0) is never dealt. The WebGL budget: a page holds at most 4 live contexts (perf.mjs glPeak). The deal
counts the contexts the page already spends - the big scene (--scene <id>: its `webgl` in
assets/scenes/index.json; it also leaves out that scene's avoidMoments, its twins and the ones that
fight it, so neither pick nor check needs them as --exclude), each effect (--effects a,b: one each, worst case) and any others
(--contexts n: objects, components) - and never deals a moment whose own meta.webgl would push the
total over 4. Every moment has a still variant for phones and reduced motion (meta.phone,
meta.reduced; the loader enforces it, moments.js), and none may sit in a card (check reports it).
total over 4. Every moment has a still variant for reduced motion and one for phones (meta.phone,
meta.reduced; the loader enforces it, moments.js), but a moment light enough for a phone runs there
(meta.phoneRuns true, and `export const phone = true` in its moment.js: word-highlight, the film look
of picture-develops, photo-chapters), and none may sit in a card (check reports it).
pick deals one or two moments at random, seeded. The history file's `moments` list is read as
cycles: once every moment that fits has been dealt, the next cycle starts empty, so an id comes
@@ -60,10 +65,14 @@ const KINDS = ['text', 'scroll', 'hover', 'transition', 'liquid'];
const FITS = ['hero', 'section', 'statement', 'closing', 'between'];
const TRIGGERS = ['load', 'enter', 'scroll', 'hover', 'pointer', 'loop'];
const COSTS = ['low', 'mid', 'high'];
const PAGES = ['tech', 'gaming', 'software'];
const TECH = ['tech', 'gaming', 'software'];
const OTHER = ['place', 'product', 'service']; // site-kind other: photographs, type and flat colour lead
const PAGES = [...TECH, ...OTHER];
const BUDGET = 4; // live WebGL contexts a page may hold (references/perf.md, perf.mjs glPeak)
// page kinds as other files say them -> the three kinds a moment is dealt on
const PAGE_ALIAS = { tech: 'tech', technology: 'tech', ai: 'tech', gaming: 'gaming', game: 'gaming', games: 'gaming', software: 'software', saas: 'software', app: 'software', 'technical-product': 'software', 'tech-product': 'software' };
const PAGE_ALIAS = { tech: 'tech', technology: 'tech', ai: 'tech', gaming: 'gaming', game: 'gaming', games: 'gaming', software: 'software', saas: 'software', app: 'software', 'technical-product': 'software', 'tech-product': 'software',
place: 'place', cafe: 'place', 'café': 'place', bar: 'place', bakery: 'place', restaurant: 'place', guesthouse: 'place', hotel: 'place', shop: 'place', florist: 'place', gym: 'place',
product: 'product', brand: 'product', goods: 'product', service: 'service', studio: 'service', agency: 'service', firm: 'service', clinic: 'service', trade: 'service' };
function parseArgs(argv) {
const out = { _: [] };
@@ -93,7 +102,9 @@ function validate(meta, folder) {
if (typeof meta.webgl !== 'number' || meta.webgl < 0 || meta.webgl > 1) errs.push('meta.webgl must be 0 or 1 (at most one context)');
if (!Array.isArray(meta.pages) || meta.pages.some(p => !PAGES.includes(p))) errs.push(`meta.pages must list some of ${PAGES.join(', ')} (empty only with meta.retired)`);
else if (!meta.pages.length && !(typeof meta.retired === 'string' && meta.retired.trim())) errs.push('meta.pages is empty: a moment that fits no page kind needs meta.retired (a reason) and weight 0');
if (typeof meta.phone !== 'string' || !meta.phone.trim()) errs.push('meta.phone must say what a phone shows (every moment has a static phone variant)');
if (typeof meta.phone !== 'string' || !meta.phone.trim()) errs.push('meta.phone must say what a phone shows (a static variant, or how it runs there with phoneRuns)');
if (meta.phoneRuns != null && typeof meta.phoneRuns !== 'boolean') errs.push('meta.phoneRuns must be true or false');
if (meta.phoneRuns && !/export const phone = true/.test(fs.existsSync(path.join(DIR, folder, 'moment.js')) ? fs.readFileSync(path.join(DIR, folder, 'moment.js'), 'utf8') : '')) errs.push('meta.phoneRuns needs `export const phone = true` in moment.js (the loader reads that)');
if (meta.retired && (meta.weight ?? 1) !== 0) errs.push('a retired moment needs weight 0');
if (meta.maxPerPage !== 1) errs.push('meta.maxPerPage must be 1');
if (meta.avoidWith != null && !Array.isArray(meta.avoidWith)) errs.push('meta.avoidWith must be an array');
@@ -153,13 +164,15 @@ function readHistory(file) {
if (!file || file === true || !fs.existsSync(file)) return {};
try { const h = JSON.parse(fs.readFileSync(file, 'utf8')); return h && typeof h === 'object' && !Array.isArray(h) ? h : {}; } catch { return {}; }
}
// a page kind as a caller says it -> { page } (a kind moments are dealt on) or { none: why } (a kind that gets none)
// a page kind as a caller says it -> { page } or { none: why } (a word that names no kind: "other" is
// three kinds, so the caller says which)
export function pageKind(v) {
if (v == null || v === true) return null;
const k = String(v).toLowerCase().trim();
if (PAGE_ALIAS[k]) return { page: PAGE_ALIAS[k] };
return { none: `a "${k}" page gets no moment: moments are dealt only on tech, gaming and software pages (never a place, shop, cafe, service or other local page; they lead with photographs)` };
return { none: `"${k}" is not a page kind: pass the kind from SKILL.md section 1, one of ${PAGES.join(', ')} (site-kind other is place, product or service)` };
}
export const isOther = page => OTHER.includes(page);
// the live WebGL contexts the page already spends besides a moment: the scene, the effects, the rest
// and the moments that scene keeps off its page (its meta avoidMoments in assets/scenes/index.json: its twins and the ones that fight it)
export function pageContexts({ scene, effects = [], contexts = 0 } = {}) {
@@ -206,8 +219,13 @@ export function deal(moments, { count = 1, seed, history = [], exclude = [], kin
for (let i = history.length - 1; i >= 0 && tail.size < keep; i--) if (ids.has(history[i])) tail.add(history[i]);
// what can share a page: hover and liquid are one group (one pointer effect a page), one of a kind,
// one of a slot, never two that avoid each other, never two WebGL contexts
// on a place, product or service page nearly every moment follows the scroll, so the kind rule would
// allow one moment only; there the two are one statement and one section (type, then photographs), or
// two of different kinds (picture-develops runs once on entering, a chapters moment follows the scroll),
// so the one statement moment is not on every long page
const group = m => (m.kind === 'hover' || m.kind === 'liquid' ? 'pointer' : m.kind);
const together = (m, p) => group(m) !== group(p)
const statement = m => m.fits.includes('statement');
const together = (m, p) => (isOther(page) ? statement(m) !== statement(p) || m.kind !== p.kind : group(m) !== group(p))
&& !(p.avoidWith || []).includes(m.id) && !(m.avoidWith || []).includes(p.id)
&& !(m.fits.length === 1 && p.fits.length === 1 && m.fits[0] === p.fits[0])
&& (m.webgl || 0) + (p.webgl || 0) <= room;
@@ -236,13 +254,8 @@ function cmdPick(a) {
const idx = readIndex();
const count = Math.max(1, Math.min(2, +(a.count || 1)));
const pg = pageKind(a.page || a.type);
if (!pg) die('pick: pass --page tech|gaming|software (the page\'s kind). A place, shop, cafe or service page gets no moment.');
if (!pg.page) {
console.error(pg.none);
if (a.json) console.log(JSON.stringify({ seed: null, page: String(a.page || a.type), picks: [], reason: pg.none }));
else console.log('no moment: ' + pg.none);
return;
}
if (!pg) die(`pick: pass --page <kind>, one of ${PAGES.join(', ')} (SKILL.md section 1). Every kind gets one moment, two on a long page.`);
if (!pg.page) die('pick: ' + pg.none);
const ctx = pageContexts({ scene: a.scene && a.scene !== true ? a.scene : null, effects: list(a.effects) || [], contexts: a.contexts });
ctx.notes.forEach(n => console.error(n));
// the scene's own avoidMoments are left out of the deal: the caller need not pass them as --exclude
@@ -295,7 +308,7 @@ function cmdGallery() {
const rows = shown.map(m => `<li><a href="${m.id}/demo.html"><b>${m.name}</b> <span>${m.kind} · ${m.fits.join(', ')} · ${m.trigger}${m.webgl ? ' · WebGL' : ''} · ${m.pages.join(', ')}</span><p>${m.look}</p><p class="ph">Phone and reduced motion: ${m.phone}</p></a></li>`).join('\n');
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Moments</title>
<style>body{margin:0;font:16px/1.5 system-ui,sans-serif;color:#111;background:#fff;padding:48px 24px}h1{font-size:28px;font-weight:500;margin:0 0 8px}p.lead{color:#555;margin:0 0 32px;max-width:60ch}ul{list-style:none;padding:0;margin:0;display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}li a{display:block;padding:16px 20px;border:1px solid #e5e5e5;border-radius:12px;color:inherit;text-decoration:none}li a:hover{border-color:#999}li b{font-weight:500}li span{display:block;color:#666;font-size:13px;margin:2px 0 6px}li p{margin:0;color:#333;font-size:14px}li p.ph{margin-top:6px;color:#666;font-size:13px}</style></head>
<body><h1>Moments</h1><p class="lead">${shown.length} creative moments for tech, gaming and software pages, one or two a page, never on a place page (references/moments.md). Each link is a full demo page; the moment is marked data-moment in its source.</p><ul>${rows}</ul></body></html>`;
<body><h1>Moments</h1><p class="lead">${shown.length} creative moments, one or two a page on every kind; a place, product or service page is dealt only the photo- and type-led ones that list it (references/moments.md). Each link is a full demo page; the moment is marked data-moment in its source.</p><ul>${rows}</ul></body></html>`;
fs.writeFileSync(path.join(DIR, 'gallery.html'), html);
console.log(`wrote ${path.join(DIR, 'gallery.html')} (${shown.length} moments)`);
}
@@ -307,11 +320,21 @@ function findChrome() {
if (!f) throw new Error('no Chrome found (set CHROME_PATH)');
return f;
}
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
async function cmdShoot(a) {
const out = a.out && a.out !== true ? path.resolve(a.out) : die('shoot: pass --out <dir>');
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'moments-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-moments-' + process.pid);
const profile = a.profile && a.profile !== true ? path.resolve(a.profile) : defaultProfile();
const profile = a.profile && a.profile !== true ? inProject(a.profile, 'moments') : defaultProfile();
const port = +(a.port || 5720);
const widths = (list(a.widths) || ['1440', '390']).map(Number);
const states = list(a.states) || ['start', 'middle', 'end'];
@@ -402,10 +425,11 @@ async function cmdShoot(a) {
if (a.probe && a.probe !== true) { try { console.log(` probe: ${JSON.stringify(await ev(String(a.probe)))}`); } catch (e) { console.log(` probe failed: ${e.message}`); } }
const failed = await ev(`[...document.querySelectorAll('[data-moment-failed]')].map(e => e.dataset.moment + ': ' + e.dataset.momentFailed).join('; ')`);
const gl = await ev(`document.querySelectorAll('canvas').length`);
// the still rule: on a phone width or under reduced motion the host says so and nothing runs
// the still rule: under reduced motion, and on a phone width unless the moment runs there
// (meta.phoneRuns), the host says so and nothing runs
const still = await ev(`(() => { const el = document.querySelector('[data-moment]'); return (el && el.dataset.momentStill) || ''; })()`);
const running = await ev(`document.getAnimations().filter(x => x.playState === 'running' && x.effect && x.effect.target && x.effect.target.closest && x.effect.target.closest('[data-moment]')).length`);
const expectStill = a.reduced || W < 640;
const expectStill = a.reduced || (W < 640 && !m.phoneRuns);
const note = expectStill && (still !== (a.reduced ? 'reduced' : 'phone') || running) ? ` STILL? mark "${still}", ${running} running` : '';
report.push({ id: m.id, w: W, state: st, file: name, failed, canvases: gl, still, running, logs: [...new Set(logs)].slice(0, 6) });
console.log(`${name}${still ? ' still:' + still : ''}${note}${failed ? ' FAILED ' + failed : ''}${logs.length ? ' ' + [...new Set(logs)].slice(0, 3).join(' | ') : ''}`);
@@ -506,7 +530,8 @@ function cmdCheck(a) {
if (m && n && f.id < g.id) {
if ((m.avoidWith || []).includes(n.id) || (n.avoidWith || []).includes(m.id)) faults.push(`${f.id} and ${g.id} avoid each other`);
const grp = x => (x.kind === 'hover' || x.kind === 'liquid' ? 'pointer' : x.kind);
if (grp(m) === grp(n)) faults.push(`${f.id} and ${g.id} are both ${grp(m)} moments`);
if (pg && pg.page && isOther(pg.page)) { if (m.fits.includes('statement') === n.fits.includes('statement') && m.kind === n.kind) faults.push(`${f.id} and ${g.id} are both ${m.fits.includes('statement') ? 'statement' : 'section'} moments of one kind: on a ${pg.page} page the two are one statement and one section, or two kinds`); }
else if (grp(m) === grp(n)) faults.push(`${f.id} and ${g.id} are both ${grp(m)} moments`);
}
}
const ctx = pageContexts({ scene: a.scene && a.scene !== true ? a.scene : null, effects: list(a.effects) || [], contexts: a.contexts });
+22 -7
View File
@@ -2,10 +2,10 @@
/*
taste-skill perf.mjs - a performance check for a built page. Node 22 or later (global WebSocket and fetch), no packages.
node <skill>/scripts/perf.mjs <page.html | folder | http(s) url> [--profile <own chrome dir>] [--json <file to write>] [--w 1440 --w 390] [--runs 3] [--port 5740]
node <skill>/scripts/perf.mjs <page.html | folder | http(s) url> [--profile <own chrome dir>] [--json <file to write>] [--w 1440 --w 390] [--runs 3] [--port 5740] [--root <dir>]
A file or folder is served by a small local http server (ports 5740-5749, or the --port named and the 9 after it, text gzipped like a static host),
because module scripts do not load from file://. Then ONE headless Chrome on the CPU (--headless=new
because module scripts do not load from file://. --root serves from that folder instead (a built site whose links start at its root, /style.css). Then ONE headless Chrome on the CPU (--headless=new
--disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader) loads the page at 1440x900 and at 390x844 over the
Chrome DevTools Protocol, waits until the effects near the viewport are mounted, scrolls to the bottom one screen at a
time, and measures. Chrome is closed by its own PID. No fps measurement, no soak: it only counts.
@@ -21,6 +21,9 @@
FCP and LCP are CPU-rendered: compare pages with each other, never with field data. With WebGL on, SwiftShader runs
the shaders on the CPU: the timing verdicts come from loads where every WebGL piece shows its still fallback, which
is a proxy (it does not measure shader compile or link on a real GPU).
--profile: the Chrome profile, a folder of its own in the OS temp folder or the project's .taste-skill folder, never
outside the project; a relative one that leads out of the current folder (../prof) is replaced, with a warning, by
.taste-skill/chrome-perf.
Exit code 1 when any check FAILs, 2 when the run timed out (partial results are printed).
*/
import fs from 'node:fs';
@@ -32,19 +35,20 @@ import { spawn, execFileSync } from 'node:child_process';
const args = process.argv.slice(2);
const pos = [], widths = [];
let profileArg = null, jsonOut = null, RUNS = 3, PORT0 = 5740;
let profileArg = null, jsonOut = null, rootArg = null, RUNS = 3, PORT0 = 5740;
const die = m => { console.error('perf: ' + m); process.exit(1); };
const val = i => { const v = args[i + 1]; if (v == null || v.startsWith('--')) die(args[i] + ' needs a value'); return v; };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--profile') profileArg = val(i++);
else if (args[i] === '--json') jsonOut = val(i++);
else if (args[i] === '--root') rootArg = path.resolve(val(i++));
else if (args[i] === '--w') { const w = +val(i++); if (!(w >= 200 && w <= 4000)) die('--w needs a width in px'); widths.push(w); }
else if (args[i] === '--port') { PORT0 = parseInt(val(i++), 10); if (!(PORT0 >= 1024 && PORT0 <= 65500)) die('--port needs a port from 1024 to 65500'); }
else if (args[i] === '--runs') RUNS = Math.max(1, Math.min(9, parseInt(val(i++), 10) || 3));
else if (args[i].startsWith('--')) die('unknown option ' + args[i]);
else pos.push(args[i]);
}
if (!pos.length) die('usage: perf.mjs <page.html | folder | url> [--profile <dir>] [--json file] [--w 1440 --w 390] [--runs 3] [--port 5740]');
if (!pos.length) die('usage: perf.mjs <page.html | folder | url> [--profile <dir in the OS temp folder or .taste-skill>] [--json file] [--w 1440 --w 390] [--runs 3] [--port 5740] [--root <dir>]');
if (typeof WebSocket === 'undefined' || typeof fetch === 'undefined') die('Node 22 or later is needed (global WebSocket and fetch)');
if (!widths.length) widths.push(1440, 390);
const target = pos[0];
@@ -358,13 +362,24 @@ let url = target, srvObj = null;
if (!/^https?:/.test(target)) {
const abs = path.resolve(target);
if (!fs.existsSync(abs)) die('not found: ' + abs);
const isDir = fs.statSync(abs).isDirectory(), dir = isDir ? abs : path.dirname(abs);
const isDir = fs.statSync(abs).isDirectory(), dir = rootArg || (isDir ? abs : path.dirname(abs)), rel = path.relative(dir, abs);
if (rel.split(path.sep)[0] === '..' || path.isAbsolute(rel)) { console.error(`perf: the page is not inside --root ${dir}: ${abs}`); process.exit(2); }
srvObj = await serve(dir);
url = `http://127.0.0.1:${srvObj.port}/` + (isDir ? '' : path.basename(abs));
url = `http://127.0.0.1:${srvObj.port}/` + rel.split(path.sep).map(encodeURIComponent).join('/') + (isDir && rel ? '/' : '');
}
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
// a fresh profile folder per run (a reused one can still be locked by the Chrome just closed); removed once Chrome has exited
// default profile folder: --profile, else $TASTE_SKILL_TMP/chrome/perf, else a folder under the system temp dir
const profileBase = path.resolve(profileArg || (process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'perf') : path.join(os.tmpdir(), 'taste-skill-perf')));
const profileBase = path.resolve(profileArg ? inProject(profileArg, 'perf') : (process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'perf') : path.join(os.tmpdir(), 'taste-skill-perf')));
fs.mkdirSync(profileBase, { recursive: true });
for (const d of fs.readdirSync(profileBase)) if (/^run-\d+$/.test(d)) { try { fs.rmSync(path.join(profileBase, d), { recursive: true, force: true }); } catch { /* another run, or locked */ } }
const profile = path.join(profileBase, 'run-' + process.pid);
+225 -41
View File
@@ -8,8 +8,11 @@
node <skill>/scripts/photos.mjs pick --trade cafe --sections hero,bar,roastery,coffee,visit --seed s1 --history .taste-skill/history.json
[--count 6] [--tone light|dark] [--place "Lofoten, Norway"] [--setting coast,mountain]
[--pictures 12] [--brief BRIEF.md] [--draft-lead | --no-client-photos] [--own-land] [--shop] [--mono] [--commons-only] [--json]
node <skill>/scripts/photos.mjs pick --product-without-photos a physical product with no photographs: deals nothing
node <skill>/scripts/photos.mjs fetch 326,m61713103 --out <site folder> [--hero 326] [--big id] [--lqip] [--dir assets/img]
node <skill>/scripts/photos.mjs pick --product-without-photos a physical product with no photographs: no library frame; prints the search for stand-ins
node <skill>/scripts/photos.mjs search "yoga studio interior" [--trade wellness] [--count 12] [--min 1600] [--orientation landscape|portrait] [--out .]
node <skill>/scripts/photos.mjs search --trade hotel a live search of Wikimedia Commons (CC0, CC BY, CC BY-SA, public domain JPEGs), by
the trade's own search words when no words are given; writes .taste-skill/found.json
node <skill>/scripts/photos.mjs fetch 326,m61713103,f12345 --out <site folder> [--hero 326] [--big id] [--lqip] [--dir assets/img]
node <skill>/scripts/photos.mjs credits --out <site folder> [--adapted c141] [--write] [--text] the ONE footer line and the credits list for the photographs the pages use
pick deals library frames as DETAIL slots: hands, tools, materials, process (the oven, the roasting drum, a hammer,
@@ -33,11 +36,20 @@
("Lofoten, Norway", "Iceland; Faroe Islands"; list --places): a frame is dealt when its source
names the client's place exactly or a place inside it; a broader tag (Iceland for Reykjavik) is not.
--setting (coast, mountain, lake...) only narrows frames with no named place.
A physical product (a maker or brand of bikes, chairs, scooters, devices...) without photographs of its own is
refused: the client's own photographs, else its render from assets/objects/product-render ([data-product], one crafted
studio render composed from a JSON spec of its parts, sizes and materials) leads the full product page, never the short page;
A physical product (a maker or brand of bikes, chairs, scooters, devices...) without photographs of its own is dealt
no library frame: the client's own photographs lead; until they exist, stand-ins found with search (photographs of the
same kind of thing, in use, its parts, where it lives; no maker's name, logo or face readable), each listed in the
report as a stand-in to replace. A render (assets/objects/product-render) only when the brief asks for 3D or a render;
never a drawing, never another kind's object (PH-04, PH-07). --shop (or "bike shop") is for a shop or workshop that
sells or fixes every make: it gets parts and loads only.
search asks Wikimedia Commons for JPEGs under CC0, CC BY, CC BY-SA or in the public domain, at least --min pixels wide
(1600), and keeps them in .taste-skill/found.json under --out, so fetch and credits treat them as library frames. With
no words it runs the trade's own search words (TERMS below: yoga and studio interiors, bike workshop benches and whole
bikes, hotel buildings and rooms, weddings, a game's setting...). Every result is a candidate: look at it full size.
Recent photographs: with TASTE_SKILL_RECENT=<file> set, pick and fetch keep the frames of every build in that one
small file, and pick and search put a frame another build used lately behind the fresh ones, so two cafes built in
two folders do not show the same frames. Unset (the default), nothing is written outside the project; --no-recent
leaves the file out for one run.
A bakery gets ovens, dough and loaves; a florist the stems and buds; a workshop only the frames of its own craft
(--trade blacksmith, weaving, letterpress: a smithy is never a weaver's bench; a goldsmith, silversmith or locksmith
has no trade here). A photograph marked moodOnly (its file names a business, or it is another's named room) is
@@ -47,8 +59,11 @@
the older ones (500, 960 and 1280 wide, the --hero one 1920; WebP when ffmpeg is on the path, else JPEG), keeps every
file inside the weight caps (a hero 400 KB, any other 250 KB: a larger one is left out of the srcset; the page's
images together stay under 2,400 KiB at 1440, the figure perf.mjs FAILs above: fetch prints the estimate), writes
credits.json and prints the <img> to paste, with data-source="library-057" and data-subject. A --hero also gets a
780x584 crop for the phone (a <picture>, needs ffmpeg) and sizes="100vw". The hero's 2x at 1440 (2880 wide) is not
credits.json and prints the <img> to paste, with data-source="library-057" (a search result: data-source="stand-in")
and data-subject. A --hero also gets a
780x975 (4:5) crop for the phone (a <picture>, needs ffmpeg) and sizes="100vw"; without ffmpeg its phone sizes term
covers a 4:5 box instead. Either way it carries data-phone-fill, so a place hero's photograph may grow on a phone into
what its words leave (assets/layout/layout.css). The hero's 2x at 1440 (2880 wide) is not
in the library. Node 22+, no packages (ffmpeg only for the crops and WebP).
credits prints TWO things: the ONE footer line ("Photographs via Unsplash and Wikimedia Commons", a link to credits.html) and the
credits list block (photographers, licences (the Public Domain Mark's own page for a public-domain file) and source pages),
@@ -75,30 +90,75 @@ const byId = new Map(lib.photos.map((p) => [p.id, p]));
const argv = process.argv.slice(2);
const cmd = argv[0];
const FLAGS = ['shop', 'mono', 'json', 'product-without-photos', 'detail-only', 'draft-lead', 'no-client-photos', 'own-land', 'commons-only', 'lqip', 'text', 'places', 'write'];
const FLAGS = ['shop', 'mono', 'json', 'product-without-photos', 'detail-only', 'draft-lead', 'no-client-photos', 'own-land', 'commons-only', 'lqip', 'text', 'places', 'write', 'no-recent'];
const opt = (k, d) => { const i = argv.indexOf('--' + k); return i < 0 ? d : (!FLAGS.includes(k) && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true); };
const positional = () => { const out = []; for (let i = 1; i < argv.length; i++) { if (argv[i].startsWith('--')) { if (argv[i + 1] && !argv[i + 1].startsWith('--') && !FLAGS.includes(argv[i].slice(2))) i++; continue; } out.push(argv[i]); } return out; };
const die = (m) => { console.error('photos: ' + m); process.exit(1); };
// ------------------------------------------------------------------ a physical product without photographs
const PRODUCT = 'A physical product without photographs of its own gets no stock photographs: no forest, road, weather, umbrella, '
+ 'groceries or other maker\'s product stands in for it, and no other trade is dealt to fill the page. The product is still shown, never hidden '
+ '(references/photos.md, A product without photographs; references/sections.md, Product), in this order: (1) the brief\'s own render or CAD view, if it supplies one; '
+ '(2) else its render from assets/objects/product-render (effects-objects.md, Product renders): one crafted studio render composed from a JSON '
+ 'spec of its own parts, sizes and materials, mounted as <div class="frame" data-product="assets/objects/product-render/specs/<name>.json" data-product-place="hero"> '
+ 'with auto.js, drawn by make-posters.mjs --check first, on a flat ground; it is the product\'s picture, not an effect, on any physical product (hardware or not), '
+ 'and it leads the hero (deal.mjs --type product --has object). The page is the full product page (hero with the render, the product, what it carries, how it is used, '
+ 'specs as rows, a live order card), never the short page. Renders the brief names but does not supply count as missing and the report lists them. '
+ 'Never a drawn SVG or a drawing of the product (PH-07), never mood stock, another maker\'s product or an object of another kind (a wheel is not a bicycle). '
+ 'Its parts, sizes and figures also in type and as UI cards (references/cards.md). Nothing on the page says photographs are missing; the report lists the '
+ 'photographs to shoot (the product on one plain backdrop, three angles, a detail of each part, the product in use). '
const PRODUCT = 'A physical product without photographs of its own is dealt no library frame: the library holds other places\' details, never a product. '
+ 'The product is still shown, never hidden, and by photographs (references/photos.md, A product without photographs; references/sections.md, Product), in this order: '
+ '(1) the client\'s own photographs, or a picture the brief supplies (a cut-out, a CAD view, its own render); '
+ '(2) until they exist, stand-ins found with photos.mjs search: the same kind of thing whole (a cargo bike for a cargo bike, never a wheel for a bicycle), '
+ 'in use where it lives, close-ups of the parts the page names, one light and grade across the set, no maker\'s name, logo or face readable; '
+ 'every one listed in the build report as a stand-in with the client\'s photograph to shoot in its place, nothing on the page saying so; '
+ '(3) a render from assets/objects/product-render (effects-objects.md, Product renders) only when the brief asks for 3D or a render (deal.mjs --has object). '
+ 'The page is the full product page (the product, what it carries, how it is used, specs as rows, a live order card), never the short page. '
+ 'Never a drawn SVG or a drawing of the product (PH-07), never mood stock (a forest for "the ride", groceries for "what it carries"), never an object of another kind. '
+ 'The report also lists the photographs to shoot (the product on one plain backdrop, three angles, a detail of each part, the product in use). '
+ 'A shop, workshop or rental that sells or fixes every make is a place: pick --trade bike --shop.';
// what to search for a product: its own word, whole and close (add the place it lives in plain words: "cargo bicycle street")
const productSearches = (word) => { const w = String(word || '<the product>').replace(/\b(brand|maker|company|startup|label|manufacturer)s?\b/gi, '').replace(/\s+/g, ' ').trim() || '<the product>'; return [w, `${w} detail`]; };
function refuse(trade, why) {
if (opt('json')) console.log(JSON.stringify({ trade: trade || null, refused: true, why, ids: [], photos: [], next: PRODUCT }, null, 1));
else console.log(`NO PHOTOGRAPHS (${why}).\n\n${PRODUCT}`);
const qs = productSearches(trade);
const next = qs.map((q) => `node ${cmdPath(path.join(HERE, 'photos.mjs'))} search "${q}" --out .`);
if (opt('json')) console.log(JSON.stringify({ trade: trade || null, refused: true, why, ids: [], photos: [], next: PRODUCT, search: next }, null, 1));
else console.log(`NO LIBRARY FRAMES (${why}).\n\n${PRODUCT}\n\nthe search for stand-ins (look at each result full size; list every one you use in the report):\n${next.map((x) => ' ' + x).join('\n')}`);
process.exit(0);
}
// ------------------------------------------------------------------ recent photographs, across builds
// the frames every build that shares one file was dealt or fetched (shared with start.mjs, which keeps its palettes
// there): a page in a new folder starts with an empty --history, so without it two builds of one trade show the same
// frames. Opt-in: only a path set in TASTE_SKILL_RECENT is read or written; by default nothing leaves the project.
const RECENT_FILE = process.env.TASTE_SKILL_RECENT || null;
function readRecent() {
if (!RECENT_FILE || opt('no-recent')) return null;
try { const r = JSON.parse(fs.readFileSync(RECENT_FILE, 'utf8')); return r && typeof r === 'object' && !Array.isArray(r) ? r : {}; } catch { return {}; }
}
function writeRecent(r) {
if (!r) return;
try { fs.mkdirSync(path.dirname(RECENT_FILE), { recursive: true }); fs.writeFileSync(RECENT_FILE, JSON.stringify(r, null, 1) + '\n'); } catch { /* optional: never stop a deal over it */ }
}
// how lately and how often another build used each frame: the last 6 deals of this trade (the last one counts 6, the
// oldest 1) and the last 60 frames fetched anywhere (1 each); a frame with no score is fresh
function recentIds(r, trade) {
const m = new Map();
if (!r) return m;
const deals = Array.isArray(r.photos) ? r.photos.filter((d) => d && (!trade || d.trade === trade)).slice(-6) : [];
deals.forEach((d, i) => { for (const id of d.ids || []) m.set(id, (m.get(id) || 0) + i + 1); });
for (const id of Array.isArray(r.fetched) ? r.fetched.slice(-60) : []) m.set(id, (m.get(id) || 0) + 1);
return m;
}
// ------------------------------------------------------------------ live search words, for what the library lacks
// (references/photos.md, Live search). A trade word in --trade, or the first rule its word matches
const TERMS = [
[/yoga|pilates|wellness|\bspa\b|sauna|massage|retreat/i, ['yoga studio', 'yoga class', 'pilates studio', 'sauna interior']],
[/potter|ceramic|clay/i, ['pottery wheel', 'pottery studio', 'ceramics workshop']],
[/studio|atelier/i, ['artist studio', 'design studio', 'photography studio']],
[/bike|bicycle|cycl/i, ['bicycle workshop', 'bicycle repair stand', 'bicycle mechanic', 'city bicycle', 'cargo bicycle']],
[/hotel|guest ?house|\binn\b|lodge|b&b|bed and breakfast|hostel|cabin/i, ['hotel facade', 'guesthouse', 'hotel room', 'hotel bedroom', 'inn exterior']],
[/wedding|bridal|elope/i, ['wedding ceremony', 'wedding table', 'wedding bouquet', 'wedding reception', 'wedding rings']],
[/restaurant|bistro|brasserie|trattoria|kitchen|chef/i, ['restaurant kitchen', 'restaurant interior', 'chef plating']],
[/workshop|smith|forge|joiner|carpent|wood ?work|letterpress|weav/i, ['woodworking workshop', 'blacksmith forge', 'workshop tools']],
[/salon|barber|hair/i, ['barber shop', 'hair salon']],
[/bak(?:e|er|ery)|bread|patisserie/i, ['bakery bread', 'bakery counter', 'baker dough', 'bread oven']],
[/caf[eé]|coffee|espresso|roast/i, ['coffee shop interior', 'espresso machine', 'coffee roaster', 'cafe interior']],
[/florist|flower|bouquet/i, ['florist shop', 'flower bouquet', 'flower market']],
];
const termsFor = (word) => { const hit = TERMS.find(([re]) => re.test(String(word || ''))); return hit ? hit[1] : []; };
// a brief's word for its trade
// no trade: a business whose subject the library cannot show without passing another's work as its own
const NONE = /ceramic|potter|restaurant|bistro|brasserie|trattoria|pizzeria|sushi|patisserie|jewel|(?:gold|silver|lock|word|copper|tin|white|gun)smith|tattoo|salon|barber|hairdress|nail|tailor|gallery|museum|gym\b|fitness|dentist|\bvet\b/i;
@@ -113,7 +173,7 @@ const ALIAS = [
[/tour|guide|outdoor|hike|camp|travel/i, 'outdoors'],
[/farm|garden|nursery|grow/i, 'farm'], [/wine|vineyard|winery/i, 'wine'], [/bar\b|taproom|brew|pub|cocktail/i, 'bar'],
];
const NO_TRADE = (word) => `no trade for "${word}": the library holds ${Object.keys(lib.trades).join(', ')}, and none shows this business without passing another's work, room or goods as its own. Use the client's own photographs, else type and its real UI. A physical product: pick --product-without-photos (its own object when held, else the split hero with its live order form; never a drawing).`;
const NO_TRADE = (word) => `no trade for "${word}": the library holds ${Object.keys(lib.trades).join(', ')}, and none shows this business without passing another's work, room or goods as its own. Use the client's own photographs; until they exist, stand-ins from a live search (photos.mjs search${termsFor(word).length ? ` --trade "${word}"` : ' "<its subject>"'}), each listed in the report, else type and its real UI. A physical product: pick --product-without-photos (stand-ins of the same kind of thing; never a drawing).`;
function tradeOf(word) {
if (!word || word === true) die('name a trade: --trade ' + Object.keys(lib.trades).join('|') + ' (a physical product without photographs: --product-without-photos)');
if (lib.trades[word]) return word;
@@ -263,7 +323,7 @@ function pick() {
if (trade === '__product' && !shop) refuse(word, `"${word}" is a product`);
if (trade === '__product') die('--shop needs the shop\'s trade, e.g. --trade bike --shop');
const T = lib.trades[trade];
if (T.shopOnly && !shop) refuse(trade, `the ${trade} trade holds photographs only for a shop, workshop or rental of every make (--shop); a maker of its own ${trade} is a product without photographs`);
if (T.shopOnly && !shop) refuse(typeof word === 'string' ? word : trade, `the ${trade} trade holds photographs only for a shop, workshop or rental of every make (--shop); a maker of its own ${trade} is a product without photographs`);
const sectionsArg = opt('sections');
const sections = sectionsArg && sectionsArg !== true ? String(sectionsArg).split(/[\s,]+/).filter(Boolean) : [];
// PH-02: library frames are at most 1 in 3 of the page's pictures (--pictures is the page's whole count, 12 when not said)
@@ -365,11 +425,16 @@ function pick() {
const uses = (p) => recent.filter((d) => d.ids.includes(p.id)).length;
const last = new Set(mine.at(-1)?.ids || []);
const leadsBack = (n) => new Set(mine.slice(-n).map((d) => d.lead));
// frames another build on this machine was dealt or fetched lately (the shared recent file): behind the fresh ones,
// ahead of a better hint, so two builds of one trade in two folders do not show the same frames
const recentLog = readRecent();
const shared = recentIds(recentLog, trade);
const elsewhere = (p) => shared.get(p.id) || 0;
const jitter = new Map(pool.map((p) => [p.id, rnd()]));
// the seed ranks: a frame rated 8 or more gets a head start of 0.3 in a 0-1 draw, not a place at the top, so one seed's deal differs from the next
// (a small or soft frame goes behind the others of its subject; a frame dealt in an earlier build behind the fresh ones)
const score = (p) => jitter.get(p.id) - (p.quality >= 8 ? 0.3 : 0);
const order = (a, b) => (named(b) - named(a)) || (((a.small || a.soft) ? 1 : 0) - ((b.small || b.soft) ? 1 : 0)) || ((last.has(a.id) ? 1 : 0) - (last.has(b.id) ? 1 : 0)) || (uses(a) - uses(b)) || (score(a) - score(b));
const order = (a, b) => (named(b) - named(a)) || (((a.small || a.soft) ? 1 : 0) - ((b.small || b.soft) ? 1 : 0)) || (elsewhere(a) - elsewhere(b)) || ((last.has(a.id) ? 1 : 0) - (last.has(b.id) ? 1 : 0)) || (uses(a) - uses(b)) || (score(a) - score(b));
// the lead: strong, wide, of a subject that may open this trade's page, not one of its last leads
// none when these options leave no photograph made to lead: the hero is then the client's own, type or UI, never a row picture blown up
@@ -378,6 +443,7 @@ function pick() {
let leads = left.filter(canLead);
if (!leads.length && tone) { leads = untoned.filter((p) => canLead(p) && !chosen.some((c) => c.p === p)); if (leads.length) notes.push(`no lead in a ${tone} tone: the lead keeps its own`); }
for (const n of [3, 2, 1]) { const back = leadsBack(n); const fresh = leads.filter((p) => !back.has(p.id)); if (fresh.length) { leads = fresh; break; } }
if (leads.length) { const lo = Math.min(...leads.map(elsewhere)); leads = leads.filter((p) => elsewhere(p) === lo); } // the one other builds used least lately
// a sharp, well-made photograph opens the page whenever these options leave one (older soft, hazy files stay in rows)
const strong = leads.filter((p) => p.quality >= 7); if (strong.length) leads = strong;
if (leads.length === 1 && leadsBack(1).has(leads[0].id) && mine.length) notes.push('only one photograph can lead this trade with these options: it led last time too');
@@ -427,7 +493,7 @@ function pick() {
// a repeated word opens the row in the orientation most of its frames have (landscape on a tie), so a row is as long as the library allows
const nOf = (o) => avail.filter((p) => subj.includes(p.subject) && p.orientation === o && (!p.moodOnly || mood)).length;
const rowPref = nOf('landscape') >= nOf('portrait') ? 'landscape' : 'portrait';
const rank = (p) => [subj.indexOf(p.subject), repeated && p.orientation !== rowPref ? 1 : 0, hint && hint.test(p.alt) ? 0 : 1, motifs.has(p.motif) ? 1 : 0, clash(p), last.has(p.id) ? 1 : 0, uses(p), p.soft ? 1 : 0, score(p)];
const rank = (p) => [subj.indexOf(p.subject), repeated && p.orientation !== rowPref ? 1 : 0, elsewhere(p) * 2 + (hint && hint.test(p.alt) ? 0 : 1), motifs.has(p.motif) ? 1 : 0, clash(p), last.has(p.id) ? 1 : 0, uses(p), p.soft ? 1 : 0, score(p)];
const cands = avail.filter((p) => subj.includes(p.subject) && (!row || p.orientation === row) && (!p.moodOnly || mood)).map((p) => [rank(p), p])
.sort((a, b) => { for (let i = 0; i < 9; i++) if (a[0][i] !== b[0][i]) return a[0][i] - b[0][i]; return 0; });
const best = cands.find(([r]) => r[3] === 0) || null; // never the same motif twice: a section goes empty rather than repeat
@@ -450,7 +516,7 @@ function pick() {
if (!rule) continue;
const [, subj, hint, mood] = rule;
const row = chosen.find((c) => c.p && norm(c.section) === w)?.p.orientation;
const rank = (p) => [subj.indexOf(p.subject), clash(p), hint && hint.test(p.alt) ? 0 : 1, last.has(p.id) ? 1 : 0, uses(p), p.soft ? 1 : 0, score(p)];
const rank = (p) => [subj.indexOf(p.subject), clash(p), elsewhere(p) * 2 + (hint && hint.test(p.alt) ? 0 : 1), last.has(p.id) ? 1 : 0, uses(p), p.soft ? 1 : 0, score(p)];
const cands = avail.filter((p) => subj.includes(p.subject) && !motifs.has(p.motif) && (!p.moodOnly || mood) && (!strictRow || !row || p.orientation === row))
.map((p) => [rank(p), p]).sort((a, b) => { for (let i = 0; i < 7; i++) if (a[0][i] !== b[0][i]) return a[0][i] - b[0][i]; return 0; });
if (!cands.length) continue;
@@ -530,6 +596,11 @@ function pick() {
const leadId = got.find((c) => c.slot === 'lead')?.p.id || null;
const deal = { trade, ...(draftPath ? { draft: true } : {}), lead: leadId, ids: ps.map((p) => p.id), ...(sections.length ? { sections } : {}), seed, at: new Date().toISOString() };
writeHistory(histFile, hist, deal);
if (recentLog && deal.ids.length) {
recentLog.photos = [...(Array.isArray(recentLog.photos) ? recentLog.photos : []), { trade, ids: deal.ids, at: deal.at }].slice(-40);
writeRecent(recentLog);
}
if (shared.size && ps.some((p) => shared.has(p.id))) notes.push(`${ps.filter((p) => shared.has(p.id)).length} of these frames another build on this machine used lately: the library has no fresher frame of their subjects with these options; a live search (photos.mjs search${termsFor(word).length ? ` --trade "${word}"` : ''}) finds others`);
// a place page (every SLOTTED trade) has no band, tab or text on a photograph: its hints and words come from PLACE_PAGE
const slotted = SLOTTED.has(trade);
const where = (c) => (slotted && (PLACE_PAGE[c.slot] || PLACE_PAGE[c.p.subject])) || PLACE[c.slot] || PLACE[c.p.subject] || PLACE.landscape;
@@ -565,6 +636,103 @@ function pick() {
if (!histFile) console.log('(no --history: the next deal may lead with the same photograph)');
}
// ------------------------------------------------------------------ search
// a live search of Wikimedia Commons for what the library lacks (a yoga studio, a bike workshop's bench, a hotel's
// building and rooms, a wedding, a product of the client's kind). Results are candidates, kept in
// <out>/.taste-skill/found.json so fetch and credits treat them as library frames; every one is looked at full size and
// listed in the report as a stand-in for the client's own photograph.
const foundFile = (out) => path.join(out, '.taste-skill', 'found.json');
function readFound(...outs) {
const m = new Map();
for (const o of outs) { try { for (const p of JSON.parse(fs.readFileSync(foundFile(o), 'utf8')).photos || []) m.set(p.id, p); } catch { /* none */ } }
return m;
}
const LICENCE_OK = /^(cc0|cc[ -]by(-sa)?[ -]\d|public domain|pd\b|pdm)/i;
// a file of something other than a photograph of the subject, or one a builder cannot pass as a neutral stand-in
const NOT_A_PHOTO = /\b(logo|sign(?:age|board)?|map|diagram|drawing|poster|screenshot|chart|plan|coat of arms|flag|painting|illustration|engraving|lithograph|stamp|postcard|advert|brochure|label|render(?:ing)?|3d|cgi|collage)\b/i;
const stripHtml = (s) => String(s || '').replace(/<[^>]*>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
async function searchCmd() {
const words = positional().join(' ').trim();
const tradeWord = typeof opt('trade') === 'string' ? opt('trade') : '';
const queries = words ? [words] : termsFor(tradeWord);
if (!queries.length) die(tradeWord ? `no search words for "${tradeWord}": name the subject, e.g. photos.mjs search "${tradeWord} interior" (a game: its setting in plain words, "flooded coastal town at dusk")` : 'usage: photos.mjs search "<what the photograph shows>" [--trade <trade>] [--count 12] [--min 1600] [--orientation landscape|portrait] [--out .]');
const out = typeof opt('out') === 'string' ? opt('out') : '.';
const count = Math.max(1, Math.min(60, parseInt(opt('count', 12), 10) || 12));
const per = Math.max(3, Math.ceil(count / queries.length));
const min = Math.max(800, parseInt(opt('min', 1600), 10) || 1600);
const orient = opt('orientation');
const recent = recentIds(readRecent(), '');
const seen = new Set(), results = [], failed = [];
for (const [qi, q] of queries.entries()) {
const u = 'https://commons.wikimedia.org/w/api.php?' + new URLSearchParams({
action: 'query', format: 'json', formatversion: '2', generator: 'search', gsrnamespace: '6', gsrlimit: '50',
// photographs only: paintings, prints and drawings of the subject are left out by their categories' words
gsrsearch: q + ' filetype:bitmap -painting -paintings -drawing -engraving -illustration -lithograph -poster -map',
prop: 'imageinfo', iiprop: 'url|size|mime|extmetadata', iiextmetadatafilter: 'LicenseShortName|LicenseUrl|Artist|ObjectName|Restrictions',
});
let data = null, err = '';
for (let attempt = 0; attempt < 4 && !data; attempt++) {
try {
const r = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(30000) });
if (r.status === 429 || r.status >= 500) { err = 'HTTP ' + r.status; await sleep(2000 * 2 ** attempt); continue; }
if (!r.ok) { err = 'HTTP ' + r.status; break; }
const text = await r.text();
try { data = JSON.parse(text); } catch { err = 'busy (' + text.slice(0, 40).trim() + ')'; await sleep(2000 * 2 ** attempt); }
} catch (e) { err = e.message; await sleep(1500); }
}
if (!data) { failed.push(`"${q}": ${err}`); continue; }
const pages = ((data.query || {}).pages || []).sort((a, b) => (a.index || 0) - (b.index || 0));
const mine = [];
for (const pg of pages) {
const ii = (pg.imageinfo || [])[0]; if (!ii) continue;
const md = ii.extmetadata || {}, val = (k) => stripHtml((md[k] || {}).value);
const id = 'f' + pg.pageid, title = String(pg.title || '').replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, '').replace(/_/g, ' ');
const licence = val('LicenseShortName');
if (seen.has(id) || ii.mime !== 'image/jpeg' || ii.width < min || !LICENCE_OK.test(licence) || /\b(nc|nd)\b/i.test(licence)) continue;
if (/personality|trademark|insignia/i.test(val('Restrictions')) || NOT_A_PHOTO.test(title)) continue;
const ratio = Math.round((ii.width / ii.height) * 100) / 100;
const orientation = ratio >= 1.05 ? 'landscape' : ratio <= 0.95 ? 'portrait' : 'square';
if (orient && orient !== true && orientation !== orient) continue;
// one file of a series (the same place shot five times: "Hotel X, room (1)", "(2)"...), so the candidates differ
const series = title.toLowerCase().replace(/[\d()[\]_.,-]+/g, ' ').split(/\s+/).filter(Boolean).slice(0, 3).join(' ');
if (mine.some((m) => m.series === series)) continue;
seen.add(id);
// the plain file URL (Commons adds tracking words after "?"): fetch makes its 500, 960 and 1280 thumbnails from it
mine.push({ id, source: 'commons', url: String(ii.url).split('?')[0], width: ii.width, height: ii.height, ratio, orientation, by: val('Artist') || 'Unknown author', licence,
licenceUrl: val('LicenseUrl') || (/public domain|pd/i.test(licence) ? 'https://creativecommons.org/publicdomain/mark/1.0/' : ''), page: String(ii.descriptionurl).split('?')[0],
alt: val('ObjectName') || title, subject: typeof opt('subject') === 'string' ? opt('subject') : 'stand-in', tone: 'mid', calm: 'none',
standIn: true, query: q, series, ...(tradeWord ? { trade: tradeWord } : {}), at: new Date().toISOString() });
}
// fresh first: a file another build fetched lately goes behind the others of its query
mine.sort((a, b) => (recent.has(a.id) ? 1 : 0) - (recent.has(b.id) ? 1 : 0));
results.push(...mine.slice(0, per));
if (qi < queries.length - 1) await sleep(1200); // Commons answers 429 to quick runs
}
const keep = results.slice(0, Math.max(count, queries.length * 3));
if (keep.length) {
const file = foundFile(out);
let store = { note: 'Live search results (photos.mjs search): candidates, each looked at full size before use and listed in the build report as a stand-in.', photos: [] };
try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* new */ }
const ids = new Set(keep.map((p) => p.id));
store.photos = [...(store.photos || []).filter((p) => !ids.has(p.id)), ...keep];
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(store, null, 1) + '\n');
}
if (opt('json')) { console.log(JSON.stringify({ queries, photos: keep, failed }, null, 1)); return; }
console.log(`search: ${keep.length} candidate${keep.length === 1 ? '' : 's'} from Wikimedia Commons (JPEG, ${min}px wide or more, CC0, CC BY, CC BY-SA or public domain) for ${queries.map((q) => `"${q}"`).join(', ')}\n`);
for (const p of keep) {
console.log(`${p.id.padEnd(11)} ${String(p.width + 'x' + p.height).padEnd(10)} ${p.orientation.padEnd(9)} ${p.licence.padEnd(13)} ${p.by.slice(0, 40)}${recent.has(p.id) ? ' [another build used it lately]' : ''}`);
console.log(` ${p.alt.slice(0, 110)} (${p.query})`);
console.log(` ${p.page}`);
}
if (failed.length) console.log('\nnot searched: ' + failed.join('; '));
if (!keep.length) { console.log(`nothing fits: other words (the subject plainly, "bicycle workshop", "hotel room"), --min 1200, or leave the slot to the client's own photograph or type`); return; }
console.log(`\n- candidates only: open each page and look at the file full size. Keep a frame only when it shows the subject sharp, in plain colour, with no readable name, logo or sign of another business, no face shown as the client's staff or guests, no landmark passed off as the client's place; one light and grade across the set (references/photos.md, Live search)
- every frame used is a STAND-IN: list it in the build report with the client's photograph to shoot in its place; nothing on the page says so (no caption); write its alt text new, the file's title names someone else's place
- kept in ${slash(foundFile(out))}; fetch takes the ids like library ids:
node ${cmdPath(path.join(HERE, 'photos.mjs'))} fetch ${keep.slice(0, 3).map((p) => p.id).join(',')} --out ${out === '.' ? '.' : cmdPath(out)} (the ids you kept; --hero <id> for the lead)`);
}
// ------------------------------------------------------------------ list
// what pick may deal at all (the options narrow it further)
const dealable = (p, T) => !p.cliche && !p.weak && p.quality >= 7 && p.people !== 'figure' && (!T.only || T.only.includes(p.subject));
@@ -609,8 +777,14 @@ function list() {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Commons serves thumbnails only at its standard widths (330, 500, 960, 1280, 1920, 3840); Picsum at any width
const STEPS = [500, 960, 1280, 1920];
// found.json sits in the project and anyone can edit it: a file is fetched only from Commons' own file host, and a
// library frame only by its number
function fileUrl(p, w) {
if (p.source !== 'commons') { const x = Math.min(w, p.width || w); return `https://picsum.photos/id/${p.id}/${x}/${Math.round(x / p.ratio)}`; }
if (p.source !== 'commons') {
if (!/^\d+$/.test(String(p.id))) throw new Error('not a library frame id');
const x = Math.min(w, p.width || w); return `https://picsum.photos/id/${p.id}/${x}/${Math.round(x / p.ratio)}`;
}
if (!/^https:\/\/upload\.wikimedia\.org\//.test(String(p.url || ''))) throw new Error('not a Commons file');
if (!p.width || w >= p.width) return p.url;
const m = /\/commons\/(\w\/\w\w)\/([^/?]+)$/.exec(p.url);
return m ? `https://upload.wikimedia.org/wikipedia/commons/thumb/${m[1]}/${m[2]}/${w}px-${m[2]}` : p.url;
@@ -632,9 +806,10 @@ async function download(url) {
// the width the browser should fetch for: the shown width, or for a wide photograph cropped into a taller box
// (a hero that fills the screen, a 4:5 frame on a phone) the box's height times the photograph's ratio
const FFMPEG = (() => { try { return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; } catch { return false; } })();
// the hero is Recipe 3's top photograph: the full width of the screen and 480 px tall (a 4:3 box at 390), so the browser is told 100vw, never a vh term
// (a vh term sends a tablet and a 3x phone to the largest file)
const HERO_CROP = { w: 780, h: 584, media: '(max-width: 639px)' }; // PH-28: the phone's 4:3 crop at twice its width
// the hero is Recipe 3's top photograph: the full width of the screen and 480 px tall, so the browser is told 100vw, never a vh term
// (a vh term sends a tablet and a 3x phone to the largest file). On a phone a place hero's photograph grows from 4:3 to at most 4:5
// into what its words leave, so its crop is 4:5 (a 4:3 one shown that tall is blown up 1.7x)
const HERO_CROP = { w: 780, h: 975, media: '(max-width: 639px)' }; // PH-28: the phone's crop at twice its width
const kb = (f) => Math.round(fs.statSync(f).size / 1024);
// a JPEG as WebP (smaller at the same look) when ffmpeg is on the path
function toWebp(buf) {
@@ -642,7 +817,7 @@ function toWebp(buf) {
const r = spawnSync('ffmpeg', ['-loglevel', 'error', '-i', 'pipe:0', '-c:v', 'libwebp', '-quality', '78', '-compression_level', '6', '-f', 'webp', 'pipe:1'], { input: buf, maxBuffer: 1 << 28 });
return r.status === 0 && r.stdout.length > 1000 && r.stdout.length < buf.length ? r.stdout : null;
}
// the phone crop of a hero: cover-cropped to 780x584 around the photograph's focus, from its 1200 file
// the phone crop of a hero: cover-cropped to 780x975 around the photograph's focus, from the first file at least that tall
function cropFile(from, to, focus) {
if (!FFMPEG) return false;
const [fx, fy] = (focus || '50% 50%').split(/\s+/).map((v) => Math.min(1, Math.max(0, parseFloat(v) / 100)));
@@ -653,7 +828,8 @@ function cropFile(from, to, focus) {
}
function sizesFor(p, hero) {
const r = Math.max(1, Math.round(p.ratio * 100) / 100);
if (hero) return '100vw';
// a hero without its phone crop: on a phone the file must cover a 4:5 box (the place hero grows to it), so its width there is the box's height times the ratio
if (hero) return hero === 'crop' ? '100vw' : `(max-width: 639px) ${Math.ceil(r * 125)}vw, 100vw`;
if (p.orientation === 'landscape' && !p.wide) return `(max-width: 639px) ${Math.round(r * 125)}vw, (max-width: 1023px) 100vw, 50vw`;
return '(max-width: 1023px) 100vw, 50vw';
}
@@ -686,10 +862,12 @@ async function fetchCmd() {
const credFile = path.join(dir, 'credits.json');
let cred = { note: 'Credits for the photographs in this folder: photos.mjs credits prints the footer block for the ones the pages use.', photos: [] };
try { cred = JSON.parse(fs.readFileSync(credFile, 'utf8')); } catch { /* new */ }
const failed = [], tags = [];
const failed = [], tags = [], saved = [];
// a live search's results (photos.mjs search, .taste-skill/found.json in the site folder or here) fetch like library frames
const found = readFound(out, '.');
for (const id of ids) {
const p = byId.get(id);
if (!p) { failed.push(id + ': not in the library'); continue; }
const p = byId.get(id) || found.get(id);
if (!p) { failed.push(id + (/^f\d+$/.test(id) ? ': not in .taste-skill/found.json (run photos.mjs search with the same --out first)' : ': not in the library')); continue; }
if (p.weak) { failed.push(id + ': never dealt' + (p.why ? ' (' + p.why + ')' : '')); continue; }
const isHero = hero.has(id);
let files = [], names = [], srcName, srcW;
@@ -730,10 +908,10 @@ async function fetchCmd() {
}
const src = isHero ? (files.find((f) => f.w >= 1440) || files.at(-1)) : files.at(-1);
srcName = src.file; srcW = src.w;
// the hero's phone crop (PH-28): 780x584 around the subject, for a <picture> source at 639px and under
// the hero's phone crop (PH-28): 780x975 around the subject, for a <picture> source at 639px and under
let crop = null;
if (isHero) {
const from = files.find((f) => f.w >= 1200) || files.at(-1), name = 'photo-' + id + '-' + HERO_CROP.w + 'x' + HERO_CROP.h + '.webp';
const from = files.find((f) => f.w / p.ratio >= HERO_CROP.h) || files.at(-1), name = 'photo-' + id + '-' + HERO_CROP.w + 'x' + HERO_CROP.h + '.webp';
if (cropFile(path.join(dir, from.file), path.join(dir, name), p.focus)) crop = { file: name, kb: kb(path.join(dir, name)) };
}
names = [...files.map((f) => f.file), ...(crop ? [crop.file] : [])];
@@ -742,8 +920,8 @@ async function fetchCmd() {
const h = Math.round(srcW / p.ratio);
const srcset = files.map((f) => rel + '/' + f.file + ' ' + f.w + 'w').join(', ');
const style = [p.focus ? 'object-position: ' + p.focus : '', lqip && p.lqip ? 'background: center / cover url(' + p.lqip + ')' : ''].filter(Boolean).join('; ');
tags.push('<img src="' + rel + '/' + srcName + '" srcset="' + srcset + '" sizes="' + sizesFor(p, isHero) + '" width="' + srcW + '" height="' + h + '" alt="' + p.alt.replace(/"/g, '&quot;') + '"' + (isHero ? ' fetchpriority="high"' : ' loading="lazy"') + ' decoding="async" data-source="library-057" data-subject="' + p.subject + '"' + (style ? ' style="' + style + '"' : '') + '>' + (p.wide ? ' <!-- wide: style="--ratio: 66.7%" on its frame -->' : ''));
if (crop) { const i = tags.length - 1; tags[i] = '<picture><source media="' + HERO_CROP.media + '" srcset="' + rel + '/' + crop.file + ' ' + HERO_CROP.w + 'w" sizes="100vw">' + tags[i] + '</picture>'; }
tags.push('<img src="' + rel + '/' + srcName + '" srcset="' + srcset + '" sizes="' + sizesFor(p, isHero && (crop ? 'crop' : 'whole')) + '" width="' + srcW + '" height="' + h + '" alt="' + p.alt.replace(/"/g, '&quot;') + '"' + (isHero ? ' fetchpriority="high"' : ' loading="lazy"') + ' decoding="async"' + (isHero && !crop ? ' data-phone-fill' : '') + ' data-source="' + (p.standIn ? 'stand-in' : 'library-057') + '" data-subject="' + p.subject + '"' + (style ? ' style="' + style + '"' : '') + '>' + (p.wide ? ' <!-- wide: style="--ratio: 66.7%" on its frame -->' : ''));
if (crop) { const i = tags.length - 1; tags[i] = '<picture data-phone-fill><source media="' + HERO_CROP.media + '" srcset="' + rel + '/' + crop.file + ' ' + HERO_CROP.w + 'w" sizes="100vw">' + tags[i] + '</picture>'; }
// where the hero's tag goes: a full-bleed photograph (hero--place, hero--photo) is the section's one layer
if (isHero) tags[tags.length - 1] += crop ? ' <!-- full-bleed (hero--place, hero--photo): <picture class="hero__bg">, a direct child of the section beside its .wrap; in a frame (.hero__media): as printed -->'
: ' <!-- full-bleed (hero--place, hero--photo): <img class="hero__bg">, a direct child of the section beside its .wrap; in a frame (.hero__media): as printed -->';
@@ -752,10 +930,15 @@ async function fetchCmd() {
const over = files.filter((f) => f.kb > capKb);
if (over.length) console.log(' over the weight cap (' + capKb + ' KB): ' + over.map((f) => f.file + ' ' + f.kb + ' KB').join(', ') + ' (every file of this photograph is over it: pick another, or re-encode it)');
if (isHero && srcW < 1440) console.log(' the hero is ' + srcW + ' wide: a 1440 screen at 2x would want 2880, which the library does not hold (PH-27); the page keeps the largest file within the cap');
if (isHero && !crop) console.log(' no phone crop: ffmpeg is not on the path (the 1200 file with its object-position covers 390 until a 780x584 crop exists)');
if (isHero && !crop) console.log(' no phone crop: ffmpeg is not on the path (the phone sizes term on the img fetches a file that covers a 4:5 box at 390 until a 780x975 crop exists)');
if (!isHero && !p.files && p.source === 'commons' && !FFMPEG) console.log(' JPEG: ffmpeg is not on the path, so no WebP (PH-27 asks for WebP or AVIF)');
if (p.standIn) console.log(' a live search result: a STAND-IN for the client\'s own photograph; list it in the build report, write its alt text new');
saved.push(id);
}
fs.writeFileSync(credFile, JSON.stringify(cred, null, 1) + '\n');
// the shared recent file: the next build on this machine puts these frames behind the fresh ones
const recentLog = readRecent();
if (recentLog && saved.length) { recentLog.fetched = [...(Array.isArray(recentLog.fetched) ? recentLog.fetched.filter((x) => !saved.includes(x)) : []), ...saved].slice(-120); writeRecent(recentLog); }
console.log('\nwrote to ' + slash(dir) + '/ (' + rel + '/ in the site folder ' + slash(out) + '; a page in that folder loads them as ' + rel + '/<file>)');
const wt = pageWeight(dir, cred.photos), total = wt.reduce((a, r) => a + r.kb, 0), n = (v) => v.toLocaleString('en');
if (wt.length) {
@@ -764,7 +947,7 @@ async function fetchCmd() {
console.log('\npage images at 1440 (the file each of the ' + wt.length + ' photograph' + (wt.length === 1 ? '' : 's') + ' in this folder loads, the hero at 2x): about ' + n(total) + ' KiB of ' + n(PAGE_KIB.fail) + ' - ' + verdict);
if (total > PAGE_KIB.warn) console.log(' heaviest: ' + [...wt].sort((a, b) => b.kb - a.kb).slice(0, 3).map((r) => r.n + ' ' + r.kb + ' KiB').join(', '));
}
if (tags.length) console.log('\nthe <img> for each (the hero is sized 100vw, as Recipe 3 shows it full width, with a 780x584 phone crop in a <picture>; any other picture is sized for a half-width or full-width frame: set sizes to the shown width; the hero eager, the rest lazy, none hidden or faded in; a photograph in a nav menu, a tab, a carousel or a sideways-scrolling row drops loading="lazy", which may never fire there; data-source and data-subject stay on every picture):\n' + tags.join('\n'));
if (tags.length) console.log('\nthe <img> for each (the hero is sized 100vw, as Recipe 3 shows it full width, with a 780x975 phone crop in a <picture>; keep data-phone-fill on the hero picture or img; any other picture is sized for a half-width or full-width frame: set sizes to the shown width; the hero eager, the rest lazy, none hidden or faded in; a photograph in a nav menu, a tab, a carousel or a sideways-scrolling row drops loading="lazy", which may never fire there; data-source and data-subject stay on every picture):\n' + tags.join('\n'));
console.log('\n' + slash(credFile) + '\nonce the page\'s markup uses these files (credits reads the page for them, so not before): node ' + cmdPath(path.join(HERE, 'photos.mjs')) + ' credits --out ' + cmdPath(out) + (rel !== 'assets/img' ? ' --dir ' + rel : '') + ' prints the footer credits for the photographs it uses');
if (failed.length) { console.log('\nnot saved:\n' + failed.join('\n')); process.exitCode = 1; }
}
@@ -837,5 +1020,6 @@ function creditsCmd() {
if (cmd === 'pick') pick();
else if (cmd === 'list') list();
else if (cmd === 'fetch') await fetchCmd();
else if (cmd === 'search') await searchCmd();
else if (cmd === 'credits') creditsCmd();
else { console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0].replace(/^#!.*\n\/\*\n?/, '')); process.exitCode = cmd ? 1 : 0; }
+20 -6
View File
@@ -3,11 +3,12 @@
taste-skill shoot.mjs - a full-page screenshot of a page with live effects, and one tile per section.
node <skill>/scripts/shoot.mjs --url <url or file> --w 1440 --out shots/1440.png --profile <own chrome dir>
[--h 900] [--reduced] [--dark] [--wait 450] [--port 5300] [--tiles <dir>] [--no-tiles]
[--h 900] [--reduced] [--dark] [--wait 450] [--port 5300] [--root <dir>] [--tiles <dir>] [--no-tiles]
A file (a path, or a folder holding index.html) is served over http on 127.0.0.1 from a free port (5300-5399, or
--port and the 9 after it) for the run, from high enough that the page's ../ links resolve: Chrome runs no module
script from file://, so a page opened that way shows its kit, effects and diagrams empty. Never port 3000.
--root serves from that folder instead (a built site whose links start at its root, /style.css).
Section tiles: at 1440 and at a phone width (under 600) it first shoots one screen
per top-level section (the children of main, a body > section, a closing footer, the footer), starting at the
@@ -29,7 +30,9 @@
fixed bar or button is left out; a ground fixed behind the whole page stays. So the nav shows on
the first screen only and nothing leaves an empty gap. A held stage (a sticky panel as wide and
nearly as tall as the screen) stays held, as the visitor sees it. --dark emulates
prefers-color-scheme: dark. Uses its own Chrome profile and closes Chrome by its own PID.
prefers-color-scheme: dark. Uses its own Chrome profile and closes Chrome by its own PID. The profile (--profile) is a
folder of its own in the OS temp folder or the project's .taste-skill folder, never outside the project; a relative one
that leads out of the current folder (../prof) is replaced, with a warning, by .taste-skill/chrome-shoot.
No empty bands: smooth scrolling and scroll snapping are switched off while it shoots (a snap or
a smooth scroll that has not arrived leaves rows no screen covered), each screen is placed where
@@ -58,13 +61,23 @@ const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i < 0 ? d : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true); };
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'shoot-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-shoot-' + process.pid);
const target = opt('url'), out = opt('out'), profileArg = (p => (p && p !== true ? p : defaultProfile()))(opt('profile'));
const target = opt('url'), out = opt('out'), profileArg = (p => (p && p !== true ? p : defaultProfile()))(opt('profile')), rootArg = opt('root');
// a profile stays in the OS temp folder or the project: a relative --profile that leads out of this folder (or the folder
// itself) goes to .taste-skill/chrome-<name>
const inProject = (p, name) => {
const abs = path.resolve(p), rel = path.relative(process.cwd(), abs);
const out = !path.isAbsolute(p) && (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
if (rel !== '' && !out) return abs;
const to = path.join(process.cwd(), '.taste-skill', 'chrome-' + name);
console.error(`${name}: --profile ${p} ${out ? 'leads outside the project folder' : 'is the project folder itself'}; using ${to} (a profile goes in the OS temp folder or the project's .taste-skill folder)`);
return to;
};
// the screen's height, as consistency.mjs has it: a tablet is held upright (820x1180, 768x1024), so a hero sized
// to the screen (100svh) is shot at the height a tablet shows it
const HEIGHTS = { 1440: 900, 1366: 768, 1280: 800, 1152: 864, 1024: 768, 896: 800, 834: 1194, 820: 1180, 810: 1080, 800: 1280, 768: 1024, 430: 932, 414: 896, 390: 844, 375: 812, 360: 780 };
const W = +opt('w', 1440), VH = +opt('h', HEIGHTS[+opt('w', 1440)] || (W < 600 ? 844 : 900)), settle = +opt('wait', 450);
if (!target || target === true || !out || out === true) {
console.error('usage: shoot.mjs --url <url or file> --w 1440 --out file.png [--profile <dir>] [--h 900] [--reduced] [--dark] [--wait 450] [--port 5300] [--tiles <dir>] [--no-tiles]');
if (!target || target === true || !out || out === true || rootArg === true) {
console.error('usage: shoot.mjs --url <url or file> --w 1440 --out file.png [--profile <dir in the OS temp folder or .taste-skill>] [--h 900] [--reduced] [--dark] [--wait 450] [--port 5300] [--root <dir>] [--tiles <dir>] [--no-tiles]');
process.exit(1);
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
@@ -80,6 +93,7 @@ if (!/^https?:\/\//i.test(url)) {
// served from high enough that the page's ../ references resolve
const ups = Math.max(0, ...[...fs.readFileSync(file, 'utf8').matchAll(/(?:(?:src|href)\s*=\s*|\bfrom\s*|\bimport\s*\(?\s*|url\(\s*)["']?((?:\.\.\/)+)/g)].map((m) => m[1].length / 3));
let root = path.dirname(file); for (let i = 0; i < ups; i++) root = path.dirname(root);
if (rootArg) { root = path.resolve(rootArg); const rel = path.relative(root, file); if (rel.split(path.sep)[0] === '..' || path.isAbsolute(rel)) { console.error(`shoot: the page is not inside --root ${root}: ${file}`); process.exit(2); } }
server = http.createServer((req, res) => {
try {
let f = path.resolve(path.join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
@@ -300,7 +314,7 @@ async function tilesOf(page, outDir, o) {
return { view, ...v, line: `${view}: ${v.sections.length} sections, ${v.screens} screens, navH ${v.navH}, tiles ${v.sections.reduce((a, s) => a + s.tiles.length, 0)}; heights ${v.sections.map((s) => s.hScreens).join(' ')}` };
}
/* ---------------------------------------------------------------- Chrome */
const profile = path.resolve(profileArg);
const profile = inProject(profileArg, 'shoot');
fs.mkdirSync(profile, { recursive: true });
const portFile = path.join(profile, 'DevToolsActivePort');
try { fs.rmSync(portFile); } catch { /* none */ }
+157 -14
View File
@@ -3,12 +3,23 @@
taste-skill start.mjs - deals colours and type, then prints the Google Fonts link, the six stylesheet links
and the :root block. library/type.json is the one source of every face, weight, tracking and size.
node scripts/start.mjs deal --page <kind> [--history .taste-skill/history.json] [--dark|--light] [--axis]
[--types-only]
node scripts/start.mjs deal --page <kind> --trade <trade> [--history .taste-skill/history.json] [--dark|--light] [--axis]
[--types-only] [--no-recent]
three palettes and three type pairings for that kind of page, at random, away from the recent ones,
weighted by how well each face suits the kind (the Figma and x.ai faces first on tech and software).
--page is required. A place is dealt one serif, one plain sans and one soft sans. Add --axis when
--page is required (--trade alone names it: a cafe is a place, a law firm a service). --trade deals
the palettes made for the trade first (library/palettes.json "trades"), so two cafes do not share a
ground and an accent; a place takes only palettes whose ground fits a place (light, low saturation).
Take the first palette and the first pairing; the next only when the first clashes with the client's
own logo or photographs, said on plan line 2 with the one sentence on why the palette fits the trade.
A place is dealt one serif, one plain sans and one soft sans, the trade's group first. Add --axis when
the hero is a word or editorial one: it keeps only faces with a weight or width axis.
Trades: cafe, bakery, restaurant, hotel, wellness, studio, workshop, wedding, retail, law, clinic,
agency, saas, devtool, fintech, ai, game, hardware, consumer-product (a brief's word works too:
guesthouse, yoga studio, bike shop, florist, dentist, developer tool).
Besides the --history file, the deals can be shared across folders: set TASTE_SKILL_RECENT=<file> and the
deals of every build go in that one small file, so the next cafe in another folder is dealt other palettes.
Unset (the default), nothing is written outside the project; --no-recent leaves the file out for one run.
node scripts/start.mjs css --page <kind> --palette <id> --type <id> [--radius sharp|soft|round]
[--no-mono|--mono] [--force] [--history <file>]
prints the Google Fonts link, the six stylesheet links in their order, and the :root block (palette,
@@ -66,11 +77,82 @@ function writeHistory(file, h) {
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
fs.writeFileSync(file, JSON.stringify(h, null, 2));
}
function draw(list, n, recent) {
const fresh = list.filter(x => !recent.includes(x.id));
const pool = (fresh.length >= n ? fresh : list).slice();
for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; }
return pool.slice(0, n);
// fresh ones first in a random order, then the recent ones, the oldest first: n of them
function drawFresh(list, n, recent) {
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const fresh = shuffle(list.filter(x => !recent.includes(x.id)));
const stale = list.filter(x => recent.includes(x.id)).sort((a, b) => recent.lastIndexOf(a.id) - recent.lastIndexOf(b.id));
return [...fresh, ...stale].slice(0, n);
}
// the deals of every build that shares one file (shared with photos.mjs): a page in a new folder starts with an empty
// --history, so without it two cafes built one after the other are dealt from the same start. Opt-in: only a path
// set in TASTE_SKILL_RECENT is read or written; by default nothing is written outside the project.
const RECENT_FILE = process.env.TASTE_SKILL_RECENT || null;
function readRecent() {
if (!RECENT_FILE || opt('no-recent')) return null;
try { const r = JSON.parse(fs.readFileSync(RECENT_FILE, 'utf8')); return r && typeof r === 'object' && !Array.isArray(r) ? r : {}; } catch { return {}; }
}
function writeRecent(r) {
if (!r) return;
try { fs.mkdirSync(path.dirname(RECENT_FILE), { recursive: true }); fs.writeFileSync(RECENT_FILE, JSON.stringify(r, null, 1) + '\n'); } catch { /* optional: never stop a deal over it */ }
}
// ---------------------------------------------------------------- trades
// library/palettes.json gives each palette the trades it is made for; --trade deals those first
const TRADES = ['cafe', 'bakery', 'restaurant', 'hotel', 'wellness', 'studio', 'workshop', 'wedding', 'retail', 'law', 'clinic',
'agency', 'saas', 'devtool', 'fintech', 'ai', 'game', 'hardware', 'consumer-product'];
// a brief's word for its trade, the first rule that matches wins
const TRADE_WORDS = [
[/wedding|bridal|\belope(?:ment)?s?\b/i, 'wedding'], [/bak(?:e|ery|er)|patisserie|boulangerie|sourdough|bread/i, 'bakery'],
[/caf[eé]|coffee|espresso|barista|roast|tea ?room/i, 'cafe'], [/\b(?:brand|maker|manufacturer)s?\b/i, 'consumer-product'], [/restaurant|bistro|brasserie|trattoria|pizzeria|diner|\bbar\b|\bpub\b|wine|kitchen(?! ?(?:fitt|design|install|renovat|refit|cabinet|worktop))|eatery/i, 'restaurant'],
[/hotel|guest ?house|\binn\b|lodge|hostel|b&b|bed and breakfast|cabin|holiday (?:home|rooms?)/i, 'hotel'],
[/yoga|pilates|spa\b|sauna|massage|wellness|retreat|gym|fitness|bath ?house/i, 'wellness'],
[/dentist|dental|clinic|physio|therap|doctor|medical|\bvet\b|optician|pharmac/i, 'clinic'],
[/\blaw\b|\blaw ?firm|lawyer|solicitor|notary|legal|attorney|accountan|tax advis/i, 'law'],
[/bike ?shop|repair|workshop|smith|joiner|carpent|\bfitters?\b|forge|garage|mechanic|letterpress|weav/i, 'workshop'],
[/potter|ceramic|photo(?:graphy)? studio|studio/i, 'studio'],
// an estate, travel or insurance agent is a service business, never an AI product
[/agency|consult|architect|design firm|branding|marketing|\b(?:estate|travel|letting|insurance|property|recruitment|talent|booking) agents?\b|realtor|real estate/i, 'agency'],
[/florist|bookshop|book ?store|boutique|shop|store|grocer|\bdeli\b|delicatessen|gift|homeware|retail/i, 'retail'],
[/game|gaming/i, 'game'], [/fintech|bank|payment|invoic|accounting (?:app|software)|wallet|lending|payroll/i, 'fintech'],
[/\bai\b|\bllms?\b|machine learning|\b(?:ai|language|ml|foundation) models?\b|\bai agents?\b|\bagentic\b/i, 'ai'], [/developer|dev ?tool|\bapi\b|\bsdk\b|\bcli\b|code|database|infra/i, 'devtool'],
[/saas|software|\bapp\b|platform|dashboard/i, 'saas'],
[/hardware|device|robot|drone|sensor|keyboard|camera|printer|charger/i, 'hardware'],
[/product|brand|maker|bike|bicycle|chair|lamp|furniture|bottle|bag|shoe|skincare|cosmetic|apparel|kettle|tableware/i, 'consumer-product'],
];
// the kind a trade is when --page is left out (a yoga or pottery studio people visit is a place: pass --page place)
const TRADE_PAGE = { cafe: 'place', bakery: 'place', restaurant: 'place', hotel: 'place', wellness: 'place', studio: 'service',
workshop: 'place', wedding: 'service', retail: 'place', law: 'service', clinic: 'service', agency: 'service', saas: 'software',
devtool: 'software', fintech: 'software', ai: 'software', game: 'gaming', hardware: 'tech', 'consumer-product': 'product' };
const TRADE_NAME = { cafe: 'a cafe', bakery: 'a bakery', restaurant: 'a restaurant', hotel: 'a hotel or guesthouse', wellness: 'a wellness or yoga studio',
studio: 'a studio', workshop: 'a workshop', wedding: 'a wedding business', retail: 'a shop', law: 'a law firm', clinic: 'a clinic', agency: 'an agency',
saas: 'a software product', devtool: 'a developer tool', fintech: 'a fintech product', ai: 'an AI product', game: 'a game', hardware: 'hardware',
'consumer-product': 'a consumer product' };
const tradeArg = opt('trade');
let trade = '';
if (typeof tradeArg === 'string') {
const w = tradeArg.toLowerCase().trim();
trade = TRADES.includes(w) ? w : (TRADE_WORDS.find(([re]) => re.test(w)) || [])[1] || '';
if (!trade) { console.error(`start: --trade "${tradeArg}" names no trade; one of ${TRADES.join(', ')}`); process.exit(2); }
} else if (tradeArg === true) { console.error(`start: --trade needs a trade: ${TRADES.join(', ')}`); process.exit(2); }
// a place's ground (backgrounds.md, --g-main): light (0.80 or more), saturation 0.12 or less
const rgb01 = h => [1, 3, 5].map(i => parseInt(color.hex(h).slice(i, i + 2), 16) / 255);
const placeGround = p => { const c = rgb01(p.bg); return !color.isDark(p.bg) && color.lab(p.bg)[0] >= 0.8 && Math.max(...c) - Math.min(...c) <= 0.12; };
// plain words for a colour, for the one sentence on plan line 2 (why the palette fits the trade)
function hueWord(c) {
const [L, C, h] = color.lch(c);
if (C < 0.03) return L > 0.9 ? 'white' : L < 0.3 ? 'near-black' : 'grey';
const name = h < 20 || h >= 345 ? (L < 0.55 ? 'deep rose' : 'pink') : h < 40 ? (L < 0.45 ? 'oxblood' : 'red') : h < 70 ? (L < 0.55 ? 'brown' : 'orange')
: h < 95 ? (L < 0.6 ? 'ochre' : 'gold') : h < 115 ? 'yellow' : h < 135 ? (L > 0.85 ? 'lime' : 'olive') : h < 175 ? 'green' : h < 225 ? 'teal' : h < 275 ? (L < 0.4 ? 'navy' : 'blue') : 'magenta';
return C < 0.06 ? 'muted ' + name : name;
}
function groundWord(c) {
const [L, C, h] = color.lch(c);
if (color.isDark(c)) return C < 0.02 ? 'near-black' : `dark ${hueWord(c).replace(/^muted /, '')}`;
if (L > 0.985 && C < 0.006) return 'white';
const warm = C > 0.004 && h > 30 && h < 115, cool = C > 0.004 && h > 180 && h < 280;
return `${warm ? 'warm ' : cool ? 'cool ' : ''}${L > 0.95 ? 'off-white' : L > 0.88 ? 'stone' : 'putty'}`;
}
// make grey text readable on both grounds (4.5:1), keep its hue
@@ -113,15 +195,49 @@ const TECH = new Set(['tech', 'software', 'gaming']);
const pageArg = opt('page', opt('kind', undefined));
let page = typeof pageArg === 'string' ? pageArg.toLowerCase() : '';
page = ALIAS[page] || page;
if (!page && trade) page = TRADE_PAGE[trade]; // --trade alone names the kind
if (page && !PAGES.includes(page)) { console.error(`start: --page must be one of ${PAGES.join(', ')} (got "${pageArg}")`); process.exit(2); }
if (cmd === 'deal' && !page) {
console.error(`start: deal needs --page <${PAGES.join('|')}>: the kind decides which faces are dealt, the display weight and the mono.\n Tech (software, apps, AI, fintech) = software; technical product (hardware) = tech; add --axis for a word or editorial hero.\n node scripts/start.mjs deal --page <kind> [--axis] --history .taste-skill/history.json`);
console.error(`start: deal needs --page <${PAGES.join('|')}> (or --trade <trade>): the kind decides which faces are dealt, the display weight and the mono.\n Tech (software, apps, AI, fintech) = software; technical product (hardware) = tech; add --axis for a word or editorial hero.\n node scripts/start.mjs deal --page <kind> --trade <trade> [--axis] --history .taste-skill/history.json`);
process.exit(2);
}
let placePage = page === 'place';
let techPage = TECH.has(page);
const setPage = p => { page = p; placePage = p === 'place'; techPage = TECH.has(p); };
// the finish gate (scripts/gate.mjs) holds a build open from a deal until its checks pass. Only a deal arms it (a
// css or brand run restyles a page that may be finished): a folder with an open build keeps it, a folder one or two below
// an open build joins that one, any other gets a new one. The page is left to gate.mjs to find in this folder (or one
// below); gate.mjs --start names it.
if (cmd === 'deal') {
try {
const read = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8').replace(/^/, '')); } catch { return null; } };
// an open build here, or one or two folders up (gate.mjs --start run in the session's folder, the site in site/ or
// site/web/ of it): the nearest one is kept, so the folder never holds a second build that its PASS would not close
const open = d => { const b = read(path.join(d, '.taste-skill', 'build.json')), g = read(path.join(d, '.taste-skill', 'gate.json'));
return (b && typeof b === 'object' && typeof b.start === 'string') || (g && typeof g.start === 'string' && /^(active|fail|error)$/.test(g.status || '')); };
const here = path.resolve('.'), up = [here, path.dirname(here), path.dirname(path.dirname(here))].filter((d, i, a) => a.indexOf(d) === i);
const home = up.find(open) || here;
const dot = path.join(home, '.taste-skill'), marker = path.join(dot, 'build.json');
const m = read(marker);
if (home !== here) console.error(`start: the finish gate is already armed in ${home}; this deal joins that build`);
if (m && typeof m === 'object' && typeof m.start === 'string') {
let changed = false;
if (!m.kind && page) { m.kind = page; changed = true; }
// a build joined from below whose page is not there yet names this folder as its page (gate.mjs reads a folder as
// its index.html), so the gate finds a page two folders down too
if (home !== here && !m.url && !(typeof m.page === 'string' && m.page && fs.existsSync(path.resolve(home, m.page)))) { m.page = path.relative(home, here).split(path.sep).join('/'); changed = true; }
if (changed) fs.writeFileSync(marker, JSON.stringify(m, null, 1) + '\n');
} else if (home === here) {
fs.mkdirSync(dot, { recursive: true });
fs.writeFileSync(marker, JSON.stringify({ page: null, dir: process.cwd(), kind: page || null, start: new Date().toISOString(), by: 'start.mjs' }, null, 1) + '\n');
console.error(`start: the finish gate is armed in ${process.cwd()}; run start.mjs from the project folder (the one that holds the page)`);
}
} catch { /* the gate is optional: never stop a deal over it */ }
}
const GROUPS = { serif: 'a serif', plain: 'a plain sans', soft: 'a soft sans' };
const PLACE_GROUP = { bakery: 'serif', hotel: 'serif', restaurant: 'serif', wedding: 'serif', retail: 'plain', workshop: 'plain',
studio: 'plain', clinic: 'plain', law: 'plain', agency: 'plain', wellness: 'soft', cafe: 'soft' };
const allTypes = lib('type.json');
function suits(t, p) {
@@ -234,7 +350,10 @@ function dealTypes(h, { axis = false } = {}) {
const out = [];
if (placePage) {
const seen = [...(h.types || []).slice(-6), ...dealt];
for (const g of Object.keys(GROUPS)) {
// the trade's group first (sections.md section 4, Look): a serif for a bakery or a guesthouse, a plain sans
// for a shop, a workshop or a studio, a soft sans for a cafe; so the first pairing is the one to take
const first = PLACE_GROUP[trade];
for (const g of Object.keys(GROUPS).sort((a, b) => (b === first) - (a === first))) {
const gp = pool.filter(t => t.place === g);
const gf = gp.filter(t => !seen.includes(t.id));
const pick = wpick(gf.length ? gf : gp, () => 1);
@@ -276,19 +395,43 @@ if (cmd === 'deal') {
if (!techPage && page) pals = pals.filter(p => !color.isDark(p.bg)); // dark pages are tech only
if (opt('dark')) pals = pals.filter(p => color.isDark(p.bg));
if (opt('light')) pals = pals.filter(p => !color.isDark(p.bg));
console.log('PALETTES (pick the one that suits the brand; ignore its "note" about layout):');
for (const p of draw(pals, 3, h.palettes.slice(-12))) {
if (placePage) pals = pals.filter(placeGround); // a place's ground: light and quiet (backgrounds.md, --g-main)
// the palettes made for the trade first, away from this folder's last ones and (with TASTE_SKILL_RECENT) the trade's
// last six in the shared file; topped up from the rest of the kind only when fewer than three are made for it
const recent = readRecent();
const key = trade || page;
const shared = recent && recent.palettes && Array.isArray(recent.palettes[key]) ? recent.palettes[key] : [];
const avoid = [...shared.slice(-6), ...h.palettes.slice(-12)];
const fit = trade ? pals.filter(p => (p.trades || []).includes(trade)) : [];
const dealt = drawFresh(fit, 3, avoid);
if (dealt.length < 3) dealt.push(...drawFresh(pals.filter(p => !fit.includes(p)), 3 - dealt.length, avoid));
console.log(trade
? `PALETTES for ${TRADE_NAME[trade]} (made for the trade; take the first, the next only when the first clashes with the client's own logo or photographs; ignore a "note" about layout):`
: `PALETTES (take the first; --trade <trade> deals the palettes made for the trade; ignore a "note" about layout):`);
for (const p of dealt) {
const tk = tokens(p);
const made = !trade ? `made for ${(p.trades || []).join(', ') || 'any trade'}` : (p.trades || []).includes(trade) ? `made for ${trade}` : `from the rest of the ${page} palettes: fewer than three are made for ${trade}`;
console.log(` ${p.id.padEnd(26)} ${p.name} bg ${tk.bg} surface ${tk.surface} ink ${tk.ink} accent ${tk.accent} [${p.tags.join(', ')}]`);
console.log(` ${groundWord(tk.bg)} ground, ${hueWord(tk.accent)} accent; ${made}`);
}
if (dealt.length) {
const p = dealt[0], tk = tokens(p);
console.log(` plan line 2: "palette ${p.id}" and one sentence on why it fits ${trade ? TRADE_NAME[trade] : 'the client'}: what in the trade its ${groundWord(tk.bg)} ground and ${hueWord(tk.accent)} accent answer (the room, the material, the goods; never a fact the brief does not give)`);
}
if (recent && !opt('no-record')) {
recent.palettes = recent.palettes && typeof recent.palettes === 'object' && !Array.isArray(recent.palettes) ? recent.palettes : {};
recent.palettes[key] = [...shared, ...dealt.map(p => p.id)].slice(-12);
writeRecent(recent);
}
}
console.log(`TYPE for ${page} (all Google Fonts; headlines ${techPage ? '400 or 500' : '400'}, text 400, 500 for buttons and labels; sentence case, sizes come from system.css):`);
console.log(`TYPE for ${page}${placePage && PLACE_GROUP[trade] ? ` (${TRADE_NAME[trade]}: ${GROUPS[PLACE_GROUP[trade]]} first)` : ''} (take the first; all Google Fonts; headlines ${techPage ? '400 or 500' : '400'}, text 400, 500 for buttons and labels; sentence case, sizes come from system.css):`);
for (const t of tys) console.log(describe(t, false));
if (!tys.length) console.log(' (no pairing matches; drop --axis)');
h.dealt = [...(h.dealt || []), ...tys.map(t => t.id)].slice(-30);
if (!opt('no-record')) writeHistory(histFile, h);
if (!opt('axis') && !placePage) console.log(' (a word or editorial hero needs a weight or width axis: deal again with --axis)');
console.log(`\nthen: node scripts/start.mjs css --page ${page} --palette <id> --type <id> --radius soft --history ${histFile}`);
const self = fileURLToPath(import.meta.url).split(path.sep).join('/');
console.log(`\nthen: node ${/\s/.test(self) ? `"${self}"` : self} css --page ${page} --palette <id> --type <id> --radius soft --history ${histFile}${placePage ? '\n (the client\'s own colours, a painted shopfront or a logo the brief gives, in place of the palette: start.mjs brand)' : ''}`);
} else if (cmd === 'types') {
for (const t of allTypes.filter(t => suits(t, page))) console.log(describe(t, true));
} else if (cmd === 'css' || cmd === 'brand') {
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
// The kit must pass its own checks. scripts/gate.mjs will not let a build end while consistency.mjs reports a FAIL or
// assets/layout/check.mjs reports any finding, so every page the kit ships as a model (the hero demos, the starter and
// the moments in a page) is checked the way the gate checks a builder's page: node
// skills/taste-skill/scripts/consistency.mjs --quick --widths 1440,390 --json, then node
// skills/taste-skill/assets/layout/check.mjs --sizes 1440x900,390x844 --json, one page at a time, each in its own
// headless Chrome (each script starts it with its own profile and stops it by its PID). It prints the FAIL ids of each
// page (a layout finding as "layout <rule>") and exits 1 when any page has one, 2 when a page could not be checked.
//
// node tests/kit-clean.mjs [--profile <dir>] [--out <dir>] [--only <part of a path>] [--save <file.json>] [--check <file>]
// [--demos] [--no-layout]
//
// --demos adds every moment's own demo page (one moment alone on a stand-in page, not a page model). --no-layout runs
// consistency.mjs alone.
//
// --profile is the folder the checker's Chrome profiles go under (removed after each page), --out where the reports
// and crops go (never beside the kit's files), --only runs the pages whose path holds that text, --save writes the
// table as JSON, --check runs another copy of consistency.mjs (a before-and-after comparison). A page takes one to three
// minutes on the CPU.
//
// The starter is a template: a builder copies it beside the kit's assets folder, so it is checked from a scratch folder
// under --out holding a copy of it as index.html and a link to assets/ (nothing is written into the kit). Its bracketed
// stand-ins ("[Name]", "#[section id]", "[photo]") are the template's own: a FAIL of the checks that read words, links
// and files (placeholder, links, assets, empty-visual, lang) whose message or element names one, and a copy-* FAIL (its
// copy is all stand-ins), is listed under "template stand-ins" and does not fail the run; every other FAIL of the
// starter (its layout, its sections, its motion) does.
//
// A page that runs past 20 minutes is stopped with its whole process tree (the checker and the Chrome it started), so no
// Chrome is left behind; the page is listed as not checked.
import { readdirSync, existsSync, mkdirSync, rmSync, rmdirSync, lstatSync, writeFileSync, statSync, copyFileSync, symlinkSync } from 'node:fs';
import { join, dirname, relative, basename } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILL = join(ROOT, 'skills', 'taste-skill');
const argv = process.argv.slice(2);
const opt = (n, d) => { const i = argv.indexOf('--' + n); return i < 0 ? d : argv[i + 1]; };
const CHECK = opt('check', join(SKILL, 'scripts', 'consistency.mjs'));
const PROFILE = opt('profile', join(tmpdir(), 'taste-skill-kit-clean-chrome'));
const OUT = opt('out', join(tmpdir(), 'taste-skill-kit-clean'));
const ONLY = opt('only', '');
const SAVE = opt('save', '');
const LAYOUT = argv.includes('--no-layout') ? '' : join(SKILL, 'assets', 'layout', 'check.mjs');
// the pages: every hero demo, the starter, and the moments mounted in a page (assets/moments/_demo/in-page*.html: the
// model a builder copies a moment's section from). Each moment's own demo.html shows it alone on a stand-in page
// (_demo/demo.css: a plain nav, .container, no layout v4 screens); --demos checks those too, and their FAILs count
const DEMOS = argv.includes('--demos');
const pages = [];
const layout = join(SKILL, 'assets', 'layout');
for (const f of readdirSync(layout).sort()) if (/^hero-.+\.html$/.test(f)) pages.push(join(layout, f));
pages.push(join(SKILL, 'assets', 'starter.html'));
const moments = join(SKILL, 'assets', 'moments');
for (const f of readdirSync(join(moments, '_demo')).sort()) if (/^in-page.*\.html$/.test(f)) pages.push(join(moments, '_demo', f));
if (DEMOS) for (const d of readdirSync(moments).sort()) {
const dir = join(moments, d);
if (d.startsWith('_') || !statSync(dir).isDirectory()) continue;
for (const f of readdirSync(dir).sort()) if (/^demo.*\.html$/.test(f)) pages.push(join(dir, f));
}
const todo = pages.filter((p) => !ONLY || relative(SKILL, p).replace(/\\/g, '/').includes(ONLY));
if (!todo.length) { console.error(`kit-clean: no page matches --only ${ONLY}`); process.exit(2); }
// the starter's scratch folder goes: its assets link first, alone (a non-recursive rmdir removes a junction and never
// what it points at), then the folder; a link that will not go stops the run before anything is deleted through it
const dropSite = (site) => {
const link = join(site, 'assets');
if (existsSync(link) || (() => { try { lstatSync(link); return true; } catch { return false; } })()) {
try { rmdirSync(link); } catch {}
try { lstatSync(link); throw new Error(`kit-clean: could not remove the link ${link}; remove it by hand`); } catch (e) { if (e.message.startsWith('kit-clean')) throw e; }
}
rmSync(site, { recursive: true, force: true });
};
// the checker, stopped with its process tree when it runs past the time (spawnSync's timeout would end node alone on
// Windows, leaving its Chrome running)
const LIMIT = 20 * 60 * 1000;
const runCheck = (args) => new Promise((res) => {
const p = spawn(process.execPath, args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '', timedOut = false, error = null;
p.stdout.setEncoding('utf8').on('data', (d) => { stdout += d; });
p.stderr.setEncoding('utf8').on('data', (d) => { stderr += d; });
const t = setTimeout(() => {
timedOut = true;
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(p.pid), '/T', '/F'], { stdio: 'ignore' });
else { try { p.kill('SIGTERM'); } catch {} setTimeout(() => { try { p.kill('SIGKILL'); } catch {} }, 10000).unref(); }
}, LIMIT);
p.on('error', (e) => { error = e; });
p.on('close', (code) => { clearTimeout(t); res({ status: code, stdout, stderr, timedOut, error: error || (timedOut ? new Error(`stopped after ${LIMIT / 60000} minutes`) : null) }); });
});
// the checks a template stand-in can trip (its words, links and files), and the copy checks
const HELD = new Set(['placeholder', 'links', 'assets', 'empty-visual', 'lang']);
const rows = [];
for (const [i, page] of todo.entries()) {
const name = relative(SKILL, page).replace(/\\/g, '/');
const slug = name.replace(/^assets\//, '').replace(/[\\/]/g, '-').replace(/\.html$/, '');
const profile = join(PROFILE, slug), out = join(OUT, slug);
mkdirSync(profile, { recursive: true }); mkdirSync(out, { recursive: true });
const t0 = Date.now();
process.stderr.write(`[${i + 1}/${todo.length}] ${name} ... `);
// the starter sits where a builder puts it: index.html beside assets/
const template = basename(page) === 'starter.html';
let target = page;
if (template) {
const site = join(OUT, 'starter-site');
dropSite(site); mkdirSync(site, { recursive: true });
copyFileSync(page, join(site, 'index.html'));
symlinkSync(join(SKILL, 'assets'), join(site, 'assets'), 'junction');
target = join(site, 'index.html');
}
const r = await runCheck([CHECK, target, '--quick', '--widths', '1440,390', '--json', '--profile', profile, '--out', out]);
for (let k = 0; k < 20 && existsSync(profile); k++) { try { rmSync(profile, { recursive: true, force: true }); } catch { spawnSync(process.execPath, ['-e', 'setTimeout(()=>{},250)']); } }
let report = null;
if (!r.timedOut) try { report = JSON.parse(r.stdout.slice(r.stdout.indexOf('{'))); } catch {}
const row = { page: name, code: r.status, secs: Math.round((Date.now() - t0) / 1000), fails: {}, held: {}, warns: 0, notCheckedAt: [], error: '' };
if (!report) row.error = (r.error && r.error.message) || (r.stderr || '').trim().split('\n').pop() || 'no report';
else {
const all = [...report.widths.flatMap((w) => w.findings.map((f) => ({ ...f, at: w.width }))), ...((report.sweep && report.sweep.findings) || []).map((f) => ({ ...f, at: f.width })), ...((report.narrow && report.narrow.findings) || []).map((f) => ({ ...f, at: f.width }))];
for (const f of all) {
if (f.sev !== 'FAIL') { row.warns++; continue; }
const id = f.check + (f.rule ? ` ${f.rule.replace(/^SR-\d+ /, '')}` : '');
// the template's own stand-ins: a bracketed placeholder named by a words, links or files check (in its message or
// its element, never the section's heading it quotes), and the copy checks (its copy is all stand-ins)
const own = template && (/^copy-/.test(f.check) || (HELD.has(f.check) && /\[[^\]]{1,60}\]|%5B[^%]{1,60}%5D/.test(`${f.sel || ''} ${f.msg || ''}`)));
const into = own ? row.held : row.fails;
(into[id] = into[id] || []).push(f.at);
}
row.notCheckedAt = report.notCheckedAt || [];
}
// the layout check, as the gate runs it: every finding fails (a template's stand-ins are words, never layout)
if (LAYOUT && !row.error) {
const lprof = join(PROFILE, slug + '-layout');
mkdirSync(lprof, { recursive: true });
const l = await runCheck([LAYOUT, '--url', target, '--profile', lprof, '--sizes', '1440x900,390x844', '--json']);
for (let k = 0; k < 20 && existsSync(lprof); k++) { try { rmSync(lprof, { recursive: true, force: true }); } catch { spawnSync(process.execPath, ['-e', 'setTimeout(()=>{},250)']); } }
let rep = null;
if (!l.timedOut && l.status !== 2) try { rep = JSON.parse(l.stdout.slice(l.stdout.indexOf('['))); } catch {}
if (!Array.isArray(rep)) row.error = `check.mjs: ${(l.error && l.error.message) || (l.stderr || '').trim().split('\n').pop() || `exit ${l.status}`}`;
else for (const sz of rep) for (const f of sz.findings || []) {
const id = `layout ${f.rule}`;
(row.fails[id] = row.fails[id] || []).push(Number(String(sz.size).split('x')[0]));
(row.layout = row.layout || []).push(`${sz.size} ${f.rule} ${f.where}: ${f.detail}`);
}
row.secs = Math.round((Date.now() - t0) / 1000);
}
rows.push(row);
const n = Object.keys(row.fails).length;
process.stderr.write(`${row.error ? 'ERROR' : n ? `${n} FAIL id${n > 1 ? 's' : ''}` : 'clean'} (${row.secs}s)\n`);
}
try { rmSync(PROFILE, { recursive: true, force: true }); } catch {}
dropSite(join(OUT, 'starter-site'));
const w = Math.max(...rows.map((r) => r.page.length));
console.log(`\n${'page'.padEnd(w)} FAIL ids (widths)`);
for (const r of rows) {
const ids = Object.entries(r.fails).map(([id, at]) => `${id} (${[...new Set(at)].join(',')})`);
const extra = r.error ? `NOT CHECKED: ${r.error}` : r.notCheckedAt.length ? `NOT CHECKED at ${r.notCheckedAt.join(', ')}` : '';
const held = Object.keys(r.held).length ? `(template stand-ins: ${Object.keys(r.held).join(', ')})` : '';
console.log(`${r.page.padEnd(w)} ${[ids.join('; ') || (extra ? '' : '-'), extra, held].filter(Boolean).join(' ')}`);
for (const f of r.layout || []) console.log(`${''.padEnd(w)} ${f}`);
}
const bad = rows.filter((r) => Object.keys(r.fails).length), lost = rows.filter((r) => r.error || r.notCheckedAt.length);
const clean = rows.filter((r) => !bad.includes(r) && !lost.includes(r));
console.log(`\n${rows.length} pages: ${clean.length} clean, ${bad.length} with a FAIL, ${lost.length} not checked`);
if (SAVE) writeFileSync(SAVE, JSON.stringify(rows, null, 1));
process.exit(bad.length ? 1 : lost.length ? 2 : 0);