60 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
Leon LinandClaude Opus 5.5 822e0b9d87 tasteskill v2.5.0: professional company websites at the figma.com and x.ai bar (#187)
A complete rebuild of design-taste-frontend: one page system, effects only on tech, gaming and software pages, real product UI, photographs and icons, products always shown, and over 100 checks in a real browser at 1440, 1024, 768 and 390. Tested on a fresh install. See CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:40:44 +01:00
LeonxlnxandClaude Opus 5.5 50cb75fc27 v2.5: release date 2026-11-05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:39:42 +01:00
LeonxlnxandClaude Opus 5.5 a851fc5c20 v2.5: the short page with a photograph beside its menu
A bakery now deals 4 frames, so it is the short page, and the docs put its
second photograph beside the menu: that composition ran past one screen at
1440 (852 > 828) and left a hole at 768 with --h: 2. The short page's section
now has one tested layout: the groups and the photograph in one row (three
groups on span-3 with a 1:1 photograph, two with a 3:2 span-6), the visit
block across under them; at the tablet the photograph full width on the first
screen and the groups and visit columns on the second (one page-CSS rule,
no extra class); on phones a 16:9 photograph. 0 FAIL at 1440, 1024, 820,
768 and 390 and no check.mjs findings on the rehearsal bakery page, applied
verbatim by a second builder to a fresh copy with the same result.

Also: offer groups side by side hold the same number of rows; the Bakery
order points to the short page; photos.mjs pick prints `--out .` so its
fetch line runs as printed from the site folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:33:26 +01:00
LeonxlnxandClaude Opus 5.5 c9890c32eb v2.5: scripts run the same from a symlinked install
Node runs a script under its real path (import.meta.url) while argv[1] and
the working folder keep the link's path, so through install.sh's symlink:
- moments.mjs and backgrounds/pick.mjs skipped their command line and
  exited 0 with no output (their main-file test compared the two paths)
- shoot.mjs index, run inside the installed skill, took the skill's own
  assets/scenes for a project's and rewrote the skill's index.json
All three now compare real paths, as icons.mjs already did. Found by the
re-run of the fresh-install rehearsal (install.sh now makes a real link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:09:29 +01:00
LeonxlnxandClaude Opus 5.5 76822a3408 v2.5: fixes from a fresh-install rehearsal
A clean install built a small bakery page from SKILL.md alone and ran every
script; everything it tripped on is fixed:

- check.mjs: a split nav's logo passes centred on the page's centre line;
  check.mjs serves a page file itself (127.0.0.1:5770-5779), so the docs
  need no separate server
- the hero photograph photos.mjs fetch prints (<picture class="hero__bg">)
  is placed by layout.css and judged by its img in consistency.mjs
- SR-7: menu groups beside a photograph keep one height among themselves
  instead of the photograph's
- a place with under 8 photographs is the short page whatever its trade,
  with a tablet recipe that keeps it within its screens; what a short page
  drops from the deal; the menu group label is set off by air, not a rule
- a brief without a street prints the district only
- photos.mjs prints absolute, quoted commands (credits keeps --dir) and
  where fetch wrote; credits runs once the markup uses the photographs
- the scene index is never written into the installed skill
- start.mjs brand examples quote their colours (a bare # is a comment)
- CHANGELOG: photo and icon sources stated exactly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:00:39 +01:00
LeonxlnxandClaude Opus 5.5 c0e5dbf185 v2.5: release fixes from a fresh-install rehearsal and a docs review
- install.sh on Windows (Git Bash, MSYS2, Cygwin): plain ln -s silently copied the
  folder, so updates never arrived, a re-run skipped quietly and --uninstall removed
  nothing. It now asks for a real symlink, falls back to a directory junction when
  symlinks are not allowed, removes junctions safely, and ends with an error when a
  link fails or an older copy is in the way (uninstall reports copies it cannot remove).
- README: eleven hero layouts (word was missing), optional ffmpeg and CHROME_PATH, a
  first-run line for start.mjs and consistency.mjs, the Windows install behaviour.
- CHANGELOG 2.5.0: the real UI fragment names, eleven heroes, --profile only for the
  scripts that open Chrome, the installer fix.
- SKILL.md: Chrome, Edge or Chromium, ffmpeg optional; layout-v4.md: eleven heroes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:19:27 +01:00
LeonxlnxandClaude Opus 5.5 c707f2ac6d v2.5: one object never carries the page, slots for the client's own assets, honest reports
- A product or hero object is shown whole in the hero and two more sections at most;
  the rest are carried by its world, its use, its parts or an exploded view, close-ups,
  a still diagram or a type moment (a per-kind table in references/sections.md). The
  checker warns when one object, shown whole, is the main picture of more than three
  sections (same-object); scenes the spec declares count as use.
- client-slots recipe: a trailer frame with a poster, a strip of captures and a CAD
  frame that read finished now, each named in a comment for the client's file.
- A check whose measurement timed out reads "partly measured" (WARN) instead of PASS,
  and a product without stills is told to run make-posters.mjs.

Tested on two builds: the robot-arm page won 6 v 4.5 over its previous version; the game
page scored higher alone but lost its head-to-head on art direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:00:48 +01:00
LeonxlnxandClaude Opus 5.5 2d4644c53b v2.5: a product's own render is never counted as an effect
On a plain product page the checker failed the product render itself as a WebGL
effect ("take the effect off"): the render asks for its context while its canvas is
still off the page, so the guard could not see the [data-product] frame. The guard now
judges such a canvas again once the asking code has run and counts it as the
product's own. Found by testing a live render on a product page; a builder that
followed the old message deleted a kit file.

SKILL.md: never delete or edit the kit's own files to clear a check; report the
check and run it again after any change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:31:33 +01:00
LeonxlnxandClaude Opus 5.5 0042d7f8bb v2.5: make-posters keeps every view across runs at different sizes
A stills manifest was merged only with an earlier run at the same --px, so making
a close-up at 800 and the hero at 1100 in two runs kept only the last view. Every
variant now stays and records the size it was made at; the page already reads each
still's own size and box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 04:50:03 +01:00
LeonxlnxandClaude Opus 5.5 829d610b13 v2.5: port of the v5.2 system - big products, app window, visible hover, portable scripts
- Product pages show the product big: the hero render or photograph stands on the page
  ground with no box and fills most of the wrap; a different view in every section; the
  order card's colour buttons swap the picture (data-product-swatch, product:set); stills
  by default once make-posters.mjs has run; the checker fails a hero under 60% of the
  wrap and a repeated view.
- Software pages show their product in one app window (app-frame recipe).
- Buttons step and press, cards lift, photos zoom; reduced motion keeps colour only.
- iris-opens and line-carries-colour stay inside their section and never cut a word.
- Tablet screens are planned and checked (rhythm at 768 by default).
- Every script takes --profile, then TASTE_SKILL_TMP, then the system temp folder.
- Render kit: per-spec views, centred close-ups, still-mode fill, poster fallback without
  WebGL; demo specs ship without stills (make-posters.mjs makes them).
- Text: no test names, lab paths or agent names; CHANGELOG 2.5.0 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:38:58 +01:00
LeonxlnxandClaude Opus 5.5 94b71d8bc4 v2.5: port of the v5.1 system - product renders, photo-led place pages, game key art, kit polish
- A physical product or hardware without photographs leads with a crafted render
  (assets/objects/product-render: a JSON part spec, 17 part types, 10 materials,
  stills on phones and reduced motion, one live context) and gets a full product page.
- Place pages lead with a photograph (a remote place with its region's landscape),
  deal named photo compositions and one type moment, and print credits as one line.
- Game pages name the game and show its world as key art; effects only sit behind it.
- UI kit: loops at 2.5-4 s, reduced-motion stills inside fragments, diagram captions
  that fit, visible hover; three new tech fragments (diff-review, terminal-run,
  pipeline-board).
- consistency.mjs accepts the product render, FAILs a product page that never shows
  its product, and checks credits, hover and the tech word budget.
- README and CHANGELOG for 2.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:12:23 +01:00
LeonxlnxandClaude Opus 5.5 f2d1470444 tasteskill 2.5.0: ship cleaned 2.5 system, references, scripts and manifests
- Replace skills/taste-skill with the 2.5 page system, layout v4, UI fragments, diagrams, backgrounds, icons, photos, effects, moments, objects, scenes, scripts and references
- Keep frontmatter name design-taste-frontend with updated 2.5.0 description
- Strip lab paths, test brief names, staging references and lab screenshot folders
- Update plugin manifests, README, CHANGELOG and CI checks for 2.5.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:52:55 +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
LeonxlnxandClaude Opus 5.5 939b5640b5 v2.5: round 11f - studio chrome, a product shown without a photograph, vetted photographs
Round 11f answers review-007, round 14's verdicts and research-048. The five chrome designs both jurors scored 7 or
more are dealt at full weight, each only where its facts are real (the brief's hours or figures, the product's
window beside the headline); four near misses at half, the 30 under 5 at 0.3, one tilted thing a page. A product
with no photographs is shown by its product statement (name, true figures, named colourways, measured size) in a
hero or section built on it, with a moment on its figures; nothing needing its photograph is dealt. 87 vetted
photographs join the library, Commons ones credited by author and licence; never a whole furnished room, a menu
item in type, a place only where the brief names it, cut-outs only from the client's own, and a fresh history
leads with any good photograph of the trade (the brief's word in a picture's alt no longer decides it). The
inspector warns on another's room or menu item in the client's slot and a button on a busy photograph, and fails a
cut figure. In thirty draws: plain designs 28-29% of frame parts (39% before), every cargo-bike sheet places its
statement (none before), no design without its facts (5 before); six fresh café sheets lead with five photographs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:59:54 +01: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
LeonxlnxandClaude Opus 5.5 b5615ab1a2 v2.5: round 11e - plain chrome down, static Must show, real surfaces, one place once
Round 11e answers review-006 (pages built as written scored 4.9). The seven tested designs that were the usual
now weigh 0.3 and the library may take three frame parts. Every Must show line names what a still
shows and none is taken away elsewhere; no card, tile or blueprint with an empty middle is dealt. The surface is a
real step from the ground, studied palettes included, and an accent under 3:1 is a fill only. A software page sets
its product beside the headline from eight heroes, four of them not halves, at a size that fits beside the window.
A trade the library holds few photographs of sends the builder to search first, and two sheets of one brief share
no photograph. The inspector fails one place reached twice on one screen, the bar's action included, counts folder
tabs and a round monogram as button shapes, and fails a footer column of links to other companies. A coined name
is searched before use. In thirty draws: 4-6 of 120 parts from the seven (9 before), no hover-only or
contradicting line (6 before), every software hero beside its headline (3 of 10), every surface 6 points or more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 04:55:24 +01:00
LeonxlnxandClaude Opus 5.5 94ac7c53e5 v2.5: round 11d - honest pictures, checks that catch them, a bolder hero
Round 11d keeps another business's product, rooms and work off the client's page. Three of four pages on the 11b
commit could not be published for it, though every picture was credited. The sheet now prints a hard rule under the
photograph total: without the client's pictures the product is a designed frame and the photographs show its use,
its load and its place; a room is a detail that belongs to no one; a piece of work is the project's own still. No
product page is dealt or sent to search a product of its kind. assets.mjs falls back to Wikimedia Commons with
retries and drops old and snapshot pictures; one shared rule notes a credit that names a business. A hero built on
giant type gets its own size and stays whole above the fold; split heroes are rarer; galleries keep three
photographs; holds written in words count with the scenes; a page closes once. The inspector adds
credit-names-business and hero-cut, fails a work slot holding someone else's photograph, and counts every ground by
the screens it covers. The three failed pages now fail for what their reviews found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:26:51 +01:00
LeonxlnxandClaude Opus 5.5 b592220c47 v2.5: round 11c - patterns built as written, designed chrome, varied pages
Round 11c builds the patterns as written, designs the chrome and varies each
page. The lab rewrote every pattern to agree with the sheet, with a quality
score, the assets it needs and a line it must show. The deal skips patterns
scored 3 or below, weighs the rest against the tested designs, and never
deals one without its assets (new --have logos, map, data and 3d). Every
frame part and hero prints its Must show line. No navigation hides on
scroll: the hiding bar gives way to a band that names the section in view.
A page has one button family. Sections carry a structure, and none comes
three times on a page or twice in a row. Software heroes rotate through six
substances. A direction's own holding move counts with the scenes, and
wordmark-under-strain needs a loud tone.

The inspector adds button-family, nav-not-as-dealt and same-structure.
Contact overlays are no longer sections, and a place gets no sign-up card as
its contact. In thirty draws over seven briefs no pattern lacks its assets,
no navigation hides, and every sheet shows all five Must show lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:25:19 +01:00
LeonxlnxandClaude Opus 5.5 5ac696986a v2.5: round 11b - pictures of the trade, two grounds, portfolio work, footers as dealt
Round 11b answers the round-11 tests and the variety audit. The deal keeps
photographs, techniques, entrances and hero shapes in its history, and
remembers each sector's last direction, hero shape and entrance. Pictures
are matched to the trade the brief names, with a sheet line on what they
show and never show. A product page is never without pictures and marks a
slot for the client's own shot. The sheet gives a photograph total for the
page, allows two grounds, asks a portfolio for pictures of its work in
marked slots, counts a pinning section as a scene, prints a scene-carried
section as its scene and has the footer built as dealt.

The inspector adds footer-not-as-dealt and portfolio-without-work, and
counts grounds and panels once under surface-count. Scenes take T4's
fix-007: object-through-giant-type, letter-physics, and peel-tear-and-drag
dealt again as a half-weight drag for brands that fly. one-figure-reforms
sets its panes on the accent, the closing demos end on designed footers,
and the window-relay and fly demos are fixed. assets.mjs searches Openverse
by default. In thirty draws over seven briefs, no direction, hero,
technique, entrance or lead photograph repeats on two draws of one brief in
a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:32:24 +01:00
LeonxlnxandClaude Opus 5.5 079a1cf5d0 v2.5: round 12 - the design-anatomy patterns in the deal
Ships library/patterns/ (1,097 patterns from 107 great sites: heroes,
sections, navigation, footers, cards, buttons, type and motion, with no
site names). The draw deals the navigation, footer, cards and buttons from
the twelve tested designs plus the fitting patterns; heroes and sections
widen with them, and each page gets one to three small moves and a type
note. Every design or pattern is kept out for nine draws. Thirty draws over
seven briefs put no hero, navigation, footer, card style, button or section
on more than three pages, most on two.

The sheet prints each pattern's description, its house terms and its phone
plan once, and says an id is only a handle and that the dealt entrance
decides arrivals. Integration fixes: software heroes show the interface; no
stock buttons, scrambles, bento, canvas cards, peeking rows or empty
footers; small moves no longer ride on the button or repeat on consecutive
pages; long pattern pins get the two-or-three-screens note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:24:51 +01:00
LeonxlnxandClaude Opus 5.5 de63a423aa v2.5: round 11a - Leon's review: cards, space, less text, varied nav, footer, cards and buttons
Round 11a answers Leon's review of 29 October (everything looked the same, cluttered, too
much text, documentary photographs, too few cards, weak structure).

The inspector now passes filled cards in any number. It fails boxes in boxes, boxes on
photographs, shape-cut tickets, outlined rows and pages made only of boxes. It adds
hero-not-alone (the hero fills the first screen alone), section-air (real space between
sections), edge-run (nothing runs into one screen edge), announcement-bar and nav-sentence,
cropped-lettering, and stricter word budgets; template-hero now catches a centred name over
two cards.

The deal gives the navigation, footer, cards and buttons twelve designs each, kept away
from recent draws (at most 2 in any 8), along with sections, heroes and scenes. It keeps
the hero alone on the first screen at 100svh, puts 16vh or more between sections, keeps
dealt margins on both sides, cuts the words and drops documentary photographs. The footer
is a designed section, never a single line. The rules, tells, layout notes and the sheet
name every check by its id.

Twenty scenes take the testers' audit fixes, and three new scenes join
(closing-line-in-a-field-of-words, dot-becomes-the-full-stop, window-names-its-parts; demo
photographs on scene-media 4517053, 0359e99, 1ed5848). raster-cut is retired,
fly-into-the-shape is dealt as 'out' only, one-figure-reforms is not dealt until its panes
stop being boxes in a box, and the demos of dealt scenes fill their first screen alone.

scripts/lib/cdp.mjs only ever stops the Chrome it started: a closed or crashed browser is
never killed again at exit (its pid is cleared and its exit hooks removed, so a reused pid
is never hit), a failed launch stops its own Chrome, a dropped socket fails every pending
call, and eval takes a timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:21:04 +01:00
LeonxlnxandClaude Opus 5.5 f7e77ca5dc v2.5: round 10 part B - stricter rest checks, section 9 always dealt, six scenes in, seven out
From the round-9 tests r9-p41 (notes app), r9-p42 (Haulet) and r9-p43 (Hvit).

The inspector fails gradients and pale veils over photographs, violet and periwinkle fills,
running text under 4.5:1, held headings cut off on tablet and phone, and boxed lists or
lone headings in a scene's rest. It warns on stats bands, a second email form, repeated
splits, run-out columns, dead controls, uncredited pictures and faint replay beats; the
framed-elements check is provisional and is reworked in the next commit (cards with a fill
are wanted). scenes.mjs warns on tilted letters and pieces sliced by the screen edge.

The deal always prints section 9 (a replay page moves the replay or takes a technique when
no scene fits), never greys pictures unless the brief asks for black and white, keeps
palettes off violet with muted text at 4.5:1, breaks runs of the same split, and ends a
place people book on a booking block. Photo rules: no blurred faces, never one photograph
twice, never another maker's product or another business's rooms shown as the client's,
a picture under a line shows what the line names. The replay is louder: content fills its
window, text 18px or more, each beat changes about a third of the window.

The stage rests every scene under reduced motion at every width. hinged-sheets runs on a
clock and is no longer dealt; shaped-window-over-fixed-world opens on one picture.

Six scenes built by the testers from the library are merged: letter-bar-floods-the-ground
(0.6), seam-pocket-gathers-the-words, speed-bent-row, panels-reproportion-on-arrival,
object-lowered-onto-the-last-line (0.7) and through-the-openings (demo photographs on
scene-media 83705a5, 6e69e09). Seven are retired after the testers' audit:
twin-image-scanner, warp-jump, light-flood, depth-card-flythrough, route-journey,
stepped-columns-raise-a-stage and arrive-and-settle-track. orbit-to-formation holds 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:14:20 +01: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
LeonxlnxandClaude Opus 5.5 8bc269818b v2.5: round 10 part A - the page looked at again at rest, the hero binding, a real visit block
From the round-9 test r9-p44 (Kaffee Klee). After the film the inspector looks at the page
again at rest (scripts/lib/probe-rest.mjs). It now fails clipped text (a list cut short by
its stage; one line of display type cropped at the side or foot is a crop and passes), a
picture shown three times at one width, and the template hero stack (a centred headline,
subline, two actions and a rounded picture card). It warns on restated headings, a short
label held over step statements, and a visit block with no street, district or map link.

The deal makes the hero's geometry binding and bans the template stack and bare category
headlines; object-on-plain-ground needs a product photograph. A place people go to gets a
real street without a house number, the district, the stop or the way there, the hours and
a directions link, marked as sample copy. The sheet names the trade's stock shots; stepped
holds get a picture per step where the steps carry pictures, and display-size statements.

thumbnail-becomes-the-stage opens from large thumbnails, keeps the whole statement as its
heading and moves its notes into the page's flow on phones, so no coffee is clipped.

The techniques the deal draws are re-exported from the 379-entry library (merge 7): 45,
three of them transitions and three closings, with texts rewritten free of studied sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:49:28 +01: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
LeonxlnxandClaude Opus 5.5 47c7dee7cb v2.5: two new scenes - closing-cut-under-the-rise and window-relay-with-stamps
Both built fresh by the testers from researched library techniques (lib-0241, lib-0237),
reviewed on their contact sheets and merged with their catalogue entries:

- closing-cut-under-the-rise (camera through space, closing): the subject, masked from the
  closing photograph, rises out of a flat room of that photograph's own wall and floor while
  the camera ducks under it; at the cut the room becomes the photograph with the subject back
  in place, and the last sentence lands on a short band of the floor's tint with the action.
  Needs product-photo and one picture; hold 2.
- window-relay-with-stamps (media sequence, middle): the next picture opens as a window over
  the current one while the old picture shrinks into a trail of at most four stamps; the name
  changes in one step. Needs four pictures; five hold 2.4 screens.

Demo photographs are on the scene-media branch (b457929: CC0 teapot set, ColBase CC BY 4.0
Raku bowls, credits in CREDITS.md). scenes.mjs runs every scene at all three sizes with no
frame over 34 ms; inspect --film: teapot 0 FAIL 0 WARN, bowls 0 FAIL 1 WARN (off-centre
measures the chip's oversized picture, every visible edge sits on the 40px margins).
The catalogue now holds forty scenes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:44:18 +01:00
LeonxlnxandClaude Opus 5.5 cec6ef61af v2.5: round 9 - a replay that registers, honest pictures, stricter checks
The product replay no longer draws a cursor or plays micro changes: each beat
is one large change (the item at work takes the accent fill, a panel opens on
the window, the view zooms to a whole block), the window starts in its first
state, and the inspector measures what visibly changes (replay-faint,
replay-bands, replay-cursor, replay-nested).

Pages short of pictures are dealt scenes and techniques that need one
photograph or none, and a redraw never deals the same scene twice. A product's
photographs stay of its own kind and show it in use; light-named clients get
bright pictures and a light ground; N listed rooms or dishes get N pictures;
facts name real towns, roads and ferries; visit details print the area and
hours, never a made-up street or a zeroed phone; split-hero headlines are 5vw
or more. Monochrome sources are kept to the black-and-white treatment, and the
dealt technique's sheet opens with what to avoid.

New inspector checks: split-hero-title, placeholder contacts, small list and
table text, text on photographs at every width, letters left half-drawn on
phones, missing pictures, drawn diagrams and charts, tile walls, slider-like
tracks. The skill's own heroes, sections, imagery rule and scene demos were
brought in line with them. New scene: line-carries-colour-swap (lib-0240);
techniques re-exported from the 363-entry library (38 dealt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:23:12 +01:00
LeonxlnxandClaude Opus 5.5 f05a86f7a4 v2.5: the deal reaches into the researched library; three new scenes
About one page in two, draw.mjs now deals a researched technique from
library/techniques.json in place of a scene: 36 big moves studied in
award-winning sites' code and written up as principles (what it is, a fresh
direction, what breaks it), exported from the lab's library without sources,
notes, site, brand or person names. Techniques are placed by the scene rules
(sector, what the brief really has, a free role, the length budget, one scene
on software pages) and the sheet prints each as one to build fresh on the
shared stage and check with scenes.mjs. --technique <id> forces one,
--no-technique turns it off.

Three object-scrub scenes join the library (37 in all): one-figure-reforms
(a window re-forming through the chapters, carrying each chapter's own product
interface) and product-assembles-into-its-action (the product's screen builds
itself step by step and folds into its live action) for software pages, and
closing-object-turns (one photographed object turns over to show the closing
line and the action). Demo photographs on the scene-media branch.

A brand the brief does not name gets a coined name, reported as a placeholder:
builders twice named a notes app after a real company's trademark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:37:41 +01:00
LeonxlnxandClaude Opus 5.5 134b6cd21f v2.5: round-6 fixes, safety rules and the sheets-tip-in-and-away scene
The product replay is held and stepped by scroll and sized as the page's big
moment; the sheet, SKILL.md and motion.md describe it the same way. Scenes
declare what they need (pictures, a mark, a route, product UI), are redrawn
twice at most, then taken by id or dropped (--redraw scenes=none) with a
shorter length budget, so a page without photographs of its own no longer
loops on scenes it cannot fill. The inspector adds lone-heading, boxed-list,
decorative-control, greyed-row contrast and replay-small/not-held/unseen.

Colour pages (food, flowers, seasons, colour-named clients) are never dealt
black-and-white pictures. Rooms, dishes and products are shown with a
photograph of their kind, never boxed. A product without photographs is shown
as one configuration, large. Interface pages are dealt only heroes that show
the product large, and sit in the page's own ground.

Two safety rules from the preview review: no form that sends a visitor's
details is wired to a guessed address, and stand-in photographs show no other
maker's name or logo, no signs, house numbers or plates, and no identifiable
children. Directions, heroes and blueprints that put a picture inside a
sentence, fill tiles or empty the left of a row are rewritten or retired.

New scene: sheets-tip-in-and-away (lib-0189), chapters as full-screen sheets
that land tilted and rest flat; demo photographs on the scene-media branch.
Scene demos carry no contacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:21:52 +01:00
LeonxlnxandClaude Opus 5.5 a30aedadc2 v2.5: round-5 follow-ups and the page-lifts-off closing scene
- A colour, material or light in the client's name sets the ground; the
  sheet orders a palette redraw when the dealt one contradicts it (a
  guesthouse named "white" got a navy ground and lost).
- No unchecked facts about real places (a page claimed "forty minutes from
  Reykjavik" for the Westfjords).
- Sample contacts are unmistakably fictional: a country's reserved range,
  otherwise a patterned number; the brand's own domain for email.
- Product pages without product photos show the product type large in real
  photographs of it in use (never named or priced as this model) in the
  hero and two sections; a picture of what the name means never stands in
  for the product (a cargo-bike page showed only a tern bird and lost).
- stepped-columns-raise-a-stage and scatter-and-lock are no longer dealt
  (judged hollow or a stock trope twice each); --list scenes marks them.
- dither-field-opener caps its mark from the stage's narrower side, so a
  4-letter mark stays whole at 768 and 390.
- New closing scene page-lifts-off-closing-picture (built in the lab from
  library entry lib-0006): the last section lifts off a picture or product
  screen settling from 1.35 to 1; demo media on scene-media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:03:40 +01:00
LeonxlnxandClaude Opus 5.5 6b21f9ff58 v2.5: round-4 fixes and a dealt product replay for software pages
Most of round 4's faults were dealt by the skill itself (a hero blueprint
with the headline in a card, a contact blueprint at 7-9vw, a bento feature
grid, a menu on a paper panel, link-column footers on one-page sites).
Those heroes and blueprints are banned or rewritten; one-page sites get no
sitemap footer; the type deal drops soft geometric display faces over
serifs and same-class pairs.

Scenes: no drawings, tracks or progress bars inside a scene (stepped
columns loses its wheel and rail; pinned-chapter-switcher its bars);
fly-into-the-shape is a flat mask with the photo inside from frame one;
the work fly-through stops each piece flat at 60% width; closing scenes
hold one screen per line (light-flood 1.2, wordmark 1.2 on phones).

Software pages (interface imagery) get one dealt product replay from six
patterns (library/replays.json, assets/replay.js): the product's own
fragment plays 3-5 states with real labels and a Replay control when it
scrolls into view, never on the hero, with at most one scroll scene.

Inspector: giant-contact anywhere, hero-in-card, range-slider, bento,
line-holds, held-dead-screen, scene-drawing, scene-track, small-body,
broken-address, result-first, surface-count, hollow-fragment and replay
checks; scenes.mjs piece-unseen and empty-steps. Each one fires on the
round-4 page that had the fault and not on round-3's p21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:31:33 +01:00
LeonxlnxandClaude Opus 5.5 71aae5b5cc v2.5: two new scenes from the library, both fit software pages
Built in the lab from library entries (fresh builds of the principle,
never a source site's code or look), reviewed frame by frame by T2:

- parts-lock-into-the-whole (lib-0024, grid-reassembly, middle, hold 1.6):
  a huge word steps back while the product's own interface flies in as
  its panels and locks; data, comments and an agent's edit ink in under
  one rewriting caption; it ends as a column beside a spec list.
- dither-field-opener (lib-0004, colour-and-ground, opening, hold 0): a
  numeral turns inside a one-bit dither field; a fast scroll sends accent
  bands across it. No pictures; crisp at DPR 1-3.

Both pass scenes.mjs and the inspector. Software sectors were dealt from
8 scenes; 30 dealt sheets now spread over 10, and arrive-and-settle-track
falls from half of all sheets to under a quarter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:39:20 +01:00
LeonxlnxandClaude Opus 5.5 6ddd0e4acf v2.5: the entrance is dealt, from seven styles
Leon's cross-model pages all opened the same way: masked headline lines
rising (7 of 11) and a clip-path shutter on the hero picture (9 of 11),
both taught by the skill as the default. draw.mjs now deals one of seven
entrances per page, seeded and weighted, filtered by tone, by what the
first screen shows (photo, interface, type only) and by the page's other
moves: Already there, Focus pull, Exposure, Product at work, Aperture,
Line blocks, and Set type (the old pair) at weight 0.3.

Each style is a base.css recipe keyed by body[data-entrance] with its own
below-the-fold rule (no fade-up on every block), everything hidden only
under .js and prefers-reduced-motion: no-preference, settled by 1.24 s.
Rendered frame by frame at 1440 and 390 and reviewed; Doors was dropped
(a full-screen cover at first paint is a preloader). motion.md "The
entrance is dealt" replaces the Load sequence default; --redraw entrance
and --list entrances; the inspector checks the dealt entrance was built.
Demo: assets/entrances/demo.html?e=<id>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:36:14 +01:00
LeonxlnxandClaude Opus 5.5 9544064aba v2.5: deal scenes by weight; the six that models repeat deal less often
Leon's 11 cross-model test pages mounted only six of the 30 scenes
(scroll-turned-drum on 5, fly-into-the-shape on 4, thumbnail, arrive,
stepped columns, raster cut). scenes.json gets a weight for those six and
dealScenes picks by weight. For software sectors the pool is still the
same 8 scenes, so the lasting fix is new scenes that carry interface
content (library work in the lab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:54:02 +01:00
LeonxlnxandClaude Opus 5.5 b2a5350e84 v2.5: real logos for known products, never drawn
assets.mjs --logo saves real marks from Simple Icons 16.35.0 (pinned,
currentColor) into assets/logos and says where to get the ones it lacks
(Slack, Microsoft, OpenAI, xAI: the brand's press kit). SKILL.md,
imagery.md and the direction sheet now say so: no drawn logos, no initial
in a circle, a row of logos in the page's ink, never invented customers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:56:14 +01:00
LeonxlnxandClaude Opus 5.5 67a5015b10 HANDOFF: only the last commit is untested
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:28:10 +01:00
LeonxlnxandClaude Opus 5.5 55dfd617d6 HANDOFF: state after round 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:43 +01:00
LeonxlnxandClaude Opus 5.5 254909555e v2.5 round-3 fixes: no page-curl scene, type scenes for creative pages, work shows work
Round 3 won 2 of 3 (the notes app on Opus and Kaffee Klee on Fable, both lost
in round 2). Mara Lind lost again: its work slots showed skylines and its
closing wordmark scene came to rest broken.
- peel-tear-and-drag is no longer dealt (judged cheap twice)
- type-flip-relayout joins the atmosphere scenes (creative pages only)
- stepped-columns-raise-a-stage no longer carries pricing (hollow cards twice)
- SKILL.md: a stock or location photograph never stands in for a piece of work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:22 +01:00
LeonxlnxandClaude Opus 5.5 7daebfd6f5 v2.5: no drawn illustrations; a word budget for the page
- imagery.md §8 rewritten: do not illustrate. SVG is for the logo, real
  charts, maps and plans with labels, and icons inside product fragments;
  a missing picture becomes type, the product's interface or a real photograph
- inspector: drawn-illustration (FAIL, a large SVG picture of a thing),
  svg-doodles, wordy-hero (over 45 words on the first screen), wordy-section
  (over 130 words), wordy-page (WARN over 70 words a screen, FAIL over 95);
  thin-page only under 15 words a screen
- SKILL.md and the sheet: the whole page is about 300-500 words, a section
  20-80; product slots without photographs carry the product's facts in type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:58:30 +01:00
LeonxlnxandClaude Opus 5.5 d357baaba8 HANDOFF: state after test rounds 1 and 2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:23:23 +01:00
LeonxlnxandClaude Opus 5.5 0f9a7a32da v2.5 round-2 fixes: bigger heroes, shorter scenes, no hollow boxes
From the second blind round (3 of 6 won; the losing pages had long scenes with
broken-looking mid-frames, small heroes, pictures that did not show the subject,
hollow cards, colour drift and a desktop Menu button):
- type: the hero's display step is dealt around 5-7.6vw (was 3.4-6.6)
- scenes: the dealt hold is capped at two screens (three on loud or creative
  pages), and the sheet says every frame must read as a finished layout
- colour: deep grounds stay near neutral; the accent moves to the subject's
  own colour when it has nothing to do with it
- frame: the Menu navigation keeps its links inline on desktop
- inspector: repeated-picture, repeated-heading, hollow-box, switch-list
- SKILL.md: the first picture shows the product or the place; each photograph
  once; otherwise a type-led hero

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:22:30 +01:00
LeonxlnxandClaude Opus 5.5 50eac221f6 v2.5: twelve scroll scenes repaired; scripts serve ../ references
- lens-over-thumbnail-stream: names relay strictly in turn (no overlap)
- thumbnail-becomes-the-stage, twin-image-scanner: documented recolour passes
- fly-into-the-shape: the title is fitted inside the mark, never split
- shaped-window-over-fixed-world: title and caption clear of the windows
- descend-through-weather: clouds wait for the title to clear
- instrument-dial-stage: the picture stands under the name
- wordmark-under-strain: echo letters are drawn, not text; the fall stays in
  its column
- object-through-giant-type: flights checked against lines and letters
- warp-jump: one screen a beat, plates leave along their ray (hold 3)
- letter-physics: the answer lands sooner (hold 1.8); picture-develops keeps
  developing to 0.88 (hold 2)
- inspect.mjs and scenes.mjs serve from high enough that a page's ../ links
  resolve, so a scene demo can be checked by its file path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:38:25 +01:00
LeonxlnxandClaude Opus 5.5 5dabfc2ca9 v2.5 round-1 fixes: dead-scroll check, atmosphere scenes gated, readable type
From the first blind test round (v2.5 won 3 of 5; the two losses were notes
pages whose atmosphere scenes, tiny mock-up text and dark card-only pages
the judges marked down):
- inspector: dead-scroll measures words and real pictures on every screen of
  the walk and the scroll film (a glow, a burst of lines or an empty stage is
  not content); tiny-mockup-text, placeholder-contact, builder-note; no-nav
  wants a menu or three links on tablet and phone
- scenes: atmosphere scenes (warp-jump, light-flood, letter-physics,
  wordmark-under-strain, object-through-giant-type) only for loud or creative
  pages, one at most; curtain footer no longer dealt
- type: section headings at least 2.25x the body, the headline at least 3x;
  no hairline text faces on dark grounds (colour is dealt first); no light
  display weight at modest sizes
- bento heroes banned, the tile-board direction rarer; a coloured page ground
  keeps one hue
- SKILL.md: software shows itself large and readable plus one large picture
  that is not a card; archive photographs when they are the idea; complete
  pricing and booking; nothing on the page talks to the client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:08:39 +01:00
LeonxlnxandClaude Opus 5.5 46e9927e4a v2.5: type.md matches the dealt scale; mock-up crops on desktop are not cut text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:18:18 +01:00
LeonxlnxandClaude Opus 5.5 be03e9fda4 v2.5: phone and tablet breakage checks, concise app screens, banned sections
- inspector: cut-text (text cut by the screen edge or a clipping box, mock-ups
  included), ellipsis, sticky-band on phone and tablet, form-in-sentence,
  no-nav; app screens get sentence-title and app-wordy, page fragments
  fragment-wordy
- app-screens.md: words few and never about the interface
- eleven section blueprints never dealt (drag rows, sideways strips, the
  enlarged progress bar, folder and index-card tabs, sliders, stacked colour
  sheets, the fan of cards)
- the sheet: blueprints as shapes, not geometry to trace; reference cards
  show only the idea; video only as dealt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:09:53 +01:00
LeonxlnxandClaude Opus 5.5 fade7eca82 v2.5: deal everything at random, restrained colour, real type pairings, new checks
- draw.mjs deals one sheet (no choosing among options): weighted directions
  (the ones every model reached for are rarer, loud ones only for a loud
  tone), five professional directions and six professional heroes, banned
  heroes (pictures in letters, folder tabs) never dealt
- type from a 169-face collection checked against Google Fonts and
  Fontshare: nine kinds of pairing, real weights, a six-step scale with
  display sizes measured on professional sites (about 4vw, not 14vw)
- colour: restrained generated palettes in OKLCH with contrast checks,
  loud library palettes only for a loud tone
- section 0, the frame: navigation, footer, corners, spacing, picture
  treatment, and whether the page gets a tool or a video at all
- pictures: fewer, sharper, larger; no archive scans by default; credits
  in an "Image credits" dialog, never a footer wall; 1800px minimum
- inspector: picture-in-headline, picture-in-sentence, giant-footer,
  credit-wall, upscaled-picture, colour-sprawl/colour-ground, drag-bar,
  sideways-rail, boxed-page, label-clutter, text-wall, empty-left; video no
  longer required
- SKILL.md and references rewritten to match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:58:45 +01:00
1765 changed files with 127699 additions and 78115 deletions

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "taste-skill",
"description": "tasteskill: art direction for coding agents, for websites, product pages, dashboards and app screens, 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 v2: art direction for websites, product pages, dashboards and app screens, with a library of researched directions, real pictures from open collections and a real-browser inspector.",
"version": "2.1.5",
"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": {
@@ -19,7 +19,7 @@
"homepage": "https://github.com/Leonxlnx/taste-skill",
"repository": "https://github.com/Leonxlnx/taste-skill",
"license": "MIT",
"keywords": ["skills", "frontend", "design", "taste", "ui", "dashboard"]
"keywords": ["skills", "frontend", "design", "taste", "ui", "websites"]
}
]
}
+9 -8
View File
@@ -1,7 +1,7 @@
{
"name": "taste-skill",
"version": "2.1.5",
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, 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"
@@ -14,21 +14,22 @@
"frontend",
"design",
"taste",
"ui"
"ui",
"websites"
],
"skills": "./skills/",
"interface": {
"displayName": "Taste Skill",
"shortDescription": "Art direction for websites, dashboards and app screens.",
"longDescription": "tasteskill v2 from Leonxlnx/taste-skill: researched directions, real pictures and a real-browser inspector for websites, product pages, dashboards and app screens.",
"shortDescription": "Professional company websites, checked in a real browser.",
"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": [],
"websiteURL": "https://github.com/Leonxlnx/taste-skill",
"defaultPrompt": [
"Design a distinctive frontend without generic AI patterns.",
"Redesign this interface while preserving its brand.",
"Build the dashboard for this product with real data and charts."
"Design a professional company website without generic AI patterns.",
"Redesign this landing page while preserving its brand and content.",
"Build a product page with real UI cards and one section per screen."
]
}
}
+11 -3
View File
@@ -20,6 +20,14 @@ jobs:
- name: Text files are clean UTF-8 (no double encoding)
run: node tests/text-encoding.mjs
- name: Scripts parse
run: for f in skills/taste-skill/scripts/*.mjs skills/taste-skill/scripts/lib/*.mjs skills/taste-skill/assets/*.js skills/taste-skill/assets/scenes/*.js; do node --check "$f" || exit 1; done
- name: Browser profile cleanup and inspector file names
run: node --test tests/profile-cleanup.mjs tests/inspection-files.mjs
run: |
node -e '
const fs = require("node:fs"), path = require("node:path"), { execFileSync } = require("node:child_process");
const walk = d => fs.readdirSync(d, { withFileTypes: true }).flatMap(e => {
const p = path.join(d, e.name);
return e.isDirectory() ? walk(p) : /\.(mjs|js)$/i.test(e.name) ? [p] : [];
});
for (const f of [...walk("skills/taste-skill/scripts"), ...walk("skills/taste-skill/assets")]) {
execFileSync(process.execPath, ["--check", f], { stdio: "inherit" });
}
'
+1 -5
View File
@@ -1,9 +1,5 @@
.taste-skill/
.taste-pro/
node_modules/
.DS_Store
Thumbs.db
# Scene demo media lives on the scene-media branch (served by jsDelivr), never in the skill folder
skills/taste-skill/assets/scenes/demo/media/
skills/taste-skill/assets/scenes/demo/*/
+46
View File
@@ -4,6 +4,52 @@ 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`).
### What's new compared with 2.1.x
- **One page system (`starter.html`, `system.css`, `system.js`, `assets/layout/`):** Every build starts from a shared HTML/CSS foundation instead of open-ended layout generation. The hero fills the first screen alone, every body section and `footer.closing` is planned to one or two full screens at `1440x900` with no neighbour peeking into a section tile, and left and right content edges match across the page. `assets/layout/deal.mjs` deals eleven tested hero layouts (`split`, `centred`, `product`, `photo`, `place`, `collage`, `editorial`, `word`, `film`, `statement`, `triptych`), verified by `assets/layout/check.mjs` including max-headline stress tests.
- **Strict business-kind routing (effects only on tech and gaming pages):** Every page declares its kind up front (`software`, `gaming`, `tech` for technical hardware, `product`, `service` or `place`). Shader grounds (`scripts/effects.mjs`, `assets/effects/`), creative scroll and pointer moments (`scripts/moments.mjs`, `assets/moments/`), scroll-held big scenes (`assets/scenes/`) and WebGL object prints (`assets/objects/`) are dealt only on `software`, `tech` and `gaming` pages within a shared cap of 4 live WebGL contexts, and every effect has a static phone and reduced-motion state. Cafes, hotels, shops, studios, services and non-technical products get zero WebGL or scroll effects and lead with photographs, typography and flat colour.
- **Real product UI fragments, components and diagrams:** `assets/ui/` ships 19 HTML product UI fragments (`chat`, `diff-review`, `pipeline-board`, `stat`, `notify`, `terminal-run`, `voice` and others) staged inside lit feature-card panels (`assets/ui/cards.css`, `assets/ui/ui.js`, `references/cards.md`), joined by interactive feature components in `assets/components/` and preset-driven node-and-line diagrams in `assets/diagrams/`.
- **Curated photographs, no captions, real brand icons:** `scripts/photos.mjs` deals vetted Unsplash and Wikimedia Commons photographs (`assets/photos/`, a set of them bundled as offline WebP) by trade as detail slots (capped at 1 in 3 page images unless `--draft-lead` is passed for a client without photographs), enforces image weight budgets, and writes a single expandable footer credit line (`photos.mjs credits`). Captions under images, renders and UI visuals are banned across all page kinds. `scripts/icons.mjs` resolves real full-colour brand marks (Gil Barbara's logos, Simple Icons where no colour mark exists), file-type icons and Lucide interface icons into local SVGs (`assets/icons/`).
- **Palettes, typography, CSS grounds and calm motion:** `scripts/start.mjs` deals palettes and Google Fonts pairings by page kind (`library/palettes.json`, `library/type.json`) and emits the exact `<head>` links and `:root` tokens. `assets/backgrounds/` provides 16 contrast-verified CSS grounds for tech sections, and `assets/motion/` supplies restrained hover, press, tab, focus, count and single-loop card interactions.
- **Real-browser consistency, performance and screenshot checks over HTTP:** `scripts/consistency.mjs` replaces `inspect.mjs` and `lint.mjs` with over 100 browser checks across `1440`, `1024`, `768` and `390` (rendered visual emptiness, GLSL ES reserved-word linting, forced shader-failure fallbacks, WCAG text and button contrast, grid and split alignment, section rhythm, placeholder and invented-address detection, slow-mobile image loading and reduced-motion compliance), paired with `scripts/perf.mjs` and `scripts/shoot.mjs`.
- **A product is always shown, and shown big (`assets/objects/product-render`):** When a physical product has no photographs, the page renders it from a JSON spec of its parts in real studio materials (a city bike, a desk lamp, a robot arm) instead of hiding it behind an abstract shader or an order form. The hero product stands on the page ground with no box around it and fills most of the content width (the checker fails a product hero under 60% of it), every section shows a different view (`side`, `three-quarter`, `front`, close-ups of a named part, loaded or in-use scenes with props such as a child seat or crates), and an order card's colour buttons swap the picture with no page script (`data-product-swatch`, or the `product:set` event). `make-posters.mjs` turns a spec into stills, which pages show by default once they are made; a live turn then runs only when a page asks for it (`data-product-mode="live"`). The checker fails a product page whose pictures never show the product or repeat a picture, and one that shows a view twice in one colour or in neighbouring sections (another colour further down warns).
- **One object never carries the page:** The product or hero object is shown whole in the hero and two more sections at most; the other sections are carried by a different kind of picture (for a game its world and crew, for hardware the product in use, its parts or an exploded view, close-ups, a still diagram, a type moment). The checker warns when one object, shown whole, is the main picture of more than three sections (`same-object`, on product, tech and gaming pages).
- **The client's own assets get slots (`client-slots` in `references/sections-library.md`):** When a brief says the client has a trailer, key art, screenshots, CAD renders or a motion video, the page holds a designed slot for each that already reads finished (a video frame with a poster, a strip of captures, a CAD frame), named in a comment for the client's file.
- **Software shows its product in one app window (`app-frame` in `references/sections-library.md`):** Every software page has one app window across the full content width, a list pane beside the open item, composed from the `assets/ui` fragments (pull requests beside a diff, an inbox beside a thread, a pipeline board). It stacks at tablet width and shows the open item alone on a phone.
- **Hover you can see:** Buttons step 1px on hover and press on click, linked cards lift 2px, photographs zoom 1.02-1.03 inside their frame and underlines draw; under reduced motion only the colours change.
- **Scroll moments stay inside their section:** `iris-opens` and `line-carries-colour` open inside an inset panel with an even gutter and reveal each block whole, so no word is cut mid-move.
- **Tablet screens are planned and checked:** Sections are planned at `768` to `820` as well as `1440` and `390`, and the checker's rhythm pass runs at `768` by default.
- **Runs on any machine:** Every script that opens Chrome takes `--profile` for its Chrome profile, then the `TASTE_SKILL_TMP` folder, then the system temp folder.
- **Install on Windows:** `install.sh` makes a real link (or a directory junction when symlinks are not allowed) instead of silently copying the folder in Git Bash, and an older copied folder or a failed link ends the run with an error instead of a quiet skip.
- **Tested on a fresh install:** A clean install built a small bakery page from `SKILL.md` alone and ran every script; what it tripped on is fixed. The split nav, the hero photograph `photos.mjs fetch` prints and the offer's menu groups beside a photograph pass the checks; a place with under 8 photographs is the short page whatever its trade, with one layout for its menu, its visit block and the photograph beside the menu that passes at every width; a brief without a street prints the district only; `assets/layout/check.mjs` serves a page file itself like the other checkers; `photos.mjs` prints commands that run from any folder; the scene index is never written into the installed skill; and every script runs the same through a symlinked install (`install.sh`).
- **Honest reports:** A check whose measurement timed out reads `partly measured` (WARN) instead of PASS, a product without stills is told to run `make-posters.mjs`, and a product's own render is never counted as a page effect.
- **Plain words and honest pictures:** `references/copy.md` and the checker cap the words on a screen and the footer to one line, and every picture must render (a visual that comes up empty fails at `1440`, `1024`, `768` and `390`).
## 2.1.5 - 2026-10-25
Fixes from the community. Thanks to everyone who reported and sent them.
+32 -20
View File
@@ -58,9 +58,9 @@
<p align="center"><sub><a href="https://github.com/sponsors/Leonxlnx">Become a sponsor</a></sub></p>
**Taste Skill** is an Agent Skill that makes coding agents design websites and app screens instead of assembling the average landing page. It gives the agent an art direction, real pictures and a way to check its own work in a real browser.
**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 it has built, each from one brief:
Pages built from a single brief in earlier v2 runs:
<table>
<tr>
@@ -83,7 +83,7 @@ npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-fro
That installs the skill with the [`npx skills`](https://github.com/vercel-labs/agent-skills) CLI.
The skill runs its scripts with **Node 22+** and an installed **Chrome, Edge or Chromium**. It installs nothing and needs no API keys. Finding pictures needs network access.
The skill runs its scripts with **Node 22+** and an installed **Chrome, Edge or Chromium** (set `CHROME_PATH` if it is in a non-standard place). Optional: **ffmpeg** on the PATH, for WebP output and the phone crop of a hero photograph in `photos.mjs`. It installs nothing and needs no API keys. Bundled Wikimedia Commons photographs work offline; fetching additional photographs or icons uses network access.
<details>
<summary>Claude Code plugin</summary>
@@ -114,28 +114,40 @@ Start a new Codex session afterwards so the skill is loaded.
<details>
<summary>From a clone, linked instead of copied</summary>
`./install.sh` symlinks the skill into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates it in place.
`./install.sh` symlinks the skill into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates it in place. On Windows (Git Bash) it makes a real link, or a directory junction when symlinks are not allowed, and it reports an older copied folder instead of skipping it silently.
</details>
Already have the skill? Run the install command again; the install name did not change. Looking for one of the older skills? See [Moved skills](#moved-skills).
## How v2 works
## How 2.5 works
The skill is built from a study of 247 curated sites. A build runs in six steps:
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. Reads the brief and lists what only this client has.
2. A script deals three of 20 researched directions and the agent picks one.
3. Finds the idea: one sentence tying the client's own material to that direction.
4. Builds with real pictures and footage from open collections, credited.
5. Checks the page in a real browser at three widths and fails it on slop.
6. Looks at its own screenshots and fixes the top three problems, up to three rounds.
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`), 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.
A build takes roughly 30 to 45 minutes and a few hundred thousand tokens. Details are in the [changelog](CHANGELOG.md) and the [study](skills/taste-skill/references/research.md).
Details of what changed from `2.1.x` are in the [changelog](CHANGELOG.md).
Dashboards and app screens (v2.1) take their own route: the skill deals one of the app styles distilled from real product screens, builds the shell, the data, the charts and the tables to those numbers, and checks the result with app rules: quiet panels, aligned figures, colour spent on data, a phone layout where the navigation folds away. A product's landing page builds the app it shows the same way, so its screenshots look like a working product.
## The tools
v2.1 also checks the frame of every page (a clear title on the first screen, equal margins, no section shoved to one side, no random blank stretches) and moves the way strong award sites do, measured on the latest 100 Awwwards Sites of the Day.
Everything is a plain Node 22+ script in the skill folder; none installs anything.
| Tool | What it does |
| --- | --- |
| `scripts/start.mjs` | Deals a palette and a Google Fonts pairing for the page kind and prints the `<head>` links and `:root` tokens. |
| `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 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, 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
@@ -157,16 +169,16 @@ npx skills add https://github.com/Leonxlnx/taste-skill-classics --skill "high-en
## Common Questions
**What does the skill need?**
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. It needs the internet only to find pictures.
Node 22 or newer and Chrome, Edge or Chromium; ffmpeg is optional (WebP photographs and the phone crop of a hero photograph). It installs nothing and uses no API keys. Bundled Commons photographs are stored locally; network access is used only when downloading additional photographs, Google Fonts or brand icons.
**Where do the pictures come from, and can I use them?**
From open collections such as Wikimedia Commons, Openverse and the Met. Only public domain, CC0, CC BY and CC BY-SA files are offered, and the skill puts the credits on the page. Replace them with your own when you have them.
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. The rules are about design, not a framework.
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.
**Is it for dashboards and app screens?**
Yes, since v2.1. The skill tells a page from an app screen and builds a dashboard, an admin view or a settings screen with its own run: a style dealt from a study of real product screens, quiet panels, honest data and charts, and its own checks (`inspect.mjs --app`).
**When does a page get shaders or scroll effects?**
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
+54 -8
View File
@@ -16,6 +16,16 @@
set -euo pipefail
# Windows (Git Bash, MSYS2, Cygwin): plain `ln -s` silently COPIES a folder there, so a
# `git pull` would never reach the install. Ask for a real link, and fall back to a
# directory junction (no admin or Developer Mode needed) when a symlink is not allowed.
WINDOWS=0
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*)
WINDOWS=1
export MSYS=winsymlinks:nativestrict CYGWIN=winsymlinks:nativestrict ;;
esac
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILLS_DIR="$REPO/skills"
@@ -72,17 +82,42 @@ wants() {
return 1
}
# make_link <dir> <link> -- a symlink, or on Windows a junction when symlinks are not
# allowed; fails (non-zero) rather than leaving a copy behind
make_link() {
if ln -s "$1" "$2" 2>/dev/null && [[ -L $2 ]]; then return 0; fi
[[ -e $2 && ! -L $2 ]] && rm -rf "$2" # a copy left by a non-strict ln
if [[ $WINDOWS -eq 1 ]] && command -v cygpath >/dev/null 2>&1; then
cmd //c mklink //J "$(cygpath -w "$2")" "$(cygpath -w "$1")" >/dev/null 2>&1 && [[ -L $2 || -d $2 ]] && return 0
fi
return 1
}
# remove_link <link> -- a symlink or a Windows junction, never the folder it points at
remove_link() {
if [[ $WINDOWS -eq 1 ]] && command -v cygpath >/dev/null 2>&1; then
cmd //c rmdir "$(cygpath -w "$1")" >/dev/null 2>&1 && return 0
fi
rm "$1"
}
problems=0
if [[ $mode == uninstall ]]; then
for target in "${targets[@]}"; do
[[ -d $target ]] || continue
for link in "$target"/*; do
[[ -L $link ]] || continue
if owned_by_repo "$link"; then
rm "$link"; echo "removed $link"
if [[ -L $link ]]; then
if owned_by_repo "$link"; then
remove_link "$link"; echo "removed $link"
fi
elif [[ -d $link && -f $link/SKILL.md ]] && grep -q "$(basename "$link")" <(for d in "$SKILLS_DIR"/*/; do install_name "$d/SKILL.md"; done) 2>/dev/null; then
echo "left $link (a copy, not a link -- delete it by hand)" >&2
problems=$((problems + 1))
fi
done
done
exit 0
exit $(( problems > 0 ? 1 : 0 ))
fi
found=0
@@ -110,17 +145,23 @@ for dir in "$SKILLS_DIR"/*/; do
link="$target/$name"
if [[ -L $link ]]; then
if owned_by_repo "$link"; then
rm "$link"
remove_link "$link"
else
echo "skip $link (symlink to another install -- remove it first)" >&2
problems=$((problems + 1))
continue
fi
elif [[ -e $link ]]; then
echo "skip $link (real file/dir, not a symlink -- move it aside first)" >&2
echo "skip $link (real file/dir, not a symlink: an older copy? move it aside first)" >&2
problems=$((problems + 1))
continue
fi
ln -s "$dir" "$link"
echo "linked $link -> skills/$(basename "$dir")"
if make_link "$dir" "$link"; then
echo "linked $link -> skills/$(basename "$dir")"
else
echo "error could not link $link (on Windows: enable Developer Mode, or run from an elevated shell)" >&2
problems=$((problems + 1))
fi
done
done
@@ -133,3 +174,8 @@ if [[ ${#wanted[@]} -gt 0 && $found -ne ${#wanted[@]} ]]; then
printf ' %s\n' "${all_names[@]}" >&2
exit 1
fi
if [[ $problems -gt 0 ]]; then
echo "$problems install(s) not done: see the lines above" >&2
exit 1
fi
-81
View File
@@ -1,81 +0,0 @@
# tasteskill v2 (`design-taste-frontend`)
A design skill for coding agents, built from a study of 247 curated sites (One Page Love and Awwwards
Sites of the Day, October 2026). It gives an agent an art direction instead of letting it fall back on
the average landing page, holds it to a strict no-ornament house style, and makes it look at its own
render before calling the work done.
## How it works
1. **Inventory.** The agent lists what only this client has: the name and what it means, the street,
the numbers, the one constraint, the materials, the hours.
2. **Draw.** `scripts/draw.mjs` deals three of 20 directions (each an idea engine seen on the
strongest studied sites); the agent picks the one that can carry something from the inventory, and
the script deals the rest: a hero composition, section compositions, a type system of real free
fonts, a palette, photographs, a motion, studied references, and under every blueprint a list of
where it disagrees with the house rules. Recent draws are remembered so projects do not repeat.
3. **Idea and live thing.** One sentence tying the client's own material to the direction's device,
checked against the nearest competitor, and one thing the visitor can do on the page.
4. **Build** from the sheet, words first, full content. When the project has no photographs of its
own, `scripts/assets.mjs` finds real pictures of the subject's world in open collections (its
street in old postcards, old maps, its materials, engravings of its trade, museum objects of its
kind), downloads them into the project and records their credits; the subject is drawn in code
only where no honest picture exists.
5. **Gate.** `scripts/lint.mjs` (source) and `scripts/inspect.mjs` (real browser, 1440 / 768 / 390)
measure the house rules: headlines of two lines or fewer, no eyebrow labels, no monospace, no
01/02/03, no thin rules, no gradients, no resting shadows, no cream-and-terracotta, no violet, and
the faults blind jurors punished: no focal point, empty screens, text covered or cut, long pins,
sideways rows, faded text.
6. **Look.** The inspector saves screenshots; the agent critiques them with an Awwwards-weighted
rubric, fixes the top three problems and repeats, at most three rounds.
Five rounds of blind tests against a no-skill baseline and another design skill are recorded in
`references/research.md` §11.
## Contents
```
SKILL.md the run, the house rules, the laws
references/ copy, type, colour, layout, imagery, motion, critique, tells, research
scripts/draw.mjs deal options / a direction / reprint / redraw; --alternatives, --photos, --site
scripts/assets.mjs search open image collections (Openverse, Wikimedia Commons, Art Institute of
Chicago, the Met) and, with --video, real footage (Wikimedia Commons, Internet
Archive, NASA); --get downloads with the credit, --credits prints them
scripts/lint.mjs static scan for banned patterns and stock phrases
scripts/inspect.mjs render, measure, photograph
scripts/lib/ zero-dependency Chrome DevTools client, the in-page probe, the rules
library/ directions (20), heroes, sections, type systems, palettes, photographs,
studied sites, motions
assets/base.css, base.js an optional neutral foundation
evals/evals.json six briefs for testing the skill against a no-skill baseline
```
## Requirements
Node 22 or newer and an installed Chrome, Edge or Chromium for `inspect.mjs` (it drives the browser
over the DevTools protocol with Node's built-in WebSocket; nothing is installed). `draw.mjs`,
`lint.mjs` and `assets.mjs` run on Node 18+; `assets.mjs` needs network access and no API keys. Set
`CHROME_PATH` if the browser is somewhere unusual.
## Install
Copy this folder to your skills directory, for example `~/.claude/skills/design-taste-frontend/`, or keep it in
a repository under `.claude/skills/`.
## Notes
- Photographs in the library are served by Lorem Picsum from Unsplash, under the Unsplash licence.
They are placeholders for atmosphere; replace them with the project's own pictures.
- Pictures found with `assets.mjs` keep their own licences. By default it offers only CC0, public
domain, CC BY and CC BY-SA, and `--get` records the title, creator, licence and source of every
download in `assets/credits.json`. The page must carry the credits (`--credits` prints them).
- `assets.mjs --video` finds short clips of the subject's world, takes a 720p-or-smaller rendition,
refuses files over 25 MB unless `--max-mb` is passed, and saves a poster frame with each clip; how
to use one on a page (muted loop, poster, stopped under reduced motion) is in
`references/imagery.md` §11.
- Fonts are loaded from Google Fonts and Fontshare. Check each face's licence for your use.
- The scores in `references/research.md` are model judgements from screenshots, not Awwwards scores.
- The draw history lives in `~/.taste-skill/history.json` (set `TASTE_SKILL_HISTORY` to move it).
Delete it to reset.
- A build takes an agent roughly 30–45 minutes and a few hundred thousand tokens with the full
look-and-fix loop; most of that is finding or drawing pictures when a project has no photographs.
+350 -539
View File
@@ -1,570 +1,381 @@
---
name: design-taste-frontend
description: >-
tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of
247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions
(idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict
no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use
this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site,
portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React,
Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated,
"AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other
site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens
people work in, with their own run and rules.
tasteskill 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 v2
# tasteskill 2.5
A page built by a model usually looks like the average of every page the model has seen: a centred
headline over three lines, a little label above it, a row of icon cards, a soft gradient, a few thin
rules and a monospace caption. Nothing in it is wrong and nothing in it was decided.
Build the website a good company would ship: it says plainly what is sold, shows the real thing on
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).
This skill replaces those defaults with decisions, in four ways:
1. **A script deals the directions, you choose among them.** Asked to invent a look, a model picks
the category default every time. `scripts/draw.mjs` deals three of twenty researched directions;
you choose the one that carries the subject's idea, and the script then deals its type system,
palette, hero and section compositions from a library distilled from 247 real sites.
2. **The idea comes from the subject.** In the study, what separated the best sites from the forgettable
ones was a concept taken from the thing itself, not the styling.
3. **A house style with no ornaments,** measured in a real browser by `scripts/inspect.mjs`, so the
rules are checked rather than hoped for.
4. **You look at what you made.** Screenshots at three widths, a juror's rubric, fix, look again.
One warning learned in testing. Taking the ornaments away is only half the job; a page that has lost its
labels, rules and cards and gained nothing in their place reads as thin, and blind jurors rightly prefer
a richer page with tells over an empty page without them. The other half is substance: type set
larger, whole fields of colour, real pictures of this subject's world (found or drawn), and every
section carrying its full content. The skill is built to give you that; do not trade it for restraint.
In the commands below `<skill>` is this skill's folder. Run them from the project's root, so the
working files land in `.taste-skill/` there. The scripts need Node 22+ and an installed Chrome, Edge
or Chromium, and install nothing. `assets.mjs` also needs network access; it uses no API keys.
## Page or app screen
Decide first which of two kinds of work this is.
- **A page** is read and scrolled: a landing page, a marketing or product site, a portfolio, an
event, a restaurant, a one-pager. It follows **the run** below.
- **An app screen** is worked in: a dashboard, an admin or analytics view, settings, billing, an inbox
or queue to work through, a wallet, a list with filters, an editor or builder. It follows
**`references/app-screens.md`**: its own run, its own draw (`draw.mjs --app`), its own house rules
(quiet borders, small labels and repeated panels are normal there; gradients, glows, icons in tinted
squares, rainbow charts and invented vanity numbers are not) and `inspect.mjs --app`. Read that file
whole before you start; the page run below does not apply.
A product site that shows its app is a page; the interface fragments on it follow the app-screen
rules for their insides. Deal the product's app style once, in its own folder, with the page's colours
and type kept:
```bash
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<the product>" --keep palette,type --out .taste-skill/app
```
and build every fragment the page shows (the hero's live screen, a feature's crop, a chart) as a real
piece of that app: `assets/app.css` for its panels, tables and controls, `assets/charts.js` for its
charts, real data that adds up, one finding stated, cropped and scaled the way the page's blueprint
treats a picture. A fragment that looks like a working product is the strongest picture a software page
has; grey bars and lorem numbers are the weakest. When a request mixes both (a landing page and the
dashboard behind it), build each with its own run and one shared palette and typeface.
## The run
Do these in order. Steps 1–5 take a few minutes and decide whether the page is any good.
### 1. Read the brief and the project
Find out what is real: the subject, the audience, the one thing the page must get someone to do, the
stack, any existing brand (colours, fonts, logo), and above all **what imagery truly exists**: the
project's own photographs or product shots, a real interface that can be shown, an image generation
tool you can call, or nothing. Do not plan around pictures you do not have. Nothing of its own is
rarely nothing at all: the subject's city, street, materials and history are in open archives and
museums, free to use with a credit (step 6, `scripts/assets.mjs`).
Name the pictures a visitor needs before deciding: for a place to stay, the rooms, the house and the
table; for food, the plates and the room; for a product, the product; for a practice, its work. When
the project has none of them, say in the report that they are missing (or ask, when the user is there
to answer) and choose a direction that does not promise them. Their slots are then drawn from the
subject (the room's plan at a size that can be read, the product in its normal pose;
`references/imagery.md` §8), never filled with the place's birds or weather.
If the request is vague, decide and state your reading in one line instead of asking. Ask only when a
missing fact blocks the work (for example, no product name).
Then **inventory the subject** in your notes: eight to twelve things that belong to this client and to
no competitor. Look for its name and what the name means; its address, door number, street and
neighbourhood, and what that place looks like (its tiles, signs, paint colours, typefaces on its shop
fronts, its light, its weather); the numbers in the brief (seats, rooms, minutes to the sea, the
price, the year); the constraint it lives with (only one batch a month, closed in winter, by
appointment); its materials and colours; its rituals and hours (the oven lit at five, the ferry at
seven); the printed things of its world (a timetable, a label, a ticket, a sign). This list is where
the idea comes from in step 4. In blind tests, the page that won each brief was built from an item on
such a list (the client's name and what it means, the street it stands in, the one constraint that
makes it what it is), and the pages that lost had a good composition with a category idea. The list
is also your picture search: each place, material and printed thing on it is a query for step 6.
### 2. Write the argument
Before any design, list the page as claims with their proof, one line per section, the way
`references/copy.md` §1 shows. The claims decide which sections exist and in what order. Delete any
section that has no claim. This is how you avoid the stock sequence of hero, logos, three features,
testimonial, pricing, call to action.
### 3. Draw the direction
```bash
node <skill>/scripts/draw.mjs --sector <sector> --brief "<one line about the subject>" \
--sections <roles in the order of your argument> [--tone <tone>] [--have <assets>] [--keep palette,type] \
--options 3
```
This deals three directions and prints a short summary of each. Hold each one against your inventory:
which way of composing can carry something only this client has? Choose that one, not the most
familiar, and write one line in your notes on why. In testing, a single random direction that did not
suit the subject was the commonest cause of a weak page. Then deal the full sheet:
```bash
node <skill>/scripts/draw.mjs <the same arguments, without --options> --direction <chosen id>
```
- `--sector`: `node <skill>/scripts/draw.mjs --list sectors`. Pick the closest.
- `--sections`: roles from `--list roles` (features, showcase, how-it-works, testimonial, proof, stats,
pricing, faq, cta, footer, intro, about, work, gallery, list, contact). The hero is implied.
- `--tone` (optional): quiet, bold, warm, technical, playful, luxury, editorial. Only pass it when the
brief clearly asks for one.
- `--have`: assets that really exist, comma-separated: `photography` (the project's own photographs of
the place, people or work), `product-photo` (photographs of the specific product, or an image tool
that can make them), `product-ui` (real screenshots), `illustration`, `video`. Leave it out if you
have none: the draw then avoids compositions that depend on them and the sheet says how to fill
picture slots honestly (software shows its own interface built in HTML; a product with no photographs
is drawn and set among real pictures of its world, never a fake product shot). Pictures you will
find in the open collections are not the project's own; leave them out of `--have`.
- `--keep palette,type`: when the brand already fixes colours or typefaces. The draw then deals only
the composition, and you map the brand's values onto the roles.
The command prints a **direction sheet**. It is the art direction for this build:
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
reason you can state: it contradicts a stated brand constraint, needs an asset that does not exist,
or is a section that cannot hold the real content (next point). Never redraw to get something more
comfortable. Record every redraw in your report.
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
it wants five quotations and there are none), do not bend the content to fit. List the others with
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with
`--redraw section:<role>=<id>`.
- **Check the palette against the subject.** A dealt palette is a set of proportions (one ground, one
ink, one accent, fields) as much as a set of colours. If its colours fight the subject's own material
(candy pastels for a peat whisky, neon for a mountain hut), redraw the palette once; if the subject
has unmistakable colours of its own (its material, the paint of its street, the landscape outside
its windows), keep the dealt proportions and take the colours from the subject instead, and say so
in the report.
- Reprint it any time with `node <skill>/scripts/draw.mjs --show`. In a long session, re-read it before each
section you build.
### 4. Find the idea
The direction is a way of composing. The idea is what makes the page belong to this subject and no
other. Write this sentence in your notes and do not start building until both blanks are filled from
the subject itself:
> On this page, **[something that belongs to the subject]** becomes **[the device the direction calls for]**.
Places to look for the first blank:
- **Its output.** What does the thing produce? Put the real output on the page as material. (A camera
that prints poems delivered its copy as paper slips.)
- **Its object or shape.** What does it physically look like, in cross-section, in silhouette?
Enlarge it, crop it, make it the frame or the one motif.
- **Its name.** Can the letters hold a photograph, stack into a wall, split to two corners?
- **Its place.** Is there one view that makes someone want to be there?
- **Its ritual.** Is there a sequence the page can perform as it scrolls?
- **Its artefact.** What printed thing already exists in its world: ticket, menu, label, receipt,
invitation, folder, manual?
- **Its opposite.** Which other category owns the feeling this one lacks?
Fill the first blank from your inventory (step 1), not from the category. **The competitor test:** put
the nearest real competitor in the sentence (the bakery two streets away, another oak furniture maker,
another hut on the same mountain). If the sentence still works, the idea belongs to the category: a
loaf of bread is every bakery; the seven doors of the street this one is named after are only this one.
"Clean and modern" is not an idea. The sites the study's panel scored lowest were the ones whose idea
was recorded as "none beyond the standard formula".
**One live thing.** Give the page one thing the visitor can do, or that is different for each visit,
made from the idea: pick a wood and see the chair in it; type your name and see it cut in the
foundry's letters; set the loan and read the real monthly figure; see whether the hut's stove is lit
right now and how long until the last ferry; choose a size and see it against the room. In blind tests every winning page had one and the jurors named it first. Rules: it uses the subject's real content, it works on a phone with a thumb, it has a plain
fallback without script, it never blocks reading, and it is one thing, not a playground. Its result
stays in view while the visitor works the controls, at every width (on a phone, a small sticky result
bar if the controls run long), and every heading beside it is true for every state it can reach. It is
built in step 6 as part of the page, not added as motion in step 9; when it is built, drive every
control to its extremes and look at the widest result at 1440, 768 and 390, since the inspector only
sees the default state.
**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
Decide what each one shows from your inventory before building: the fly-through passes through this
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
to anyone is a screensaver, and one still filled with its demo's town, photographs or words is not
started.
### 5. Write the words
Write the whole page as a copy deck (plain text) before markup, following `references/copy.md`:
- **Every headline fits in two lines or fewer at every width.** Do the arithmetic in copy.md §2 before
committing to a sentence: about 32 characters is safe everywhere at a confident size. The studied
gallery heroes have a median of 5 words and 32 characters.
- One supporting sentence, directly under the headline (32–48px below it) and shown with it, never
docked at the foot of the first screen or held back by an animation. When the headline is a name or
a figure of speech, this sentence says plainly what the thing is. One primary action, named for what
it does, as a real button on the first screen.
- **The first screen answers the visitor's first question in large type.** For an event: what it is,
when and where (the dates belong in the headline or at the second size, never only in small text).
For a product: what it is and what it costs or does. For a person: who and what they do. Every
section the visitor came for (the line-up, the menu, the rooms, the price) has its own heading and
its own place in the navigation.
- **Write the full content.** Every section the brief asks for delivers its information completely:
the whole menu with prices, each room with its own details, every speaker and talk, the price with
what it includes, an answer for each real question. Thin sections are the commonest reason a clean
page loses to a busier one.
- Sample details are expected where the brief leaves gaps (dish names, room names and rates, project
titles) so the design is seen at full density; list them in the report for the client to confirm.
Proof is never invented: no customers, quotes, reviews, press, logos or business statistics.
### 6. Build it, still
Build the page with motion switched off in your head; a page that needs animation to look finished is
not finished. Order of work:
1. **Tokens.** Colour and type roles from the sheet as CSS custom properties. Everything downstream
references tokens only. `<skill>/assets/base.css` is a neutral foundation you may start from (reset, token
names, fluid type steps, grid, focus and selection styles, the motion helpers); it has no look of
its own.
2. **The hero, exactly as the blueprint describes it,** at desktop and at phone width. Get the big
thing big and the small things small before anything else exists.
3. **Each section from its blueprint,** with this project's content. Keep the geometry, change the
subject, and leave the props behind: a café blueprint's opening-hours bar, scalloped menu sheet and
visit card stay with cafés, and on an architecture practice its list of buildings becomes a
drawing register, not a menu. Neighbouring sections must not share a layout. **Carry the idea into
every section,** pricing, FAQ and footer included: the commonest failure in the study, even among
good sites, is a distinctive first screen followed by stock blocks.
4. **Imagery,** in this order of preference:
1. the project's own;
2. generated, if you have an image tool;
3. **real pictures of the subject's world from the open collections,** whenever an honest one
exists. Search them with `node <skill>/scripts/assets.mjs "<query>"` (Openverse, Wikimedia
Commons, the Art Institute of Chicago and the Met; only licences that allow commercial use, by
default). Search the inventory, not the category: its street in old postcards, an old map of the
neighbourhood, its material up close, its trade in engravings, a museum object of its kind, a
botanical plate, a star chart. Use them large and with intent: one archival photograph as the
hero, an engraving enlarged into the page's motif, the real map of the street as the contact
section. Look at each before choosing; `--get <ref>` downloads it into `assets/` and records
its source and licence, and `--credits` prints the credit line for the footer. A found picture
shows the world around the client and is never presented as the client's own product, room,
dish, person or team;
4. **drawn in code from the subject itself,** for what no honest picture exists of (its objects,
its tiles, its windows, its product's interface, made as one consistent flat graphic language in
the palette's real colours);
5. the photographs dealt on the sheet, only where an honestly generic picture fits (the sea for a
sea house), never as a stand-in for the subject;
6. type and colour alone.
When the project has footage or a real picture of its own process or product, the first screen
shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
the pictures come from or what they are not ("none of these shows our bar"): a caption says what
the picture shows and sits under or beside it, never across a piece of work, and the credit line
sits small in the footer. A portfolio whose work is invented for the brief draws each piece as a
finished still of that work (its title card with its own type, an end frame, a poster), with real
detail, never as a pictogram of the client's product.
**The floor, which the inspector checks:** at least four real pictures on the page, usually six to
twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
cropped interface fragments count too). SVG drawings do not count toward the floor: draw what no
picture exists of, and still carry real pictures. Add one moving picture wherever an honest clip
exists: search it with `assets.mjs --video "<query>"` and use it as a muted loop with a poster
frame. Models skip pictures and footage because finding them is work; the page fails without them.
`references/imagery.md` covers searching, licences and credits, video, drawing, crops and treatments.
5. **The live thing** from step 4, built into the section where it proves the claim (often the hero
or the booking), with real content, a thumb-sized phone version and a plain fallback.
6. **The scroll scenes,** when the sheet deals them (section 9), each with the section it carries: the page's big
moments, the camera that flies through the work, the picture that opens to the whole screen, the
word that buckles under the scroll. Copy the shared stage and the scene's files from
`<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
page's tokens so it takes the page's look. `assets/scenes/demo/<id>.html` is a working example of
each, and its content is a placeholder: every picture, word and drawn thing in your scene comes from
this subject, and a route map shows the real route, coast and relief. A scene whose pictures or
drawings are still the demo's, or the stand-in a script draws when it is given nothing (the route's
bare ground with only the road), is unfinished. A scene is composition: it decides how long the page is and what
each screen shows, so it is built now and seen in every look round, not added at the end. It
replaces the content of the section it carries, never adds screens of nothing in front of it, and a
page with scenes still has a length budget: about 12-16 screens at 1440 in all, of which the scenes
hold about six. `references/scenes.md` says how to put one on a page, fill it, fit it and check it.
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
Running text is in the full ink at 16px or more; grey is for secondary notes.
8. **The frame.** The page has one pair of equal outer margins at every width, and every section uses
the width the page has set up: when its text sits in the left half, the right half holds its
picture, its second column, its list or its figure, never nothing. Asymmetry lives inside the grid,
never in the frame: a void is placed only when something closes it on the far side. No stretch of
the page taller than about half a screen holds nothing at all; sections are 120–240px apart on
desktop. Every section the visitor came for opens with a heading that names it, and the first
screen carries the page's title in an `h1`. These are what readers name first when a page looks
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
inspector measures each of them. On a page longer than about ten screens at 1440 (a page with
scenes always is), the navigation stays in reach after the hero: the plain bar hides while the
visitor scrolls down and comes back on the first scroll up, on a solid band of the ground where it
passes pictures, or at least the one action stays fixed in a corner. A hero that animates its bar
away hands it on to that bar (`references/layout.md` §7).
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
its own composition. Every navigation and skip link lands on the section that holds what it names,
never on a scene that only introduces it. Every control, the live thing included, works from the
keyboard alone in reading order and has an accessible name: its visible label, or `aria-label` when
it shows only an icon.
Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
behind a decision; the laws below are the short form.
### 7. Gate
```bash
node <skill>/scripts/lint.mjs <source files or folder>
node <skill>/scripts/inspect.mjs <url or path/to/index.html>
node <skill>/scripts/scenes.mjs <url or path/to/index.html> # when the page has scroll scenes
```
`scenes.mjs` photographs every scroll scene at 0, 10 … 100% of its hold, on desktop and on a phone,
and the reduced-motion page; with scenes on the sheet, `inspect.mjs` also films the whole scroll
(`desktop-film-NN.jpg`). A scene that never started or rests on desktop is a `FAIL`.
`lint.mjs` scans source for banned patterns and stock phrases. `inspect.mjs` opens the page in a real
browser at 1440, 768 and 390 pixels, measures it (headline line counts, type sizes, fonts actually
loaded, colours, rules, shadows, gradients, contrast, overflow, and the frame: equal margins, sections
huddled on one side, blank stretches, the title, untitled sections, text against the screen edge) and
saves screenshots to `.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with the meter and do not
hide things from it. The inspector serves local files over a throwaway local server; if the page must
also open straight from the file system, open `index.html` that way once before you finish.
Inspect what the visitor receives. With a build step, run the last gate on the production build
(`vite build && vite preview`, `next build && next start`, or the built `index.html`), not only on the
dev server: minifiers and bundlers change what ships (one dropped a `backdrop-filter` that the dev
server showed). The inspector warns when it is looking at a dev server.
### 8. Look
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
screenshots the inspector lists (every viewport, with your image viewer or Read tool) and critique them
with `references/critique.md`: the fourteen passes, the Awwwards-weighted score, the caps. Write the critique
down in its format, fix the top three problems, run the inspector again, look again. Three rounds at
most.
Judge only the pictures. If a problem is visible in a screenshot, it is real, whatever the code says.
### 9. Move
Now build the motion layer. It is required, and it is more than one fade: the inspector counts the
elements that really move while the page loads and is scrolled, and fails a page under eight (it warns
under sixteen). Any scroll scenes are already built (step 6); around them the layer has four parts:
1. **The signature move** named on the sheet: one, done well (a dealt scene can be it).
2. **An entrance for the parts of every section:** the heading, then the text, then the picture,
staggered by 60–90ms, rising 16–32px with opacity, triggered once as the section enters. Not one
fade for the whole block, and never content that stays hidden if the script fails.
3. **Feedback on every control:** press, hover and focus on buttons, links, cards and the live thing.
4. **One moving picture:** a found clip as a muted loop, a slow scale or drift on a photograph, or a
scroll-linked move on the hero picture.
Transform and opacity only; every move has a `prefers-reduced-motion` fallback that keeps the content
and drops the travel. A move that changes the colour or the layout of whole screens (colour by
chapter, a pinned story) is part of the composition, so build it in step 6 and let every look round see
it. `references/motion.md` has the tokens and tested recipes. Then run the inspector once more and check
every screenshot for content that failed to appear.
### 10. Report
Do not report until all of these are true; each one was skipped in a test run that came out badly:
- `inspect.mjs` shows **0 FAIL** at 1440, 768 and 390, on the final version of the page.
- You opened every screenshot it listed after the last change, not only the first screen.
- With scroll scenes: `scenes.mjs` ran on the final page with no problems, and you read every sheet
(desktop, phone, reduced motion).
- A stranger reading the first screen for five seconds can say what this is and what to do.
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
- The page is about 16 screens or fewer at 1440 (the inspector's header counts them), and the
navigation or the one action can be reached from every screen.
- The critique of your last round is written down in the shape of `references/critique.md` §4.
Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
sentence and the live thing; the final scores from your last critique round and what still falls short;
every found picture and clip with its title, creator, licence and source page (`assets.mjs --credits` prints
the list) and where its credit sits on the page; which bundled photographs and which drawings stand in
for photography (so they can be replaced); and any sample content that must be swapped before launch.
Do not claim a check you did not run.
`<skill>` is this folder. It needs Node 22+ and Chrome, Edge or Chromium (ffmpeg optional).
## House rules
These hold on every build, whatever the direction. `inspect.mjs` measures them.
These nine win over everything else in this skill.
| Rule | Instead |
| --- | --- |
| **Headlines: two lines or fewer, at every width.** Every `h1`–`h6`, and the main line of the first screen whatever its tag. | Cut words. Then widen the measure. Size last. |
| **A clear title on the first screen**, in the page's one `h1`, and a heading on every section. | Say what this is: the name with what it does, the product with what it is for, the event with its date and place. |
| **Equal outer margins; no section huddled on one side** with the other half of the screen empty. | Close every void with something on its far side: the section's picture, a second column, a list, a figure. |
| **No blank stretches** taller than about half a screen. | The normal section gap. A deliberate rest is a placed picture or a field of colour, once per page. |
| **Statements are paragraphs.** An intro sentence or a quotation set large is marked up as `<p>` or `<blockquote>`, stays at statement size (36px or less on desktop, 24px on a phone) and runs to about 25 words, three lines. | Larger than that it is a headline and the two-line rule applies. In blind tests jurors read a 42px sentence over four lines as a four-line headline. |
| **No eyebrows.** No small label above a heading, no pill badge above the headline, no "New ·" chip. | Put the information in the heading or the first sentence, or drop it. |
| **No monospace** outside real code blocks. | The text face. Tabular figures where numbers align. |
| **No index numbering.** No 01 / 02 / 03, no (001), no "Step 2". | Order shown by position, size, or the words. A real `<ol>` if order matters. |
| **No thin rules.** No hairline dividers, no 1px outlined boxes, no grid lines, crosshairs or crop marks. | Space. A step in background tone. A change of scale. A filled shape. |
| **No gradients.** Not as backgrounds, not on buttons, not in text. | Flat colour, or a real photograph. A neutral scrim under text on a photo is the one exception. |
| **No shadows on things at rest.** No glows. | A tone step between surfaces. Shadows only on what truly floats: a menu, a dialog. |
| **No assistant palette.** No yellow-cream ground with terracotta or rust; no violet or indigo. | The dealt palette. |
| **No tiny uppercase letter-spaced labels.** | Sentence case at a readable size. |
| **No icon-topped feature cards, no rows of identical cards.** | The dealt section blueprint. |
| **No glass.** No backdrop blur panels, no floating pill navigation. | A plain bar: wordmark, a few links, one action. |
| **No emoji, no gradient blobs, no decorative shapes, no fake dashboards of grey bars.** | A real picture, a real interface fragment, or nothing. |
| **No styled accent word** in a headline (italic, serif, coloured, underlined). | One face, one colour. The words carry the emphasis. |
| **Two typefaces at most,** usually one. | The dealt type system. |
1. **No caption or description under any image, render or visual.** Licence credits go in one
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. 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. 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:** 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`.
Three places keep their own conventions and are left alone by the inspector: real code in
`<pre>`/`<code>` (monospace is correct there), real tables and form fields (they may have lines), and
a real interface fragment whose root carries the attribute `data-ui` (the product's own borders and
menus; see `references/imagery.md` §7).
## 1. The kind of business decides everything
Why these, honestly. Some are what the study measured: among the 247 sites, those the panel scored
7.5 or higher used icon feature cards 1% of the time against 28% for sites under 6.5, pill badges 14%
against 34%, gradients 22% against 52%, shadows 24% against 48%, tiny tracked caps 16% against 57%.
Others are the owner's taste and go against current gallery fashion: monospace labels, index numbers
and hairline grids are actually *more* common on award sites than on weak ones, which is exactly why
generators imitate them and why they now read as costume. They are banned here without exception.
`references/tells.md` keeps the dated registry.
Write the kind on the plan's first line and declare it on the page: the page type on
`<html data-type>` and `<meta name="page-type">` (the same value), and `<meta name="site-kind">`;
a place adds `<meta name="page-kind" content="place">`. The dealers take the last column
(`start.mjs --page`, `moments.mjs --page`), the hero dealer the page type; `consistency.mjs` reads
the metas.
Removing ornament leaves space. Never fill it with another ornament. Fill it with scale (make the main
thing larger), with the full content, with a picture made for this subject, or with a field of flat
colour. Leave it empty on purpose once per page at most.
| Brief | page type | site-kind | kind |
|---|---|---|---|
| App, SaaS, AI, developer tool, fintech or banking app, a studio selling its own software | software | software | software |
| A game, a studio launching its game | software | gaming | gaming |
| Hardware or a tech device (a drone, a robot arm, a keyboard) | product | tech | tech |
| Any other physical product or brand (a chair, a bike, coffee beans sold online) | product | other | product |
| People are hired: clinic, law firm, agency, creative studio, trade | service | other | service |
| People come to it: cafe, bar, bakery, restaurant, guesthouse, small hotel, shop, florist, bike shop, gym, local studio | place | other | place |
## Laws of the craft
A maker that also opens its door or teaches (a ceramics studio, a bakery with a web shop) is a
**place**; its web shop becomes the goods section. It is a product only when nothing happens at the door.
Short form. Numbers are from the study unless marked as house style.
"Tech" below means site-kind software, gaming or tech; "other" is everything else. Effects,
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.
**Type**
- Two or three sizes do the visible work: one very large, one small, nothing in between. Median
headline-to-body ratio on the studied sites is 4.3; the best go far beyond (largest type: median
8.4vw on the top sites against 5.0vw on the weak ones).
- Display type: tracking -0.02 to -0.05em, leading 0.85–1.05. Text: 16–20px, leading 1.45–1.6,
45–75 characters per line.
- Five sizes on a page is the ceiling. One family is normal (a third of the studied sites); two is the
limit.
- Left-aligned by default. Centre at most one moment.
## 2. The page plan by kind
**Space**
- One grid, one pair of tight margins (16–32px), used everywhere; large things run to the edges. Text
blocks share two or three left edges on the whole page.
- Asymmetry inside the grid, never in the frame: the two outer margins are equal, and an off-centre
heading is answered by something at the far side of the grid.
- The gap between sections is clearly larger than any gap inside one: 120–240px on desktop (the study's median is about 240).
- Each section has a different composition from its neighbours; none is a card grid.
The full plans, deal lists and section order: `references/sections.md`.
**Colour**
- One ground, one ink, one accent. The accent appears on the action and perhaps one other thing.
- Running text in the full ink. A page set in soft grey reads unfinished.
- Surfaces are separated by a small step in tone, never by a line or a shadow.
- A section may change the whole field colour. Nothing fades into anything.
- **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 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, 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,
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
compositions, no structure twice, one contrasting ground; the **swatch changes the picture**,
every colour shown once; **no fact twice**; buttons step and press (rule 5).
- **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 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 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 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).
**Imagery**
- One dominant image beats six small ones. On the top-scored sites the hero media covers at least half
the first screen 68% of the time.
- Same crop ratios, same treatment, square corners unless the direction says otherwise (73% of the
studied sites leave media square).
- No image is better than a placeholder, an icon, or an abstract shape. A picture drawn for this
subject, in its colours and with care, is better than either. A real picture of its world, found
and credited, is better still.
- Every found picture is downloaded into the project, credited on the page and listed in the report.
None is presented as the client's own.
- Nothing ever lies on top of a headline or a statement (the inspector fails it).
## 3. What serves which page
**Components**
- Buttons: flat, one fill colour, either square-cornered or fully round, decided once (top sites: 52%
square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
button at all, or one.
- Navigation: wordmark, three to five plain links, one action. No bar chrome. On a long page it stays
in reach: the plain bar returns on scroll up, or the one action stays fixed.
- A control explains how to use it by its form, its label and its place. When it seems to need a
sentence on how to operate it, redesign the control instead of adding the sentence. What a field
accepts is different. First remove the limit where you can: a date picker, or a parser that takes
any common format. A limit that remains, such as password rules or file type and size, stays visible
beside the field and is tied to it with `aria-describedby`.
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
Open the reference of every asset family the page uses first. A dash means never.
## Special cases
| Need | Asset | Software, gaming | Hardware | Product, service | Place | Reference |
|---|---|---|---|---|---|---|
| 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` |
| 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, 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 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` |
**An existing brand or design system.** Pass `--keep palette,type`, map the brand's colours and faces
onto the roles, and take only composition from the draw. The house rules still apply; if the brand
itself requires a banned device (its logo sits in a gradient, its accent is violet), keep the brand
asset, say so in the report, and pass `--allow <rule>` to the inspector for that rule only.
Read `copy.md`, `copy-voice.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the first page.
**Redesign, restyle or review of an existing page or app screen.**
## 4. The run
1. **Record the before.** Run the project as it is and inspect it into its own folder
(`inspect.mjs <url> --out .taste-skill/before`, with `--app` for an app screen) and lint its
source. Read the code: the framework, the styling method (Tailwind v3 or v4, CSS modules,
styled-components, plain CSS), the components, and what must survive: routes, forms, data,
behaviour, the brand's own assets.
2. **A review stops here.** Critique the before screenshots with `references/critique.md`, list the
findings with the inspector's measurements, and name the three changes that would move the page
most.
3. **Keep the stack, the content and what works.** No framework or styling-library migration; check the
dependency file before adding anything. A restyle keeps the layout and takes type, colour and
components from a dealt direction (`--keep` whatever the brand fixes); a redesign deals a full
direction and rebuilds the sections from its blueprints inside the existing components. Either way
proceed from step 2 of the run with the page's real content.
4. **Order of work, biggest effect for least risk:** type (faces and scale), colour (one ground, one
ink, one accent), the frame (margins, section rhythm, a title on the first screen and a heading on
every section), states (hover, focus, loading, empty, error), then the stock components (icon
cards, pill badges, gradient blobs, card grids) replaced by the dealt blueprints.
5. **Prove it.** Inspect again into `.taste-skill/after`, compare before and after at every width,
click through every link, form and menu that worked before, and report the before and after
measurements side by side.
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`).
**A component or a single section, not a page.** Skip the draw unless there is no direction to follow.
Match the surrounding design, apply the house rules and the laws, and still inspect the result.
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:
**No checkout, no form backend.** The primary button goes to the real destination when the brief names
one. Otherwise it opens an email with a written subject and body (`mailto:` with the visitor's choices
filled in), or, for a shop, it points at `#checkout` and the report says it must be wired before
launch. Never fake a payment form.
```bash
node <skill>/scripts/gate.mjs --start index.html --kind <kind> --category "<what is sold>" --primary "<main action>"
```
**No Chrome or Node 22.** Run `lint.mjs` (Node 18+), take screenshots with whatever browser tool you
have, and critique those. Say in the report that the inspector could not run.
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.
**No network.** If `assets.mjs` reaches no source, draw the pictures and say in the report that the
open collections could not be searched.
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).
## Files
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), 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`.
| File | Read it when |
| --- | --- |
| `references/app-screens.md` | Any dashboard, admin, settings or other app screen: its own run, rules, tokens and checks. |
| `references/copy.md` | Writing the argument and the copy deck (steps 2 and 5). The two-line arithmetic. |
| `references/type.md` | Setting type: scales, tracking, measure, loading fonts, pairing. |
| `references/colour.md` | Applying the palette: roles, proportions, tone steps, dark pages, contrast. |
| `references/layout.md` | Grid, margins, rhythm, section variety, navigation, footer, the phone composition. |
| `references/imagery.md` | Choosing, finding, crediting, cropping and treating pictures; what to do with none. |
| `references/motion.md` | Step 9. Budget, tokens, recipes, reduced motion. |
| `references/scenes.md` | Step 6, when the sheet deals scroll scenes: putting one on a page, filling, fitting, the phone, checking, the catalogue. |
| `references/critique.md` | Step 8. How to look, how to score, what to fix first. |
| `references/tells.md` | The dated registry of generated-page tells and what each is replaced by. |
| `references/research.md` | The study: method, numbers, what the best and worst sites do. Cite it, do not guess. |
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references; `--app` deals an app style instead. |
| `scripts/assets.mjs` | Step 6: searches open collections for real pictures of the subject's world; `--get` downloads with the credit, `--credits` prints them. |
| `scripts/lint.mjs` · `scripts/inspect.mjs` | The gate. |
| `scripts/scenes.mjs` | The gate for scroll scenes: each scene at 0-100% of its hold, desktop, phone, reduced motion. |
| `assets/base.css` · `assets/base.js` | An optional neutral foundation to build on. |
| `assets/scenes/` | The scroll scenes: the shared stage (`stage.js`, `stage.css`) and, for each scene in the library, its `.js`, `.css` and a working demo page in `demo/`. |
| `assets/app.css` · `assets/charts.js` | For app screens: a neutral shell, panels, tables and controls, and small SVG charts drawn to the app rules. |
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions, scroll scenes (`scenes.json`), and the app styles, shells and screen patterns (`app.json`). Do not read it whole; use the script. |
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,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 # 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>
```
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`) 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 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
one `.wrap`, its h2 first, `--h: 1` or `2`. Build each to its recipe with the system's classes
and tokens; write page CSS only where a reference says page CSS (inside a visual, `--p-h`,
`.wall`, `.rail`, `.device`). Paste UI from `assets/ui/<name>/fragment.html` and rewrite every
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. 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 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>
node <skill>/scripts/perf.mjs index.html --profile <dir>
```
Shoot 1024, 820x1180, 768x1024 and 390 as well. Every `check.mjs` finding fails the page (no `--no-stress`).
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:
```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
Each line names its checks: `consistency.mjs` ids in brackets, `check.mjs` findings in braces,
`perf.mjs` rows. The last line is for your eyes.
- **First screen:** says what is sold, for whom or where, with one hard fact [copy-headline,
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, 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,
spill, off-viewport, stagger, top-stagger] {edge-left, edge-right, spill, bleed-one-side,
one-sided, lines}.
- **Every visual draws** at 1440, 1024, 768 and 390; a refused effect shows its still fallback
[visual-empty, empty-visual, gl-fallback, shader-lint, no-visual, photo-weight].
- **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:** 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`;
real photographs where they fit, none twice [photo-set, photo-repeat, photo-upscale]; real icons in their own
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]; 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];
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, 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 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, 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 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`).
- Copying a reference site's code, copy, pictures or signature effect.
- Deleting or editing the kit's own files (`assets/`, `scripts/`) to clear a check: a check that
flags a kit file is reported, and the page is checked again after any change.
## References
All in `references/`; section 3 names the one each asset needs.
-216
View File
@@ -1,216 +0,0 @@
/* tasteskill / app.css
A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults
measured in the study of real product screens (references/app-screens.md). Copy it into the project,
replace the token values with the ones on the app sheet (draw.mjs --app), and build on top.
Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */
/* ---------- tokens (replace with the dealt style) ---------- */
:root {
/* names match the app sheet printed by draw.mjs --app */
--ground: #f9fafb; /* the app chrome and page */
--surface: #ffffff; /* panels and the work sheet */
--raised: #f4f5f6; /* a filled inner well, hover rows */
--active: #eeeeee; /* the active nav pill */
--border: #e8e8e8; /* panel edges: about 1.2:1 on white */
--rule: #f1f2f3; /* row rules inside tables */
--control: #d1d5db; /* inputs and outlined buttons: one step stronger */
--text: #111111;
--muted: #5f6368; /* secondary text, labels (4.5:1 or more on its ground) */
--faint: #6b6b6b; /* meta and placeholders: the lightest grey that holds 4.5:1 on white */
--accent: #1f6f5c; /* one hue: data, focus, the selected thing */
--primary: #1d1f20; /* the primary button is near-black unless the style says otherwise */
--primary-ink: #ffffff;
--positive: #247b50; --negative: #c4242a; --warning: #92400e; /* references/app-screens.md §9.5; a green accent is also --positive */
--pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e;
--pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e;
--pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e;
--pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d;
--chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6;
--chart-mark: var(--text); /* a marked period in Charts.diverging: the second tone */
--chart-span: 0.06; /* opacity of a shaded span (a selling window, an outage) */
--grid: var(--rule); /* chart gridlines (charts.js) */
--axis-ink: var(--muted); /* chart tick labels (charts.js) */
--ink: var(--text);
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px;
--sidebar-width: 232px;
--pad: 24px; /* canvas padding */
--gap: 16px; /* one gutter, horizontal and vertical */
--card-pad: 20px;
--radius-card: 8px; /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */
--radius-control: 6px;
--control-height: 32px; /* every control in the title and filter rows */
--row-height: 48px; /* single-line table row */
--nav-row: 32px; /* sidebar item height, on a 34px pitch */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms;
}
/* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */
:root[data-theme="dark"] {
--ground: #000000; --surface: #111111; --raised: #222222; --active: #222222;
--border: #1a1a1a; --rule: #161616; --control: #444444;
--text: #ffffff; --muted: #b4b4b4; --faint: #949494;
--primary: #ffffff; --primary-ink: #000000;
--positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b; /* a green or yellow-green accent is also --positive */
--pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a;
--pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f;
--pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b;
--pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff;
--chart-muted: #4a4a4a; --grid: #2b2b2b;
--chart-span: 0.12;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; }
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.num, table, .kpi-value { font-variant-numeric: tabular-nums; }
/* ---------- shell: sidebar + canvas ---------- */
.app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; }
.sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; }
.sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); }
.nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; }
.nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; }
.nav-item:hover { background: var(--raised); color: var(--text); }
.nav-item[aria-current="page"] { background: var(--active); color: var(--text); }
.sidebar .bottom { margin-top: auto; }
.canvas { min-width: 0; padding: var(--pad); }
/* ---------- title block and filters ---------- */
.title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.title-row .sub { margin-top: 4px; color: var(--muted); }
.filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; }
.filters .spacer { flex: 1; }
.stamp { color: var(--faint); font-size: var(--fs-small); }
/* ---------- panels and grid ---------- */
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; }
.panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; }
.panel .meta { color: var(--faint); font-size: var(--fs-small); }
/* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps
its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */
.grid.top { align-items: start; }
/* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */
.chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; }
.chart { position: relative; min-width: 0; }
/* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover,
and no inline padding, so its text sits on the card's content edge */
.card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; }
.card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; }
.card-link:hover { text-decoration: underline; }
:root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
/* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row
that carries a rule has square corners */
.rows { display: grid; }
.row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--rule); border-radius: 0; }
/* KPIs as one divided strip: a label above a number, no icons, no tiles */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); }
.kpi { padding: 16px var(--card-pad); }
.kpi + .kpi { border-left: 1px solid var(--border); }
.kpi-label { color: var(--muted); }
.kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; }
.kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; }
.delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); }
.delta .up { color: var(--positive); } .delta .down { color: var(--negative); }
/* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and
their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity
breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */
.kpis[role="tablist"] .kpi { position: relative; cursor: pointer; }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; }
/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
/* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */
td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; }
tbody tr:last-child td { border-bottom: 0; }
/* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */
tbody tr[aria-selected="true"] td { background: var(--active); }
th.n, td.n { text-align: right; }
td .sub { display: block; font-size: var(--fs-small); color: var(--muted); }
.truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* status pill: a pale tint with dark ink of the same hue, one per row */
.pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; }
.pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
.pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); }
.pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); }
/* ---------- controls ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); }
.btn:hover { background: var(--raised); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; }
.btn.ghost { border-color: transparent; background: transparent; }
.field { display: grid; gap: 8px; }
.field label { font-weight: 500; }
.field .hint { color: var(--muted); font-size: var(--fs-small); }
.input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); }
.input::placeholder { color: var(--faint); }
.tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); }
.tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; }
.tabs a[aria-selected="true"] { color: var(--text); }
.tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
/* as wide as its options; full width only on a narrow phone */
.segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); }
.segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
.switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); }
.switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); }
/* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */
.empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; }
.skeleton { background: var(--raised); border-radius: 4px; }
/* ---------- phone: the navigation folds away and the canvas takes the width ---------- */
.menu-toggle { display: none; }
@media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); }
/* the menu opens as a full-screen sheet */
.sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); }
.sidebar.open { transform: none; }
.menu-toggle { display: inline-flex; }
.canvas { padding: 16px; }
.grid > * { grid-column: 1 / -1 !important; }
.kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi + .kpi { border-left: 0; }
.kpi:nth-child(n + 3) { border-top: 1px solid var(--border); }
.kpi:nth-child(2n) { border-left: 1px solid var(--border); }
/* an odd count leaves no half-empty row: the last cell takes the full width */
.kpi:last-child:nth-child(odd) { grid-column: 1 / -1; }
/* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */
.kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; }
.title-row { flex-wrap: wrap; align-items: flex-start; }
}
@media (max-width: 480px) {
.segmented { display: flex; width: 100%; }
.segmented button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } }
/* an empty message spans the block it stands in, never one squeezed column */
.rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; }
@@ -0,0 +1,516 @@
/*
taste-skill backgrounds.css - 16 section and page grounds, drawn in CSS (no WebGL, no images except the
photograph ground's own). Link after system.css and assets/ui/cards.css. Rules: references/backgrounds.md.
A ground is one class on a <section> (or on <body> / a wrapper for the two page grounds, or on a panel for
.bg-stage):
<section class="section bg-halo"> ... </section>
Every ground sets the page tokens for its own children (--bg --surface --ink --muted --accent --card
--action --line, --fx-*), so the system's cards, buttons and fragments work on it unchanged. It draws on
the page palette (--p-*) and the lit ground (--ground-*, system.css). Body text keeps 7:1 and muted text
4.5:1 on its text zone (index.json "textZone"; check.mjs measures it on every palette). The one
exception is .bg-stage, a panel for a visual: words on it sit on a card, never on the stage itself.
Knobs, all optional, on :root or on one section:
--g-hue the colour the tints lean to (default: the accent, hue-guarded: never violet or lavender)
--g-hue-b its neighbour, 30 degrees round the wheel (default derived, and guarded the same way)
--g-k, --g-kl, --g-kd strength of the tints, of the lines, of the dark grounds: 1 at most; backgrounds.js
sets them from the palette's contrast headroom (see there)
--tone the second brand tone of .bg-band / .bg-field (default: the lit ground, system.css)
--tone-ink its text colour (default: the lit ground's ink)
--g-photo url(...) for .bg-photo
<html data-mode="light|dark"> says whether the page ink is dark or light; <html data-tint="away"> marks a
palette whose bg is a strong colour or a mid tone (tints then move away from the ink); <html
data-lowchroma> marks a grey accent (the stage then takes the lit ground's colour). backgrounds.js sets
them and --g-k / --g-kl / --g-kd from the palette, unless the page already has (load it plain in <head>).
*/
/* ---------------------------------------------------------------- tokens */
:root {
/* the hue the tints lean to: the accent, with an oklch hue in 262-338 (violet, lavender, purple) moved
to azure (262-300) or pink (300-338), so a cobalt or plum accent never tints a ground violet */
--g-hue-src: var(--p-accent);
/* the tint's lightness is held between these, so a pale lime on a dark page or a navy on a white one
tints as deeply as a mid blue would */
--g-L-lo: 0.62; --g-L-hi: 1;
--g-hue: oklch(from var(--g-hue-src) clamp(var(--g-L-lo), l, var(--g-L-hi)) c
calc(h + clamp(0, (h - 262) * 1000, 1) * clamp(0, (338 - h) * 1000, 1)
* (250 + clamp(0, (h - 300) * 1000, 1) * 95 - h)));
/* the neighbour: 30 degrees toward the cooler side (toward the warmer side for a red or a pink, hues under 40
or over 330, so it never turns rose-violet). Any colour's own hue goes through this:
oklch(from X l c calc(h + var(--g-hs))) */
--g-hs: calc(30 - 60 * clamp(0, (h - 40) * 1000, 1) * clamp(0, (330 - h) * 1000, 1));
--g-hue-b: oklch(from var(--g-hue) l c calc(h + var(--g-hs)));
/* the hue as a pale pool, its lightness fixed: for the second-tone grounds, whose ink is dark whatever the page is */
--g-pool: oklch(from var(--g-hue) 0.8 calc(c * 0.6) h);
/* a second, different tone for two-colour fields: the lit ground (sand, sky, sage, painting) */
--g-hue-2: var(--ground-bg, #d9ccb7);
/* ...as a tint: its lightness held to the same band as --g-hue, so the contrast headroom (--g-k) counts for it too.
The soft grounds take it as their SECOND hue (not the accent's 30-degree neighbour), so a blue page is not blue
throughout: a blue wash with a sand light in the corner, as on Figma's colour fields */
--g-hue-t: oklch(from var(--g-hue-2) clamp(var(--g-L-lo), l, var(--g-L-hi)) c h);
/* the accent's own hue with the same guard: the stage uses it on a strong-colour page (data-tint="away"),
where --g-hue is the page's own colour and a stage in it would be a muddy tan on the same colour */
--g-acc: oklch(from var(--p-accent) 0.7 c calc(h + clamp(0, (h - 262) * 1000, 1) * clamp(0, (338 - h) * 1000, 1)
* (250 + clamp(0, (h - 300) * 1000, 1) * 95 - h)));
/* black when the page ink is light, white when it is dark: the way a shade moves away from the ink */
--g-away: oklab(from var(--p-ink) calc(1 - round(l)) 0 0);
--g-k: 1; --g-kl: 1; --g-kd: 1; --g-kp: 1;
/* the dark side: the page's inverse on a light page, its own bg on a dark one (data-mode) */
--g-dark-bg: var(--p-inv-bg); --g-dark-surface: var(--p-inv-surface);
--g-dark-ink: var(--p-inv-ink); --g-dark-muted: var(--p-inv-muted);
--g-dark-accent: var(--p-inv-accent); --g-dark-accent-ink: var(--p-inv-accent-ink);
--g-dark-action: var(--p-inv-action); --g-dark-action-ink: var(--p-inv-action-ink);
--g-light-bg: var(--p-bg); --g-light-ink: var(--p-ink); --g-light-muted: var(--p-muted);
--g-light-action: var(--p-action); --g-light-action-ink: var(--p-action-ink);
--g-light-accent: var(--p-accent); --g-light-accent-ink: var(--p-accent-ink);
--g-card: var(--p-bg); /* a card on a tinted light ground is the page's own white */
--g-grain: var(--g-noise-mul); --g-grain-mode: multiply; /* grain on a light page darkens, on a dark page lightens */
/* a cover over the grain, so it thins out with --g-k (--g-kd on the dark grounds) */
--g-cover: color-mix(in oklab, var(--bg) calc(100% - 100% * var(--g-k)), transparent);
--g-cover-d: color-mix(in oklab, var(--bg) calc(100% - 100% * var(--g-kd)), transparent);
--g-fade: 120px; /* the soft grounds fade into the page over this much at top and bottom */
--g-edge: 160px; /* .bg-dusk: how long the tone change takes */
/* grain: SVG fractal noise as a data URI, 240px tile, two octaves at 1.2 so it is fine, like film, never
like sand. soft is mean grey (neutral under soft-light, for the mid-tone grounds), mul sits near white
(multiply, for light pages), scr near black (screen, for dark) */
--g-noise-soft: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='0.6' intercept='.2'/%3E%3CfeFuncG type='linear' slope='0.6' intercept='.2'/%3E%3CfeFuncB type='linear' slope='0.6' intercept='.2'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--g-noise-mul: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='.26' intercept='.84'/%3E%3CfeFuncG type='linear' slope='.26' intercept='.84'/%3E%3CfeFuncB type='linear' slope='.26' intercept='.84'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--g-noise-scr: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='.17' intercept='-.05'/%3E%3CfeFuncG type='linear' slope='.17' intercept='-.05'/%3E%3CfeFuncB type='linear' slope='.17' intercept='-.05'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
html[data-mode="dark"] {
--g-L-lo: 0; --g-L-hi: 0.55;
--g-dark-bg: var(--p-bg); --g-dark-surface: var(--p-surface);
--g-dark-ink: var(--p-ink); --g-dark-muted: var(--p-muted);
--g-dark-accent: var(--p-accent); --g-dark-accent-ink: var(--p-accent-ink);
--g-dark-action: var(--p-action); --g-dark-action-ink: var(--p-action-ink);
--g-light-bg: var(--p-inv-bg); --g-light-ink: var(--p-inv-ink); --g-light-muted: var(--p-inv-muted);
--g-light-action: var(--p-inv-action); --g-light-action-ink: var(--p-inv-action-ink);
--g-light-accent: var(--p-inv-accent); --g-light-accent-ink: var(--p-inv-accent-ink);
--g-card: var(--p-surface); /* a card on a dark ground is one step lighter than it */
--g-grain: var(--g-noise-scr); --g-grain-mode: screen;
}
/* a palette whose bg is a strong colour (cobalt, mustard, pink) or a mid tone (stone, slate): the accent is
another hue, and mixing the two turns the ground to mud, so the tints are the bg's own colour pushed
away from the ink: a deeper cobalt on white text, a paler mustard on black text */
html[data-tint="away"][data-mode="light"] { --g-grain: var(--g-noise-scr); --g-grain-mode: screen; }
html[data-tint="away"][data-mode="dark"] { --g-grain: var(--g-noise-mul); --g-grain-mode: multiply; }
html[data-tint="away"] { --g-hue-src: color-mix(in oklab, var(--p-bg) 58%, var(--g-away)); --g-hue-2: color-mix(in oklab, color-mix(in oklab, var(--p-bg) 66%, var(--g-away)) 90%, var(--p-accent)); }
/* ---------------------------------------------------------------- the contract of every ground */
.bg-mist, .bg-halo, .bg-grain, .bg-dusk, .bg-ruled, .bg-dots, .bg-arcs,
.bg-band, .bg-field, .bg-stage, .bg-photo, .bg-horizon, .bg-deep, .bg-liquid {
position: relative; isolation: isolate; overflow: clip;
--bg: var(--p-bg); --surface: var(--p-surface); --ink: var(--p-ink);
/* muted leans to the ink (60/40), so a tint under it never costs the 4.5:1 (backgrounds.js counts on 60/40) */
--muted: color-mix(in oklab, var(--p-ink) 60%, var(--p-muted));
--accent: var(--p-accent); --accent-ink: var(--p-accent-ink);
--action: var(--p-action); --action-ink: var(--p-action-ink);
--card: var(--g-card);
--line: color-mix(in oklab, var(--ink) 11%, transparent);
--line-strong: color-mix(in oklab, var(--ink) 20%, transparent);
--fx-bg: var(--bg); --fx-surface: var(--surface); --fx-ink: var(--ink); --fx-muted: var(--muted);
--fx-accent: var(--accent); --fx-accent-ink: var(--accent-ink);
--ui-font: var(--font-text); --ui-mono: var(--font-mono); --ui-radius: var(--r-md);
background-color: var(--bg); color: var(--ink);
}
/* the two page grounds (.bg-paper on <body>, .bg-topglow on a wrapper) set NO tokens: the page keeps its own --card,
--muted, --line and --accent (a ground that restyled the body would turn every card flat). They paint only
background-image and ::before. */
.bg-paper, .bg-topglow { position: relative; isolation: isolate; }
.bg-paper { background-color: var(--bg); } /* the base the grain blends with */
/* the soft grounds are overlays: they paint no colour of their own, so they sit on the page ground (plain or .bg-paper) */
:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid) { background-color: transparent; }
/* the decorations of the soft grounds sit on ::before, behind the content, and fade into the page at
the top and bottom, so a soft ground can neighbour any other */
:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs)::before {
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent);
}
/* dark grounds take the dark side's tokens */
.bg-horizon, .bg-deep, .bg-photo {
--bg: color-mix(in oklab, var(--g-dark-bg) 80%, black);
--surface: color-mix(in oklab, var(--g-dark-surface) 80%, black);
--ink: var(--g-dark-ink);
--muted: color-mix(in oklab, var(--g-dark-ink) 60%, var(--g-dark-muted));
--accent: var(--g-dark-accent); --accent-ink: var(--g-dark-accent-ink);
--action: var(--g-dark-action); --action-ink: var(--g-dark-action-ink);
--card: color-mix(in oklab, var(--bg) 88%, var(--ink));
/* the light on a dark ground: the ground's own colour and the hue, then pulled toward the ink */
--lit: color-mix(in oklab, color-mix(in oklab, var(--bg) 55%, var(--g-hue)) 68%, var(--ink));
}
/* the second-tone grounds take the lit ground's tokens (sand, sky, sage, painting), or --tone */
.bg-band, .bg-field {
/* a little lighter than the lit ground, so its dark ink keeps 7:1 on all four (painting is the darkest) */
--bg: var(--tone, color-mix(in oklab, var(--ground-bg) 55%, var(--ground-surface)));
--surface: var(--ground-surface);
--ink: var(--tone-ink, color-mix(in oklab, var(--ground-ink) 85%, black));
--muted: color-mix(in oklab, var(--ink) 60%, var(--ground-muted));
--accent: var(--ground-accent); --accent-ink: var(--ground-surface);
--action: var(--ink); --action-ink: var(--bg);
--card: color-mix(in oklab, var(--ground-surface) 70%, white);
/* the tone's own neighbours, so a field is one family of colour and never two that turn to mud: a richer
neighbour 30 degrees round the wheel (a peach beside sand, cyan beside sky) and a deeper shade of itself */
--t-b: oklch(from var(--bg) l clamp(0, c * 1.6 + 0.022, 0.075) calc(h + var(--g-hs)));
--t-c: oklch(from var(--bg) calc(l - 0.032) calc(c * 1.3 + 0.012) h);
}
/* .bg-inset: a hard ground as a rounded panel on the page's grid, its left and right edges on the container's
(Figma's colour panels, x.ai's video card). Add it next to the ground class: class="section bg-field bg-inset".
On a dark page a band or a field is always inset (a pale full-bleed slab is the brightest thing on a dark page and
harsh at a phone's width); .bg-bleed opts out. */
.bg-inset, html[data-mode="dark"] :is(.bg-band, .bg-field):not(.bg-bleed) { width: min(calc(100% - 2 * var(--pad)), var(--container)); margin-inline: auto; margin-block: var(--s-5); border-radius: var(--r-lg); }
/* Spacing. A ground that changes the colour is spaced like the system's own bands, and a little more; a soft one a half
step more than a plain section; and each holds a screen of its own (min-height, the words centred), so a shot of one
section never shows the next. The system's own variants keep what they set: the hero (.section--hero: its top, its
screen-high box), .section--flush-top and .section--rule are never touched. --g-screen on a section sets its minimum
height (0px = the words' own height). */
.section:is(.bg-band, .bg-field, .bg-grain, .bg-horizon, .bg-deep, .bg-photo):not(.section--hero, .section--flush-top, .section--rule) { padding-block: calc(var(--section-band) * 1.25); }
.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid):not(.section--hero, .section--flush-top, .section--rule) { padding-block: calc(var(--section) * 1.5); }
/* a hero on a ground keeps its system top and its screen height and gets the ground's room under it */
.section--hero:is(.bg-band, .bg-field, .bg-grain, .bg-horizon, .bg-deep, .bg-photo) { padding-bottom: calc(var(--section-band) * 1.25); }
.section--hero:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid) { padding-bottom: calc(var(--section) * 1.5); }
.section:is(.bg-band, .bg-field, .bg-grain, .bg-dusk, .bg-horizon, .bg-deep):not(.section--hero) { min-height: var(--g-screen, min(88svh, 860px)); display: grid; align-content: center; }
.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-liquid):not(.section--hero) { min-height: var(--g-screen, min(76svh, 760px)); display: grid; align-content: center; }
/* on a phone the stacked words already fill the screen: the sections keep their padding and no minimum height */
@media (max-width: 767px) {
.section:is(.bg-band, .bg-field, .bg-grain, .bg-dusk, .bg-horizon, .bg-deep, .bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-liquid):not(.section--hero) { min-height: var(--g-screen, 0px); }
}
/* system.css forces the last section before the footer to one plain bottom padding (!important): a ground that closes the
page keeps its own (the horizon's dome needs its 150px; a band stays even top and bottom) */
main > section.section:is(.bg-band, .bg-field, .bg-grain, .bg-deep, .bg-photo):not(.section--hero):last-of-type { padding-bottom: calc(var(--section-band) * 1.25) !important; }
main > section.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid):not(.section--hero):last-of-type { padding-bottom: calc(var(--section) * 1.5) !important; }
main > section.section.bg-horizon:last-of-type { padding-bottom: calc(var(--section-band) * 1.25 + 150px) !important; }
/* ---------------------------------------------------------------- 1 mist: a soft gradient field */
.bg-mist::before {
background:
radial-gradient(54% 70% at 4% 6%, color-mix(in oklab, var(--g-hue) calc(42% * var(--g-k)), transparent), transparent),
radial-gradient(46% 62% at 98% 16%, color-mix(in oklab, var(--g-hue-t) calc(48% * var(--g-k)), transparent), transparent),
radial-gradient(64% 64% at 66% 116%, color-mix(in oklab, var(--g-hue) calc(24% * var(--g-k)), transparent), transparent),
radial-gradient(34% 46% at 22% 104%, color-mix(in oklab, var(--g-hue-b) calc(18% * var(--g-k)), transparent), transparent);
}
/* ---------------------------------------------------------------- 2 halo: a radial light behind the heading */
.bg-halo { --halo-x: 50%; --halo-y: 38%; }
.bg-halo::before {
background:
radial-gradient(34% 40% at var(--halo-x) var(--halo-y), color-mix(in oklab, var(--g-hue) calc(30% * var(--g-k)), transparent), transparent),
radial-gradient(70% 76% at var(--halo-x) var(--halo-y), color-mix(in oklab, var(--g-hue) calc(15% * var(--g-k)), transparent), transparent),
radial-gradient(30% 34% at calc(var(--halo-x) + 18%) calc(var(--halo-y) + 16%), color-mix(in oklab, var(--g-hue-t) calc(32% * var(--g-k)), transparent), transparent);
}
/* ---------------------------------------------------------------- 3 grain: a tint with fine grain on it */
.bg-grain {
--bg: color-mix(in oklab, var(--p-bg) calc(100% - 11% * var(--g-k)), var(--g-hue));
--surface: color-mix(in oklab, var(--p-surface) calc(100% - 11% * var(--g-k)), var(--g-hue));
background-image:
radial-gradient(90% 100% at 0% 0%, color-mix(in oklab, var(--p-bg) 74%, transparent), transparent 70%),
radial-gradient(54% 80% at 100% 0%, color-mix(in oklab, var(--g-hue-b) calc(20% * var(--g-k)), transparent), transparent 72%),
radial-gradient(80% 90% at 100% 100%, color-mix(in oklab, var(--g-hue) calc(26% * var(--g-k)), transparent), transparent 70%),
radial-gradient(46% 70% at 0% 100%, color-mix(in oklab, var(--g-hue-b) calc(12% * var(--g-k)), transparent), transparent 72%),
linear-gradient(var(--g-cover), var(--g-cover)),
var(--g-grain);
background-blend-mode: normal, normal, normal, normal, normal, var(--g-grain-mode);
}
/* ---------------------------------------------------------------- 4 paper: the page ground, never flat */
.bg-paper {
overflow: visible;
background-image:
radial-gradient(120% 60vh at 50% 0, color-mix(in oklab, var(--g-hue) calc(9% * var(--g-kp)), transparent), transparent),
radial-gradient(60% 50vh at 100% 0, color-mix(in oklab, var(--g-hue-b) calc(6% * var(--g-kp)), transparent), transparent),
linear-gradient(to bottom, transparent 70%, color-mix(in oklab, var(--g-hue) calc(6% * var(--g-kp)), transparent)),
/* the page ground carries every word of the page, so its grain is a quarter of the grain ground's */
linear-gradient(color-mix(in oklab, var(--bg) calc(100% - 28% * var(--g-kp)), transparent), color-mix(in oklab, var(--bg) calc(100% - 28% * var(--g-kp)), transparent)),
var(--g-grain);
background-blend-mode: normal, normal, normal, normal, var(--g-grain-mode);
}
/* ---------------------------------------------------------------- 5 dusk: one tone moves into the next, no edge */
.bg-dusk {
/* the two ends are transparent, so the tone melts into whatever the page really paints there (plain, .bg-paper's grain, a
neighbouring tint) and never meets a page colour it was not. Set --g-from / --g-next to a colour to melt from or into one. */
--g-from: transparent; --g-to: color-mix(in oklab, var(--p-bg) calc(100% - 15% * var(--g-k)), var(--g-hue)); --g-next: transparent;
--bg: var(--g-to);
--surface: color-mix(in oklab, var(--p-surface) calc(100% - 15% * var(--g-k)), var(--g-hue));
background-color: transparent;
background-repeat: no-repeat;
/* the two melting edges and, between them (a pixel of overlap each side), the solid tone */
background-size: 100% var(--g-edge), 100% var(--g-edge), 100% calc(100% - 2 * var(--g-edge) + 2px);
background-position: top, bottom, center;
/* a smoothstep: 0, .04, .16, .32, .5, .68, .84, .96, 1 of the way */
background-image:
linear-gradient(to bottom,
var(--g-from), color-mix(in oklab, var(--g-to) 4%, var(--g-from)) 12.5%, color-mix(in oklab, var(--g-to) 16%, var(--g-from)) 25%,
color-mix(in oklab, var(--g-to) 32%, var(--g-from)) 37.5%, color-mix(in oklab, var(--g-to) 50%, var(--g-from)) 50%,
color-mix(in oklab, var(--g-to) 68%, var(--g-from)) 62.5%, color-mix(in oklab, var(--g-to) 84%, var(--g-from)) 75%,
color-mix(in oklab, var(--g-to) 96%, var(--g-from)) 87.5%, var(--g-to)),
linear-gradient(to top,
var(--g-next), color-mix(in oklab, var(--g-to) 4%, var(--g-next)) 12.5%, color-mix(in oklab, var(--g-to) 16%, var(--g-next)) 25%,
color-mix(in oklab, var(--g-to) 32%, var(--g-next)) 37.5%, color-mix(in oklab, var(--g-to) 50%, var(--g-next)) 50%,
color-mix(in oklab, var(--g-to) 68%, var(--g-next)) 62.5%, color-mix(in oklab, var(--g-to) 84%, var(--g-next)) 75%,
color-mix(in oklab, var(--g-to) 96%, var(--g-next)) 87.5%, var(--g-to)),
linear-gradient(var(--g-to), var(--g-to));
}
.section.bg-dusk:not(.section--hero) { padding-block: calc(var(--section) + var(--g-edge) * .5); }
/* ---------------------------------------------------------------- 6 topglow: light under the nav, the page lifts out of it */
/* on <body> (with bg-paper) or a wrapper that holds the nav and the hero: a pool of light and a thin lit line just
under the nav bar, fading out by 700px down, a second tone leaning in from the right. It draws on ::before, so it
stacks with bg-paper on one element. */
.bg-topglow { overflow: visible; }
.bg-topglow::before {
content: ""; position: absolute; z-index: -1; top: 0; left: 0; right: 0; height: min(100%, 720px); pointer-events: none;
background:
linear-gradient(90deg, transparent, color-mix(in oklab, var(--g-hue) calc(60% * var(--g-kp)), transparent) 50%, transparent) 50% var(--nav-h, 64px) / 62% 1px no-repeat,
radial-gradient(62% 470px at 50% var(--nav-h, 64px), color-mix(in oklab, var(--g-hue) calc(27% * var(--g-kp)), transparent), transparent),
radial-gradient(42% 380px at 86% calc(var(--nav-h, 64px) + 60px), color-mix(in oklab, var(--g-hue-b) calc(18% * var(--g-kp)), transparent), transparent),
radial-gradient(120% 300px at 50% var(--nav-h, 64px), color-mix(in oklab, var(--g-hue) calc(12% * var(--g-kp)), transparent), transparent);
}
/* ---------------------------------------------------------------- 7 ruled: a fine grid that fades out */
/* square rules (Vercel's and Linear's heroes). The glow is ::before, full width; the grid is ::after, exactly as wide as the
container: its first rule lies on the container's left edge, the cells are spaced to fit, and its right edge is a rule too, so
the grid is the page's own frame and never a second system across it. It is clear behind the heading (a hole in the mask)
and fades toward the top and bottom. For a section with no row rules of its own: never over a table, pricing or a list
(index.json: ruled is dealt only to the sections that list it). */
.bg-ruled::before {
background: radial-gradient(50% 44% at 50% 0%, color-mix(in oklab, var(--g-hue) calc(20% * var(--g-k)), transparent), transparent);
}
.bg-ruled::after {
content: ""; position: absolute; z-index: -1; top: 0; bottom: 0; pointer-events: none; box-sizing: border-box;
--rule: color-mix(in oklab, var(--ink) calc(8% * var(--g-kl)), transparent);
--cell: 72px;
left: max(var(--pad), calc((100% - var(--container)) / 2)); right: max(var(--pad), calc((100% - var(--container)) / 2));
border-right: 1px solid var(--rule);
background:
linear-gradient(90deg, var(--rule) 1px, transparent 1px) 0 0 / var(--cell) var(--cell) space repeat,
linear-gradient(var(--rule) 1px, transparent 1px) 0 0 / var(--cell) var(--cell) repeat;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 120% 90% at 50% 40%, #000 30%, transparent 92%), radial-gradient(ellipse 46% 34% at 50% 38%, transparent 40%, #000 100%);
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 120% 90% at 50% 40%, #000 30%, transparent 92%), radial-gradient(ellipse 46% 34% at 50% 38%, transparent 40%, #000 100%);
-webkit-mask-composite: source-in; mask-composite: intersect;
}
@media (max-width: 767px) { .bg-ruled::after { --cell: 48px; } }
/* ---------------------------------------------------------------- 8 dots: a dot field that fades out */
/* the dots live in the margins and the corners and are clear behind the heading and the words (a hole in the middle of the mask,
not a spotlight on it), at 24% of the ink, so no line of body copy ever sits on them */
.bg-dots::before {
background:
radial-gradient(34% 46% at 6% 34%, color-mix(in oklab, var(--g-hue) calc(16% * var(--g-k)), transparent), transparent),
radial-gradient(30% 40% at 96% 70%, color-mix(in oklab, var(--g-hue-t) calc(24% * var(--g-k)), transparent), transparent),
radial-gradient(circle at 11px 11px, color-mix(in oklab, var(--ink) calc(26% * var(--g-kl)), transparent) 1.15px, transparent 1.9px) 0 0 / 22px 22px;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 58% 70% at 50% 40%, transparent 24%, #000 78%);
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 58% 70% at 50% 40%, transparent 24%, #000 78%);
-webkit-mask-composite: source-in; mask-composite: intersect;
}
/* ---------------------------------------------------------------- 9 arcs: rings that rise from below, x.ai's footer */
/* the rings need room under the words: a minimum height (the words centred), not more padding, so the gaps stay the page's */
.section.bg-arcs { min-height: clamp(420px, 36vw, 540px); display: grid; align-content: center; }
.bg-arcs::before {
background:
radial-gradient(56% 56% at 50% 100%, color-mix(in oklab, var(--g-hue) calc(22% * var(--g-k)), transparent), transparent),
radial-gradient(30% 40% at 78% 100%, color-mix(in oklab, var(--g-hue-t) calc(24% * var(--g-k)), transparent), transparent),
repeating-radial-gradient(circle at 50% 140%, transparent 0 79px, color-mix(in oklab, var(--ink) calc(22% * var(--g-kl)), transparent) 79px 80px);
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 84% 94% at 50% 100%, #000 30%, transparent 82%);
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 84% 94% at 50% 100%, #000 30%, transparent 82%);
-webkit-mask-composite: source-in; mask-composite: intersect;
}
/* ---------------------------------------------------------------- 10 band: a coloured band in a second tone */
/* the lit ground's tone with a light corner, a richer neighbour of the same colour leaning in from the right and a
deeper shade of itself in the far corner, under fine grain: one family of colour, never flat */
.bg-band {
background-image:
var(--g-noise-soft),
radial-gradient(66% 120% at 0% 0%, color-mix(in oklab, var(--bg) 42%, var(--g-hi, white)), transparent 72%),
radial-gradient(52% 96% at 100% 0%, color-mix(in oklab, var(--t-b) 62%, transparent), transparent 76%),
radial-gradient(64% 104% at 100% 100%, color-mix(in oklab, var(--t-c) 70%, transparent), transparent 78%);
background-blend-mode: soft-light, normal, normal, normal;
}
/* .bg-tint, with .bg-band or .bg-field: the second tone is a pale tint of the accent's own colour (a mint for a teal accent,
an azure for a blue one, Figma's flat mint panel) instead of the lit ground, so the band is coloured on a sand page too */
.bg-tint {
--tone: oklch(from var(--g-hue) 0.93 clamp(0.035, c * 0.32, 0.075) h);
--tone-ink: color-mix(in oklab, var(--g-light-ink) 58%, black);
--accent: var(--g-light-accent); --accent-ink: var(--g-light-accent-ink);
--surface: color-mix(in oklab, var(--tone) 60%, white);
--card: color-mix(in oklab, var(--g-light-bg) 80%, white);
}
/* on a dark page a pale tinted slab is the brightest thing there: the tint is a deep tone of the accent's colour with light words */
html[data-mode="dark"] .bg-tint {
--tone: oklch(from var(--g-hue) 0.24 clamp(0.04, c * 0.42, 0.09) h);
--tone-ink: color-mix(in oklab, var(--p-ink) 92%, white);
--muted: color-mix(in oklab, var(--tone-ink) 82%, var(--tone));
--accent: var(--p-accent); --accent-ink: var(--p-accent-ink);
--surface: color-mix(in oklab, var(--tone) 80%, white);
--card: color-mix(in oklab, var(--tone) 86%, black);
--action: var(--tone-ink); --action-ink: var(--tone);
--g-hi: color-mix(in oklab, var(--tone) 88%, white); /* the light corner of the band and the field stays deep, under light words */
}
/* ---------------------------------------------------------------- 11 field: a grainy gradient in the lit tone, Figma's panel */
.bg-field {
background-image:
var(--g-noise-soft),
linear-gradient(112deg, transparent 38%, color-mix(in oklab, var(--g-hi, white) 26%, transparent) 50%, transparent 62%),
radial-gradient(58% 86% at 8% 0%, color-mix(in oklab, var(--g-hi, white) 58%, transparent), transparent),
radial-gradient(54% 80% at 82% 6%, color-mix(in oklab, var(--t-b) 80%, transparent), transparent),
radial-gradient(60% 96% at 100% 100%, color-mix(in oklab, var(--t-c) 76%, transparent), transparent),
radial-gradient(40% 60% at 0% 100%, color-mix(in oklab, var(--t-b) 50%, transparent), transparent);
background-blend-mode: soft-light, normal, normal, normal, normal, normal;
}
/* ---------------------------------------------------------------- 12 stage: a vivid panel for a visual, Figma's tiles */
/* a rounded panel (not a section) behind one card or window: the accent's colour as a mesh with a light corner and a
deep one, and grain. It has NO text of its own: every word sits on a card on it (that is what lets it be vivid).
<div class="bg-stage"><div class="mini">...</div></div>
A grey accent (data-lowchroma) takes the lit ground's colour. --stage-pad is the room round the card. */
.bg-stage {
--st-src: var(--g-hue);
/* yellows go olive at mid lightness, so around hue 90 the mid and deep tones are lifted (+.12, +.1) */
--st-1: oklch(from var(--st-src) calc(0.64 + 0.12 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)) clamp(0.07, c * 0.8, 0.14) calc(h - 8 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)));
--st-2: oklch(from var(--st-src) 0.8 clamp(0.05, c * 0.6, 0.1) calc(h + var(--g-hs)));
--st-3: oklch(from var(--st-src) 0.95 0.035 h);
/* a yellow darkened turns olive: its deep tone leans to amber instead (hue -24 at yellow) */
--st-4: oklch(from var(--st-src) calc(0.44 + 0.1 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)) clamp(0.05, c * 0.7, 0.12) calc(h - 24 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)));
--stage-pad: clamp(20px, 4.5vw, 64px);
display: grid; place-items: center; padding: var(--stage-pad); border-radius: var(--r-lg);
background-color: var(--st-1);
background-image:
var(--g-noise-soft),
radial-gradient(58% 78% at 10% 4%, color-mix(in oklab, var(--st-3) 90%, transparent), transparent 72%),
radial-gradient(48% 70% at 90% 12%, color-mix(in oklab, var(--st-2) 92%, transparent), transparent 74%),
radial-gradient(70% 90% at 78% 112%, color-mix(in oklab, var(--st-4) 94%, transparent), transparent 74%),
radial-gradient(46% 62% at 4% 100%, color-mix(in oklab, var(--st-2) 60%, transparent), transparent 72%);
background-blend-mode: soft-light, normal, normal, normal, normal;
}
html[data-tint="away"] .bg-stage { --st-src: var(--g-acc); }
html[data-lowchroma] .bg-stage { --st-src: oklch(from var(--ground-accent) l c h); }
.bg-stage > * { position: relative; width: min(100%, var(--stage-w, 520px)); }
/* ---------------------------------------------------------------- 13 photo: a full-bleed photograph under a calm scrim */
/* The photograph is an <img class="bg-photo__img" src alt> child (alt text, lazy loading, found early by the
browser), or --g-photo: url(...) when the section cannot hold one. The scrim is the dark side's tone and is at
least .76 deep from the bottom edge up to --scrim-h (the top of the words; backgrounds.js measures it from the
section's own text and sets it, 66% by default, 86% on a phone), so white words keep 7:1 even on a white
photograph (a scrim of .76 over white is darker than #595959). A pale photograph takes .bg-photo--light: the scrim is
the light side's tone and the words are dark. The scrim is the section's ::before, above the photograph.
--g-photo-pos: where the subject sits (object-position, e.g. "70% 30%"); --g-photo-pos-m the same on a phone, where the
words take the lower part and the subject belongs in the upper one. */
.bg-photo {
--scrim: color-mix(in oklab, var(--g-dark-bg) 70%, black);
/* the scrim deepens as the palette's dark side loses headroom (--g-kd): .88 / .80 / .76 at 1, solid at 0 */
--scrim-a: color-mix(in oklab, var(--scrim) calc(100% - 12% * var(--g-kd)), transparent);
--scrim-b: color-mix(in oklab, var(--scrim) calc(100% - 20% * var(--g-kd)), transparent);
--scrim-c: color-mix(in oklab, var(--scrim) calc(100% - 24% * var(--g-kd)), transparent);
--scrim-h: 66%; --scrim-w: 56%;
min-height: clamp(520px, 52vw, 760px); display: grid; align-content: end;
background: var(--g-photo, none) var(--g-photo-pos, center) / cover no-repeat, var(--scrim);
}
.bg-photo__img { position: absolute; inset: 0; z-index: -2; width: 100%; height: 100%; object-fit: cover; object-position: var(--g-photo-pos, 50% 50%); }
.bg-photo::before {
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
background: linear-gradient(to top, var(--scrim-a), var(--scrim-b) calc(var(--scrim-h) * .45), var(--scrim-c) var(--scrim-h), transparent calc(var(--scrim-h) + 26%));
}
.bg-photo--left { align-content: center; }
.bg-photo--left::before { background: linear-gradient(to right, var(--scrim-a), var(--scrim-b) calc(var(--scrim-w) * .64), var(--scrim-c) var(--scrim-w), transparent calc(var(--scrim-w) + 26%)); }
.bg-photo--light {
--scrim: var(--g-light-bg);
--scrim-a: color-mix(in oklab, var(--scrim) 94%, transparent);
--scrim-b: color-mix(in oklab, var(--scrim) 88%, transparent);
--scrim-c: color-mix(in oklab, var(--scrim) 76%, transparent);
--bg: color-mix(in oklab, var(--g-light-bg) 90%, white); --surface: var(--p-surface); --ink: var(--g-light-ink);
--muted: color-mix(in oklab, var(--g-light-ink) 60%, var(--g-light-muted));
--accent: var(--g-light-accent); --accent-ink: var(--g-light-accent-ink);
--action: var(--g-light-action); --action-ink: var(--g-light-action-ink);
--card: var(--g-light-bg);
}
@media (max-width: 767px) {
.bg-photo { --scrim-h: 86%; min-height: 640px; background-position: var(--g-photo-pos-m, var(--g-photo-pos, center)); }
.bg-photo__img { object-position: var(--g-photo-pos-m, var(--g-photo-pos, 50% 50%)); }
.bg-photo--left { align-content: end; }
.bg-photo--left::before { background: linear-gradient(to top, var(--scrim-a), var(--scrim-b) calc(var(--scrim-h) * .45), var(--scrim-c) var(--scrim-h), transparent calc(var(--scrim-h) + 12%)); }
}
/* ---------------------------------------------------------------- 14 horizon: a dark band with a lit horizon */
/* a dome of the page's dark tone rising from the bottom edge, its rim a thin line of light that is brightest at the
top and fades to both sides, with the glow above it (the planet's edge in x.ai's dark pages) */
.section.bg-horizon:not(.section--flush-top, .section--rule) { padding-bottom: calc(var(--section-band) * 1.25 + 150px); } /* (0,3,0), after the spacing block: keeps the dome's room, hero or not */
.bg-horizon::before {
content: ""; position: absolute; z-index: -1; left: 0; right: 0; bottom: 0; height: 640px; pointer-events: none;
background:
radial-gradient(26% 74px at 50% calc(100% - 140px), color-mix(in oklab, var(--lit) calc(70% * var(--g-kd)), transparent), transparent),
radial-gradient(54% 76% at 50% 100%, color-mix(in oklab, var(--lit) calc(44% * var(--g-kd)), transparent), transparent 74%),
radial-gradient(120% 84% at 50% 100%, color-mix(in oklab, var(--g-hue) calc(22% * var(--g-kd)), transparent), transparent 76%);
}
.bg-horizon::after {
/* a dome as wide as 170% of the section and never less than 1200px, so its curve stays a flat planet's rim on a phone (a
dome as wide as the phone is a pointed arch) */
content: ""; position: absolute; z-index: -1; left: 50%; translate: -50% 0; width: max(170%, 1200px); bottom: -560px; height: 700px; pointer-events: none;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
background: radial-gradient(60% 100% at 50% 0, color-mix(in oklab, var(--lit) 34%, var(--bg)), var(--bg) 72%);
border-top: 2px solid color-mix(in oklab, var(--lit) 90%, white);
box-shadow: 0 -12px 56px -8px color-mix(in oklab, var(--lit) 62%, transparent), 0 -1px 16px color-mix(in oklab, var(--lit) 70%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent 5%, #000 36%, #000 64%, transparent 95%);
mask-image: linear-gradient(90deg, transparent 5%, #000 36%, #000 64%, transparent 95%);
}
/* ---------------------------------------------------------------- 15 deep: near-black with a spotlight and grain, x.ai's dark */
.bg-deep {
--bg: color-mix(in oklab, var(--g-dark-bg) 70%, black);
background-image:
radial-gradient(64% 70% at 50% 0%, color-mix(in oklab, var(--g-hue) calc(21% * var(--g-kd)), transparent), transparent 76%),
radial-gradient(48% 60% at 100% 100%, color-mix(in oklab, var(--g-hue-t) calc(18% * var(--g-kd)), transparent), transparent 72%),
linear-gradient(180deg, color-mix(in oklab, var(--g-dark-surface) calc(40% * var(--g-kd)), var(--bg)), var(--bg) 70%),
linear-gradient(var(--g-cover-d), var(--g-cover-d)),
var(--g-noise-scr);
background-blend-mode: normal, normal, normal, normal, screen;
}
/* a hairline of light along the top edge, brightest in the middle (a lit lip, as on x.ai's cards) */
.bg-deep::before {
content: ""; position: absolute; z-index: -1; left: 0; right: 0; top: 0; height: 1px; pointer-events: none;
background: linear-gradient(90deg, transparent 6%, color-mix(in oklab, var(--ink) calc(28% * var(--g-kd)), transparent) 50%, transparent 94%);
}
/* ---------------------------------------------------------------- 16 liquid: a slow mesh of colour, transform only */
/* two layers, each wider than the section (160%), each holding two wide ellipses of colour that fade to nothing inside the
layer's own height (their radii end before the section's top and bottom, so nothing is clipped into an edge). They drift,
skew and stretch against each other (translate, skewX, scaleY: transforms only, nothing repaints), so the mesh keeps
changing shape. Three hues: the accent, the lit ground's tone (a second hue, so the wash is not blue all through) and
the accent's neighbour, all low in chroma. Held still, it is the same picture. */
.bg-liquid::before, .bg-liquid::after {
content: ""; position: absolute; z-index: -1; pointer-events: none; top: 0; bottom: 0; left: -30%; right: -30%; will-change: transform;
}
.bg-liquid::before {
background:
radial-gradient(ellipse 30% 36% at 30% 44%, color-mix(in oklab, var(--g-hue) calc(40% * var(--g-k)), transparent), transparent),
radial-gradient(ellipse 22% 24% at 54% 74%, color-mix(in oklab, var(--g-hue-t) calc(44% * var(--g-k)), transparent), transparent);
animation: g-liq-a 26s ease-in-out infinite alternate;
}
.bg-liquid::after {
background:
radial-gradient(ellipse 28% 30% at 68% 38%, color-mix(in oklab, var(--g-hue-b) calc(32% * var(--g-k)), transparent), transparent),
radial-gradient(ellipse 20% 20% at 44% 26%, color-mix(in oklab, var(--g-hue-t) calc(36% * var(--g-k)), transparent), transparent);
animation: g-liq-b 34s ease-in-out infinite alternate;
}
@keyframes g-liq-a { from { transform: translate3d(-7%, 0, 0) skewX(-7deg) scaleY(.94); } to { transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.05); } }
@keyframes g-liq-b { from { transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.04); } to { transform: translate3d(-9%, 0, 0) skewX(-6deg) scaleY(.92); } }
.bg-liquid.is-off::before, .bg-liquid.is-off::after, .bg-liquid.is-held::before, .bg-liquid.is-held::after { animation-play-state: paused; }
/* the pause disc sits on the container's right edge, not the section's corner */
.bg-liquid > .loop-pause { right: max(var(--pad), calc((100% - var(--container)) / 2)); bottom: 16px; }
/* ---------------------------------------------------------------- motion and print */
@media (prefers-reduced-motion: reduce) {
.bg-liquid::before, .bg-liquid::after { animation: none; }
}
@media print { .bg-liquid::before, .bg-liquid::after { display: none; } }
@@ -0,0 +1,123 @@
/*
taste-skill backgrounds.js - optional, about 2 KB of work. Load it plain (not deferred) at the end of <head>,
right after the :root palette block, so a dark page never paints one frame with the light page's grain.
It reads the page palette (--p-*) and sets, on <html>, what backgrounds.css cannot know in CSS:
data-mode light | dark is the page ink lighter than the page bg?
data-tint away (only when) the bg is a strong colour or a mid tone: tints move away from the
ink, which only ever adds contrast
data-lowchroma (only when) the accent is a grey or near-grey: .bg-stage then takes the lit
ground's colour instead of a hue that does not exist
--g-k 0.08 to 1 how strongly the tints may show on this palette: the contrast headroom of the
ink (7:1) and muted (4.5:1) text, in lightness, over the depth the grounds are
drawn at. A palette whose ink is only just 7:1 on its bg gets almost flat grounds
(an away palette keeps 1: its tints only add contrast)
--g-kl the same headroom, kept for the grounds that draw in the ink's own direction (the rules, dots, rings)
--g-kp the same, counted on the raw muted text, for the two page grounds (paper, topglow), which set no tokens
--g-kd the same for the dark grounds (horizon, deep, photo)
An attribute or variable the page (or start.mjs) already set is kept (--g-k, --g-kl, --g-kd each on its own: inline on
<html> or in a stylesheet). It also pauses the .bg-liquid blobs while their section is off screen, and measures each
.bg-photo's words to set how high its scrim stays deep (--scrim-h, or --scrim-w for .bg-photo--left on a wide screen).
Every other ground is plain CSS.
*/
(() => {
const root = document.documentElement;
const get = n => getComputedStyle(root).getPropertyValue(n).trim();
const hex = n => {
let v = get(n).replace('#', '');
if (v.length === 3) v = [...v].map(c => c + c).join('');
return /^[0-9a-f]{6}$/i.test(v) ? [0, 2, 4].map(i => parseInt(v.slice(i, i + 2), 16) / 255) : null;
};
const lin = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const Y = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const oklab = rgb => {
const [r, g, b] = rgb.map(lin);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
};
const REF = 0.22; // the deepest a tint is drawn at k = 1, in oklab lightness
/* how far (oklab L) a ground of colour bg may move toward the ink and keep ink 7:1 and the muted
text a ground uses (60% ink, 40% palette muted) 4.5:1 */
const room = (ink, muted, bg, bgScale = 1, mixInk = 0.6) => {
const Li = oklab(ink)[0], Lm = mixInk * Li + (1 - mixInk) * oklab(muted)[0], Lb = oklab(bg)[0] * bgScale, Yi = Y(ink), Ym = Lm ** 3;
if (Li > Lb) {
const max = Math.min((Yi + 0.05) / 7 - 0.05, (Ym + 0.05) / 4.5 - 0.05) * 0.8;
return max > 0 ? Math.cbrt(max) - Lb : 0;
}
const min = Math.max(7 * (Yi + 0.05) - 0.05, 4.5 * (Ym + 0.05) - 0.05) * 1.15;
return min < 1 ? Lb - Math.cbrt(min) : 0;
};
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const detect = force => {
const bg = hex('--p-bg'), ink = hex('--p-ink'), muted = hex('--p-muted');
if (!bg || !ink) return;
const a = oklab(bg), b = oklab(ink);
if (force || !root.dataset.mode) root.dataset.mode = b[0] > a[0] ? 'dark' : 'light';
if (force) { root.removeAttribute('data-tint'); ['--g-k', '--g-kl', '--g-kd', '--g-kp'].forEach(p => root.style.removeProperty(p)); }
const ac = hex('--p-accent');
if (ac) root.toggleAttribute('data-lowchroma', Math.hypot(oklab(ac)[1], oklab(ac)[2]) < 0.045);
const away = Math.hypot(a[1], a[2]) > 0.09 || (a[0] > 0.3 && a[0] < 0.84);
if (away && !root.hasAttribute('data-tint')) root.setAttribute('data-tint', 'away');
const m = muted || ink;
/* a strength the page already set (inline on <html>, or in a stylesheet: anything but the CSS default of 1) is kept, each on its own */
const mine = n => force || (!root.dataset.k && root.style.getPropertyValue(n).trim() === '' && ['', '1'].includes(get(n)));
const kl = clamp(room(ink, m, bg) / REF, 0.08, 1);
if (mine('--g-kl')) root.style.setProperty('--g-kl', kl.toFixed(3));
if (mine('--g-k')) root.style.setProperty('--g-k', root.dataset.tint === 'away' ? '1' : kl.toFixed(3));
/* the page grounds (paper, topglow) sit under the page's own muted text, which they do not re-mix: their headroom is counted on the palette's raw muted */
if (mine('--g-kp')) root.style.setProperty('--g-kp', root.dataset.tint === 'away' ? '1' : clamp(room(ink, m, bg, 1, 0) / REF, 0.08, 1).toFixed(3));
const db = hex('--g-dark-bg'), di = hex('--g-dark-ink'), dm = hex('--g-dark-muted');
if (db && di && dm && mine('--g-kd')) root.style.setProperty('--g-kd', clamp(room(di, dm, db, 0.8) / REF, 0.08, 1).toFixed(3));
};
detect(false);
window.tasteBackgrounds = { detect: () => detect(true) };
/* the pause disc (WCAG 2.2.2: it moves for more than 5 s): the system's own .loop-pause, same icons, in the section's
bottom-right corner; it holds the blobs with .is-held. Nothing under prefers-reduced-motion, where they hold still. */
const ICON_PAUSE = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.25" y="3.25" width="2.75" height="9.5" rx="0.9"/><rect x="9" y="3.25" width="2.75" height="9.5" rx="0.9"/></svg>';
const ICON_PLAY = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.25 3.7v8.6c0 .62.68 1 1.2.66l6.5-4.3c.47-.31.47-1 0-1.32l-6.5-4.3c-.52-.34-1.2.04-1.2.66Z"/></svg>';
const hold = el => {
if (el.hasAttribute('data-no-pause') || matchMedia('(prefers-reduced-motion: reduce)').matches || el.querySelector(':scope > .loop-pause')) return;
const b = document.createElement('button');
b.type = 'button'; b.className = 'loop-pause';
const show = () => { const held = el.classList.contains('is-held'), label = (held ? 'Play' : 'Pause') + ' animation'; b.innerHTML = held ? ICON_PLAY : ICON_PAUSE; b.setAttribute('aria-label', label); b.title = label; };
b.addEventListener('click', () => { el.classList.toggle('is-held'); show(); });
show(); el.append(b);
};
const watch = () => {
const live = document.querySelectorAll('.bg-liquid');
live.forEach(hold);
if (live.length && 'IntersectionObserver' in window) {
const io = new IntersectionObserver(es => es.forEach(e => e.target.classList.toggle('is-off', !e.isIntersecting)), { rootMargin: '80px' });
live.forEach(el => { el.classList.add('is-off'); io.observe(el); });
}
};
/* the scrim of a photograph must stay deep under every word: measure where the words start (their top edge, as a share of the
section's height, from the bottom; their right edge for .bg-photo--left on a wide screen) and keep the deep part of the
scrim 8% past it. A value the section sets itself (inline) is kept. */
const fit = el => {
const r = el.getBoundingClientRect();
if (!r.height || !r.width) return;
const words = [...el.querySelectorAll('h1, h2, h3, h4, p, a, button, li, blockquote, figcaption, .btn-row')].filter(w => !w.closest('.bg-photo__img') && w.getClientRects().length);
if (!words.length) return;
const top = Math.min(...words.map(w => w.getBoundingClientRect().top)), right = Math.max(...words.map(w => w.getBoundingClientRect().right));
if (!el.dataset.scrimHSet) {
const h = Math.min(100, Math.max(40, ((r.bottom - top) / r.height) * 100 + 8));
el.style.setProperty('--scrim-h', h.toFixed(1) + '%');
}
if (!el.dataset.scrimWSet) el.style.setProperty('--scrim-w', Math.min(100, Math.max(30, ((right - r.left) / r.width) * 100 + 8)).toFixed(1) + '%');
};
const photos = () => {
document.querySelectorAll('.bg-photo').forEach(el => {
/* a page-set inline value is kept: remember it before the first measure */
if (el.dataset.scrimHSet == null) el.dataset.scrimHSet = el.style.getPropertyValue('--scrim-h') ? '1' : '';
if (el.dataset.scrimWSet == null) el.dataset.scrimWSet = el.style.getPropertyValue('--scrim-w') ? '1' : '';
fit(el);
if ('ResizeObserver' in window && !el.__fit) { el.__fit = new ResizeObserver(() => fit(el)); el.__fit.observe(el); }
});
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { watch(); photos(); }, { once: true }); else { watch(); photos(); }
if (document.fonts && document.fonts.ready) document.fonts.ready.then(photos);
})();
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en" data-ground="sand">
<head>
<meta charset="utf-8">
<title>Backgrounds contrast check</title>
<!-- Every ground in a 800px box, for check.mjs: it sets a palette, screenshots the boxes and measures
the ink and muted contrast on every pixel of each ground's text zone (index.json). The photograph
grounds sit on a flat grey picture: #7c7c7c for the dark scrim, #d4d4d4 for the light one; and the worst case of the dark scrim: a white
picture (white words on it). The light scrim is for pale photographs only. -->
<link rel="stylesheet" href="../system.css">
<link rel="stylesheet" href="backgrounds.css">
<style>body { margin: 0; width: 800px; } .box { width: 800px; margin: 0 0 8px; padding: 24px; font-size: 20px; } .pi, .pm { visibility: hidden; } .pi { color: var(--ink); } .pm { color: var(--muted); } /* the liquid blobs held at the start, the middle and the end of their drift (the keyframes in backgrounds.css), so the text zone is measured where they stand under it */
.at-start::before { animation: none; transform: translate3d(-7%, 0, 0) skewX(-7deg) scaleY(.94); } .at-start::after { animation: none; transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.04); }
.at-mid::before { animation: none; transform: translate3d(.5%, 0, 0) skewX(-.5deg) scaleY(.995); } .at-mid::after { animation: none; transform: translate3d(-.5%, 0, 0) scaleY(.98); }
.at-end::before { animation: none; transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.05); } .at-end::after { animation: none; transform: translate3d(-9%, 0, 0) skewX(-6deg) scaleY(.92); }
</style>
<script src="backgrounds.js" defer></script>
</head>
<body>
<div class="bg-mist box" data-g="mist" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-halo box" data-g="halo" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-grain box" data-g="grain" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-paper box" data-g="paper" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-dusk box" data-g="dusk" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-topglow box" data-g="topglow" style="height:560px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-ruled box" data-g="ruled" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-dots box" data-g="dots" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-arcs box" data-g="arcs" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-band box" data-g="band" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-field box" data-g="field" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-band bg-tint box" data-g="band-tint" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-field bg-tint box" data-g="field-tint" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-photo box" data-g="photo" style="height:520px;--g-photo:linear-gradient(#7c7c7c,#7c7c7c)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-photo bg-photo--left box" data-g="photo-left" style="height:520px;--g-photo:linear-gradient(#7c7c7c,#7c7c7c)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-photo bg-photo--light box" data-g="photo-light" style="height:520px;--g-photo:linear-gradient(#d4d4d4,#d4d4d4)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-photo box" data-g="photo-white" style="height:520px;--g-photo:linear-gradient(#fff,#fff)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-photo bg-photo--left box" data-g="photo-left-white" style="height:520px;--g-photo:linear-gradient(#fff,#fff)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-horizon box" data-g="horizon" style="height:640px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-deep box" data-g="deep" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-liquid box at-start" data-g="liquid" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-liquid box at-mid" data-g="liquid-mid" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
<div class="bg-liquid box at-end" data-g="liquid-end" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
</body>
</html>
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/*
taste-skill backgrounds check.mjs - measures the contrast of the page ink (7:1) and the muted ink (4.5:1)
on every ground, on every palette, from real pixels.
node assets/backgrounds/check.mjs --profile <profile-dir> [--palettes id,id] [--grounds mist,halo]
[--lit sand,painting] [--json out.json] [--save dir] [--min-ink 7] [--min-muted 4.5] [--port 5967]
It serves the skill folder over http on 127.0.0.1 (5965-5969) and opens check.html in ONE headless Chrome (WebGL on the CPU flags, no window; closed by its own PID),
sets each palette's --p-* tokens and the lit ground, screenshots the grounds' boxes, and reads the ink and
muted colours the ground really resolves to. Each ground's text zone (index.json, fractions of its box)
is cut into 8px blocks; the worst block's ratio is the result. Exit code 1 when a ground fails.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import zlib from 'node:zlib';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { spawn, execFileSync } from 'node:child_process';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SKILL = path.resolve(HERE, '..', '..');
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', 'bg-check-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-bg-check-' + process.pid);
const profile = (p => (p && p !== true ? p : defaultProfile()))(opt('profile'));
const MIN_INK = +opt('min-ink', 7), MIN_MUTED = +opt('min-muted', 4.5), SCALE = 0.5, BLOCK = 4;
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ---------------------------------------------------------------- data */
const meta = JSON.parse(fs.readFileSync(path.join(HERE, 'index.json'), 'utf8')).grounds;
const zones = {}; // box id -> text zone
for (const g of meta) { zones[g.id] = g.textZone; if (g.variants) for (const v of g.variants) zones[v.id] = v.textZone || g.textZone; if (g.checkFrames) for (const f of g.checkFrames) zones[f] = g.textZone; }
const boxIds = Object.keys(zones);
const wantGrounds = opt('grounds') ? String(opt('grounds')).split(',') : null;
const lits = opt('lit') ? String(opt('lit')).split(',') : ['sand', 'painting'];
const tokensOf = id => {
const css = execFileSync('node', [path.join(SKILL, 'scripts', 'start.mjs'), 'css', '--page', 'software', '--palette', id, '--type', 'hanken', '--radius', 'soft', '--history', path.join(path.resolve(profile), 'start-history.json')], { encoding: 'utf8' }); // the face does not matter here, only the --p-* colours; the history goes into the profile folder, not the page's .taste-skill
const t = {};
for (const m of css.matchAll(/--p-([a-z-]+):\s*(#[0-9a-fA-F]{3,8})/g)) t[m[1]] = m[2];
return t;
};
const allIds = JSON.parse(fs.readFileSync(path.join(SKILL, 'library', 'palettes.json'), 'utf8')).map(p => p.id);
const palIds = opt('palettes') ? String(opt('palettes')).split(',') : allIds;
/* ---------------------------------------------------------------- PNG (8-bit RGB/RGBA) */
function decodePNG(buf) {
let pos = 8, w = 0, h = 0, type = 0; const idat = [];
while (pos < buf.length) {
const len = buf.readUInt32BE(pos), name = buf.toString('ascii', pos + 4, pos + 8), data = buf.subarray(pos + 8, pos + 8 + len);
if (name === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); type = data[9]; } else if (name === 'IDAT') idat.push(data);
pos += 12 + len;
}
const bpp = type === 6 ? 4 : 3, raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * bpp, px = Buffer.alloc(w * h * 3);
let prev = Buffer.alloc(stride);
for (let y = 0; y < h; y++) {
const f = raw[y * (stride + 1)], line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? line[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0; let v = line[i];
if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
line[i] = v & 255;
}
for (let x = 0; x < w; x++) { const o = (y * w + x) * 3; px[o] = line[x * bpp]; px[o + 1] = line[x * bpp + 1]; px[o + 2] = line[x * bpp + 2]; }
prev = line;
}
return { w, h, px };
}
const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const lum = (r, g, b) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
/* ---------------------------------------------------------------- Chrome */
const findChrome = () => ['C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find(p => p && fs.existsSync(p));
const prof = path.resolve(profile); fs.mkdirSync(prof, { recursive: true });
const portFile = path.join(prof, 'DevToolsActivePort'); try { fs.rmSync(portFile); } catch { /* none */ }
const child = spawn(findChrome(), ['--headless=new', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check',
'--disable-extensions', '--hide-scrollbars', '--mute-audio', '--disable-gpu', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--window-size=800,900', 'about:blank'], { stdio: 'ignore', windowsHide: true });
const kill = () => { if (child.exitCode != null) return; try { execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); } catch { try { child.kill(); } catch { /* gone */ } } };
// the page is served over http on 127.0.0.1 (5965-5969, --port), never opened from file://
const PORT = +opt('port', 5967);
if (PORT < 5965 || PORT > 5969) { console.error('check: --port must be 5965-5969'); process.exit(1); }
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
const srv = http.createServer((req, res) => {
const file = path.join(SKILL, decodeURIComponent(new URL(req.url, 'http://x').pathname));
if (!file.startsWith(SKILL) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
fs.createReadStream(file).pipe(res);
});
await new Promise((resolve, reject) => { srv.on('error', reject); srv.listen(PORT, '127.0.0.1', resolve); });
const guard = setTimeout(() => { console.error('check: timed out'); kill(); process.exit(2); }, 25 * 60 * 1000);
const results = [];
try {
let port = null;
for (let i = 0; i < 150 && !port; i++) { await sleep(100); try { port = parseInt(fs.readFileSync(portFile, 'utf8'), 10) || null; } catch { /* not yet */ } }
if (!port) throw new Error('Chrome did not start');
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
const ws = new WebSocket(ver.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.addEventListener('open', r, { once: true }); ws.addEventListener('error', j, { once: true }); });
let id = 0; const pend = new Map(), subs = [];
ws.addEventListener('message', e => { const m = JSON.parse(e.data); if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); } else subs.forEach(f => f(m)); });
const send = (method, params = {}, sessionId) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method, params, sessionId })); });
const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' });
const { result: { sessionId } } = await send('Target.attachToTarget', { targetId, flatten: true });
const S = async (m, p) => { const r = await send(m, p, sessionId); if (r.error) throw new Error(`${m}: ${r.error.message}`); return r.result; };
const ev = async x => { const r = await S('Runtime.evaluate', { expression: x, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; };
await S('Page.enable'); await S('Runtime.enable');
await S('Emulation.setDeviceMetricsOverride', { width: 800, height: 900, deviceScaleFactor: 1, mobile: false });
const loaded = new Promise(r => subs.push(m => { if (m.method === 'Page.loadEventFired') r(); }));
await S('Page.navigate', { url: `http://127.0.0.1:${PORT}/assets/backgrounds/check.html` });
await Promise.race([loaded, sleep(15000)]);
await sleep(300);
for (const pid of palIds) {
const tk = tokensOf(pid);
for (const lit of lits) {
const info = await ev(`(async () => {
const root = document.documentElement, tk = ${JSON.stringify(tk)};
for (const [k, v] of Object.entries(tk)) root.style.setProperty('--p-' + k, v);
root.dataset.ground = ${JSON.stringify(lit)};
window.tasteBackgrounds.detect();
const only = ${JSON.stringify(wantGrounds)};
for (const el of document.querySelectorAll('.box')) el.style.display = !only || only.includes(el.dataset.g) || only.includes(el.dataset.g.split('-')[0]) ? '' : 'none';
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const cv = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
const rgb = c => { cv.clearRect(0, 0, 1, 1); cv.fillStyle = '#000'; cv.fillStyle = c; cv.fillRect(0, 0, 1, 1); return [...cv.getImageData(0, 0, 1, 1).data.slice(0, 3)]; };
const out = { mode: root.dataset.mode, tint: root.dataset.tint || '', boxes: {} };
for (const el of document.querySelectorAll('.box')) {
const r = el.getBoundingClientRect();
out.boxes[el.dataset.g] = { top: r.top + scrollY, left: r.left + scrollX, h: r.height, w: r.width, rad: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, ink: rgb(getComputedStyle(el.querySelector('.pi')).color), muted: rgb(getComputedStyle(el.querySelector('.pm')).color) };
}
out.k = getComputedStyle(root).getPropertyValue('--g-k').trim(); out.kd = getComputedStyle(root).getPropertyValue('--g-kd').trim();
out.height = document.documentElement.scrollHeight;
return out;
})()`);
await sleep(250);
const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: 800, height: info.height, scale: SCALE } });
if (opt('save') && opt('save') !== true) { fs.mkdirSync(opt('save'), { recursive: true }); fs.writeFileSync(path.join(String(opt('save')), `${pid}-${lit}.png`), Buffer.from(data, 'base64')); }
const img = decodePNG(Buffer.from(data, 'base64'));
for (const bid of boxIds) {
if (wantGrounds && !wantGrounds.includes(bid) && !wantGrounds.includes(bid.split('-')[0])) continue;
const b = info.boxes[bid]; if (!b || !b.h) continue;
const [zx0, zy0, zx1, zy1] = zones[bid];
const ins = b.rad ? Math.ceil(b.rad * 0.3) + 2 : 0; // a rounded box (bg-inset): the four corners outside the curve are the page, not the ground
const x0 = Math.floor((b.left + b.w * zx0 + (zx0 === 0 ? ins : 0)) * SCALE), x1 = Math.floor((b.left + b.w * zx1 - (zx1 === 1 ? ins : 0)) * SCALE), y0 = Math.floor((b.top + b.h * zy0 + (zy0 === 0 ? ins : 0)) * SCALE), y1 = Math.floor((b.top + b.h * zy1 - (zy1 === 1 ? ins : 0)) * SCALE);
const Li = lum(...b.ink), Lm = lum(...b.muted);
let worstInk = 99, worstMuted = 99;
for (let by = y0; by + BLOCK <= y1; by += BLOCK) for (let bx = x0; bx + BLOCK <= x1; bx += BLOCK) {
let r = 0, g = 0, bl = 0;
for (let y = by; y < by + BLOCK; y++) for (let x = bx; x < bx + BLOCK; x++) { const o = (y * img.w + x) * 3; r += img.px[o]; g += img.px[o + 1]; bl += img.px[o + 2]; }
const n = BLOCK * BLOCK, Lb = lum(r / n, g / n, bl / n);
worstInk = Math.min(worstInk, ratio(Li, Lb)); worstMuted = Math.min(worstMuted, ratio(Lm, Lb));
}
results.push({ inkRgb: b.ink.join(','), box: [b.left, b.top, b.w, b.h].map(Math.round).join(','), palette: pid, lit, ground: bid, mode: info.mode, tint: info.tint, k: info.k, kd: info.kd, ink: +worstInk.toFixed(2), muted: +worstMuted.toFixed(2), pass: worstInk >= MIN_INK && worstMuted >= MIN_MUTED });
}
}
process.stderr.write(`${pid} `);
}
try { await Promise.race([send('Browser.close'), sleep(3000)]); } catch { /* closing */ }
} catch (e) {
console.error('check:', e.message); process.exitCode = 1;
} finally {
clearTimeout(guard); await sleep(300); kill(); srv.close();
}
/* ---------------------------------------------------------------- report */
const fails = results.filter(r => !r.pass);
const by = {};
for (const r of results) { const k = r.ground; (by[k] ||= { n: 0, fail: 0, minInk: 99, minMuted: 99, bad: new Set() }); by[k].n++; by[k].minInk = Math.min(by[k].minInk, r.ink); by[k].minMuted = Math.min(by[k].minMuted, r.muted); if (!r.pass) { by[k].fail++; by[k].bad.add(r.palette); } }
console.log('\n');
console.log('ground'.padEnd(12), 'runs'.padStart(5), 'fail'.padStart(5), 'min ink'.padStart(8), 'min muted'.padStart(10), ' failing palettes');
for (const [k, v] of Object.entries(by)) console.log(k.padEnd(12), String(v.n).padStart(5), String(v.fail).padStart(5), v.minInk.toFixed(2).padStart(8), v.minMuted.toFixed(2).padStart(10), ' ', [...v.bad].slice(0, 12).join(', ') + (v.bad.size > 12 ? ` +${v.bad.size - 12}` : ''));
const jf = opt('json'); if (jf && jf !== true) fs.writeFileSync(jf, JSON.stringify(results, null, 1));
console.log(`\n${results.length - fails.length}/${results.length} passed (ink >= ${MIN_INK}, muted >= ${MIN_MUTED})`);
if (fails.length) process.exitCode = 1;
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="en" data-radius="soft" data-ground="sand">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backgrounds demo</title>
<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=Hanken+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../system.css">
<link rel="stylesheet" href="../ui/cards.css">
<link rel="stylesheet" href="backgrounds.css">
<style>
:root { --font-display: "Hanken Grotesk", system-ui, sans-serif; --font-text: "Hanken Grotesk", system-ui, sans-serif; --display-weight: 500; --display-tracking: -0.035em; }
/* the demo's own frame: a tool bar and a small id tag on each ground (both off with ?bar=0&labels=0) */
.demo-bar { position: fixed; z-index: 90; right: 12px; bottom: 12px; display: flex; gap: 8px; align-items: center; padding: 8px 10px; border-radius: 12px;
background: #111; color: #fff; font: 500 12px/1 ui-monospace, Menlo, Consolas, monospace; box-shadow: 0 8px 30px rgb(0 0 0 / .3); }
.demo-bar select { font: inherit; background: #222; color: #fff; border: 1px solid #444; border-radius: 6px; padding: 5px 6px; max-width: 150px; }
body.no-bar .demo-bar, body.no-labels .demo-tag { display: none; }
.demo-tag { position: absolute; z-index: 3; top: 14px; left: 14px; padding: 4px 8px; border-radius: 6px; font: 500 11px/1 ui-monospace, Menlo, Consolas, monospace;
background: rgb(0 0 0 / .72); color: #fff; letter-spacing: .02em; }
[hidden] { display: none !important; }
/* small UI pieces, drawn with the ground's tokens so they show how a card reads on each ground */
.mini { background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: var(--r-md); padding: 16px; font-size: 14px; line-height: 1.4;
box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 12px 28px -16px rgb(0 0 0 / .25); }
.mini h4 { font: 500 14px/1.3 var(--font-text); letter-spacing: 0; margin: 0 0 12px; display: flex; justify-content: space-between; align-items: center; }
.mini h4 span { font-weight: 400; color: var(--muted); font-size: 13px; }
.rowline { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
.rowline:first-of-type { border-top: 0; }
.av { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 500; background: color-mix(in oklab, var(--accent) 16%, var(--card)); color: var(--ink); }
.rowline b { font-weight: 500; display: block; }
.rowline small { color: var(--muted); font-size: 12.5px; }
.tag { font-size: 12px; padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--ink); white-space: nowrap; }
.tag.on { background: var(--action); color: var(--action-ink); border-color: transparent; }
.sw { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-top: 1px solid var(--line); font-size: 14px; }
.sw:first-of-type { border-top: 0; }
.sw i { width: 34px; height: 20px; border-radius: 99px; background: var(--line-strong); position: relative; flex: none; }
.sw i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--card); box-shadow: 0 1px 2px rgb(0 0 0 / .3); }
.sw i.on { background: var(--action); } .sw i.on::after { left: 16px; background: var(--action-ink); }
.week { display: grid; grid-template-columns: 64px repeat(5, 1fr); gap: 6px; font-size: 12.5px; }
.week > div { padding: 8px 8px; border-radius: 8px; background: color-mix(in oklab, var(--ink) 5%, var(--card)); min-height: 34px; }
.week > .h { background: none; color: var(--muted); padding: 4px 8px; }
.week > .c { background: color-mix(in oklab, var(--accent) 18%, var(--card)); }
.week > .n { background: none; color: var(--muted); padding-left: 0; }
.bubble { padding: 10px 12px; border-radius: 12px; background: color-mix(in oklab, var(--ink) 6%, var(--card)); max-width: 86%; }
.bubble.me { margin-left: auto; background: var(--action); color: var(--action-ink); }
.stack > * + * { margin-top: 8px; }
.big-stat { font: var(--display-weight) clamp(72px, 9vw, 132px)/.95 var(--font-display); letter-spacing: -0.05em; font-variant-numeric: tabular-nums; }
.steps li { display: grid; gap: 10px; align-content: start; padding-top: 18px; border-top: 1px solid var(--line-strong); }
.nav .btn { height: 36px; }
.vh { min-height: 0; }
.center { text-align: center; justify-items: center; }
.stats4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }
.stats4 b { display: block; font: var(--display-weight) 40px/1 var(--font-display); letter-spacing: -0.04em; margin-bottom: 6px; }
@media (max-width: 767px) { .stats4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .week { grid-template-columns: 44px repeat(3, 1fr); } .week > :nth-child(6n+5), .week > :nth-child(6n+6) { display: none; } }
</style>
<script>
const PALETTES = {
"white-link-blue": {"bg":"#ffffff","surface":"#f4f4f4","ink":"#111111","muted":"#6b6b6b","accent":"#1a4be0","accent-ink":"#ffffff","action":"#1a4be0","action-ink":"#ffffff","inv-bg":"#111111","inv-surface":"#1e1e1e","inv-ink":"#ffffff","inv-muted":"#9a9a9a","inv-accent":"#ffffff","inv-accent-ink":"#111111","inv-action":"#ffffff","inv-action-ink":"#111111"},
"cool-white-slate": {"bg":"#f7f9fa","surface":"#ecf0f2","ink":"#1f3038","muted":"#5d6b72","accent":"#0d7480","accent-ink":"#ffffff","action":"#0d7480","action-ink":"#ffffff","inv-bg":"#1f3038","inv-surface":"#2c3c44","inv-ink":"#f7f9fa","inv-muted":"#9ea7ab","inv-accent":"#f7f9fa","inv-accent-ink":"#1f3038","inv-action":"#f7f9fa","inv-action-ink":"#1f3038"},
"rose-grey-green": {"bg":"#ebe5e3","surface":"#e1dad7","ink":"#2b2622","muted":"#655c58","accent":"#0f7d45","accent-ink":"#ffffff","action":"#0f7d45","action-ink":"#ffffff","inv-bg":"#2b2622","inv-surface":"#36312d","inv-ink":"#ebe5e3","inv-muted":"#a29c99","inv-accent":"#ebe5e3","inv-accent-ink":"#2b2622","inv-action":"#ebe5e3","inv-action-ink":"#2b2622"},
"paper-grey-brass": {"bg":"#e8e8e3","surface":"#dcdcd6","ink":"#181715","muted":"#5c5b56","accent":"#bfb21a","accent-ink":"#181715","action":"#181715","action-ink":"#e8e8e3","inv-bg":"#181715","inv-surface":"#242321","inv-ink":"#e8e8e3","inv-muted":"#91908d","inv-accent":"#bfb21a","inv-accent-ink":"#181715","inv-action":"#bfb21a","inv-action-ink":"#181715"},
"stone-blueprint": {"bg":"#dfdedb","surface":"#d3d2ce","ink":"#26251e","muted":"#5a5850","accent":"#2456d6","accent-ink":"#ffffff","action":"#2456d6","action-ink":"#ffffff","inv-bg":"#26251e","inv-surface":"#313029","inv-ink":"#dfdedb","inv-muted":"#9e9d99","inv-accent":"#dfdedb","inv-accent-ink":"#26251e","inv-action":"#dfdedb","inv-action-ink":"#26251e"},
"white-navy-tab": {"bg":"#ffffff","surface":"#f4f4f2","ink":"#1f2b4d","muted":"#646b81","accent":"#f7941d","accent-ink":"#1f2b4d","action":"#f7941d","action-ink":"#1f2b4d","inv-bg":"#05070a","inv-surface":"#111417","inv-ink":"#ffffff","inv-muted":"#939597","inv-accent":"#f7941d","inv-accent-ink":"#1f2b4d","inv-action":"#f7941d","inv-action-ink":"#1f2b4d"},
"three-stones": {"bg":"#eeecea","surface":"#e4e1dd","ink":"#333231","muted":"#625f5b","accent":"#5c6b54","accent-ink":"#ffffff","action":"#5c6b54","action-ink":"#ffffff","inv-bg":"#333231","inv-surface":"#3e3d3c","inv-ink":"#eeecea","inv-muted":"#adaba9","inv-accent":"#eeecea","inv-accent-ink":"#333231","inv-action":"#eeecea","inv-action-ink":"#333231"},
"putty-black-teal": {"bg":"#dedad3","surface":"#d3cec6","ink":"#0a0a0a","muted":"#55514c","accent":"#256f82","accent-ink":"#ffffff","action":"#256f82","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#151515","inv-ink":"#dedad3","inv-muted":"#84827e","inv-accent":"#256f82","inv-accent-ink":"#ffffff","inv-action":"#256f82","inv-action-ink":"#ffffff"},
"moss-sand": {"bg":"#ddd6c6","surface":"#d2cab8","ink":"#2a2a26","muted":"#55534b","accent":"#4f6238","accent-ink":"#ffffff","action":"#4f6238","action-ink":"#ffffff","inv-bg":"#2a2a26","inv-surface":"#353430","inv-ink":"#ddd6c6","inv-muted":"#a39e92","inv-accent":"#ddd6c6","inv-accent-ink":"#2a2a26","inv-action":"#ddd6c6","inv-action-ink":"#2a2a26"},
"green-mat-chocolate": {"bg":"#e5e1d8","surface":"#dad5ca","ink":"#2a1d17","muted":"#65564c","accent":"#456f3a","accent-ink":"#ffffff","action":"#456f3a","action-ink":"#ffffff","inv-bg":"#2a1d17","inv-surface":"#352822","inv-ink":"#e5e1d8","inv-muted":"#9e968e","inv-accent":"#e5e1d8","inv-accent-ink":"#2a1d17","inv-action":"#e5e1d8","inv-action-ink":"#2a1d17"},
"lunar-grey-bronze": {"bg":"#111111","surface":"#1c1c1c","ink":"#f2f2f2","muted":"#8f8f8f","accent":"#a08672","accent-ink":"#111111","action":"#a08672","action-ink":"#111111","inv-bg":"#f2f2f2","inv-surface":"#e0e0e0","inv-ink":"#111111","inv-muted":"#5c5c5c","inv-accent":"#a08672","inv-accent-ink":"#111111","inv-action":"#a08672","inv-action-ink":"#111111"},
"blue-black-cornflower": {"bg":"#0a0d14","surface":"#131824","ink":"#e9edf3","muted":"#8891a1","accent":"#5ab9fc","accent-ink":"#0a0d14","action":"#5ab9fc","action-ink":"#0a0d14","inv-bg":"#e9edf3","inv-surface":"#d7dbe1","inv-ink":"#0a0d14","inv-muted":"#54575f","inv-accent":"#0a0d14","inv-accent-ink":"#e9edf3","inv-action":"#0a0d14","inv-action-ink":"#e9edf3"},
"green-black-bone": {"bg":"#161c1a","surface":"#1f2725","ink":"#e3e2da","muted":"#8e948d","accent":"#9fc7b8","accent-ink":"#161c1a","action":"#9fc7b8","action-ink":"#161c1a","inv-bg":"#e3e2da","inv-surface":"#d3d2cb","inv-ink":"#161c1a","inv-muted":"#555a55","inv-accent":"#161c1a","inv-accent-ink":"#e3e2da","inv-action":"#161c1a","inv-action-ink":"#e3e2da"},
"black-crimson": {"bg":"#0a0a0a","surface":"#151515","ink":"#efefef","muted":"#8c8c8c","accent":"#b11226","accent-ink":"#ffffff","action":"#b11226","action-ink":"#ffffff","inv-bg":"#efefef","inv-surface":"#dcdcdc","inv-ink":"#0a0a0a","inv-muted":"#565656","inv-accent":"#b11226","inv-accent-ink":"#ffffff","inv-action":"#b11226","inv-action-ink":"#ffffff"},
"forest-pale-lime": {"bg":"#0f1f10","surface":"#162a17","ink":"#ffffff","muted":"#9aa39a","accent":"#c6f19d","accent-ink":"#0f1f10","action":"#c6f19d","action-ink":"#0f1f10","inv-bg":"#ffffff","inv-surface":"#ecedec","inv-ink":"#0f1f10","inv-muted":"#606c60","inv-accent":"#0f1f10","inv-accent-ink":"#ffffff","inv-action":"#0f1f10","inv-action-ink":"#ffffff"},
"charcoal-room-sage": {"bg":"#1c1c1c","surface":"#262626","ink":"#f1f1ef","muted":"#9a9b96","accent":"#8fa68e","accent-ink":"#1c1c1c","action":"#8fa68e","action-ink":"#1c1c1c","inv-bg":"#c8c5ba","inv-surface":"#bab8ae","inv-ink":"#1c1c1c","inv-muted":"#454442","inv-accent":"#1c1c1c","inv-accent-ink":"#c8c5ba","inv-action":"#1c1c1c","inv-action-ink":"#c8c5ba"},
"warm-black-gold": {"bg":"#0c0a08","surface":"#181512","ink":"#ffffff","muted":"#8f8a84","accent":"#c9a45a","accent-ink":"#0c0a08","action":"#c9a45a","action-ink":"#0c0a08","inv-bg":"#ffffff","inv-surface":"#ebebeb","inv-ink":"#0c0a08","inv-muted":"#5c5b59","inv-accent":"#0c0a08","inv-accent-ink":"#ffffff","inv-action":"#0c0a08","inv-action-ink":"#ffffff"},
"graphite-safety-orange": {"bg":"#18191b","surface":"#222427","ink":"#eff0f1","muted":"#9c9ea3","accent":"#ff4a1f","accent-ink":"#18191b","action":"#ff4a1f","action-ink":"#18191b","inv-bg":"#eff0f1","inv-surface":"#dedfe0","inv-ink":"#18191b","inv-muted":"#5b5c5e","inv-accent":"#18191b","inv-accent-ink":"#eff0f1","inv-action":"#18191b","inv-action-ink":"#eff0f1"},
"soot-pale-lime": {"bg":"#0f0f0f","surface":"#1a1a1a","ink":"#ffffff","muted":"#8f8f8f","accent":"#dafa9c","accent-ink":"#0f0f0f","action":"#dafa9c","action-ink":"#0f0f0f","inv-bg":"#ffffff","inv-surface":"#ececec","inv-ink":"#0f0f0f","inv-muted":"#5f5f5f","inv-accent":"#0f0f0f","inv-accent-ink":"#ffffff","inv-action":"#0f0f0f","inv-action-ink":"#ffffff"},
"plum-black-pink": {"bg":"#0d060b","surface":"#1b0e17","ink":"#ffffff","muted":"#9a8f97","accent":"#ff488b","accent-ink":"#0d060b","action":"#ff488b","action-ink":"#0d060b","inv-bg":"#ffffff","inv-surface":"#ebebeb","inv-ink":"#0d060b","inv-muted":"#5e575c","inv-accent":"#ff488b","inv-accent-ink":"#0d060b","inv-action":"#ff488b","inv-action-ink":"#0d060b"},
"white-signal-red": {"bg":"#ffffff","surface":"#f2f2f2","ink":"#0a0a0a","muted":"#6b6b6b","accent":"#d92b21","accent-ink":"#ffffff","action":"#d92b21","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#d92b21","inv-accent-ink":"#ffffff","inv-action":"#d92b21","inv-action-ink":"#ffffff"},
"white-mint-tiles": {"bg":"#ffffff","surface":"#f3f3f3","ink":"#232323","muted":"#686868","accent":"#a1ffcb","accent-ink":"#232323","action":"#232323","action-ink":"#ffffff","inv-bg":"#232323","inv-surface":"#303030","inv-ink":"#ffffff","inv-muted":"#a4a4a4","inv-accent":"#a1ffcb","inv-accent-ink":"#232323","inv-action":"#a1ffcb","inv-action-ink":"#232323"},
"cool-grey-cobalt": {"bg":"#eef0f3","surface":"#e2e5ea","ink":"#0b0c0e","muted":"#5d6470","accent":"#1f2de6","accent-ink":"#ffffff","action":"#1f2de6","action-ink":"#ffffff","inv-bg":"#0b0c0e","inv-surface":"#17181a","inv-ink":"#eef0f3","inv-muted":"#8e8f92","inv-accent":"#eef0f3","inv-accent-ink":"#0b0c0e","inv-action":"#eef0f3","inv-action-ink":"#0b0c0e"},
"offwhite-bottle-yellow": {"bg":"#f7f7f7","surface":"#ebebeb","ink":"#0d2a1c","muted":"#5f6b64","accent":"#f6e016","accent-ink":"#0d2a1c","action":"#0d2a1c","action-ink":"#f7f7f7","inv-bg":"#0d2a1c","inv-surface":"#1c3629","inv-ink":"#f7f7f7","inv-muted":"#97a39c","inv-accent":"#f6e016","inv-accent-ink":"#0d2a1c","inv-action":"#f6e016","inv-action-ink":"#0d2a1c"},
"teal-on-paper": {"bg":"#f7f9f8","surface":"#ebf0ee","ink":"#06525c","muted":"#2f737d","accent":"#06525c","accent-ink":"#f7f9f8","action":"#06525c","action-ink":"#f7f9f8","inv-bg":"#06525c","inv-surface":"#205d66","inv-ink":"#f7f9f8","inv-muted":"#c1d0d2","inv-accent":"#f7f9f8","inv-accent-ink":"#06525c","inv-action":"#f7f9f8","inv-action-ink":"#06525c"},
"navy-pink": {"bg":"#1a2039","surface":"#212846","ink":"#f9a3c0","muted":"#b9879c","accent":"#f9a3c0","accent-ink":"#1a2039","action":"#f9a3c0","action-ink":"#1a2039","inv-bg":"#f9a3c0","inv-surface":"#e899b6","inv-ink":"#1a2039","inv-muted":"#483c56","inv-accent":"#1a2039","inv-accent-ink":"#f9a3c0","inv-action":"#1a2039","inv-action-ink":"#f9a3c0"},
"blush-aubergine": {"bg":"#edccbe","surface":"#e8c2b2","ink":"#351a2b","muted":"#6c4857","accent":"#351a2b","accent-ink":"#edccbe","action":"#351a2b","action-ink":"#edccbe","inv-bg":"#351a2b","inv-surface":"#402534","inv-ink":"#edccbe","inv-muted":"#b2938f","inv-accent":"#edccbe","inv-accent-ink":"#351a2b","inv-action":"#edccbe","inv-action-ink":"#351a2b"},
"cobalt-on-sand": {"bg":"#e6dbc7","surface":"#ddd0b9","ink":"#062b8a","muted":"#3253a4","accent":"#062b8a","accent-ink":"#e6dbc7","action":"#062b8a","action-ink":"#e6dbc7","inv-bg":"#062b8a","inv-surface":"#153a90","inv-ink":"#e6dbc7","inv-muted":"#aab0bc","inv-accent":"#e6dbc7","inv-accent-ink":"#062b8a","inv-action":"#e6dbc7","inv-action-ink":"#062b8a"},
"oxblood-grey": {"bg":"#e2e2e2","surface":"#d6d6d6","ink":"#7d0c14","muted":"#7a4a4e","accent":"#7d0c14","accent-ink":"#e2e2e2","action":"#7d0c14","action-ink":"#e2e2e2","inv-bg":"#7d0c14","inv-surface":"#862323","inv-ink":"#e2e2e2","inv-muted":"#d0b3af","inv-accent":"#e2e2e2","inv-accent-ink":"#7d0c14","inv-action":"#e2e2e2","inv-action-ink":"#7d0c14"},
"olive-on-bone": {"bg":"#ecebe7","surface":"#e1dfd9","ink":"#38450f","muted":"#5a6537","accent":"#38450f","accent-ink":"#ecebe7","action":"#38450f","action-ink":"#ecebe7","inv-bg":"#38450f","inv-surface":"#435020","inv-ink":"#ecebe7","inv-muted":"#bbbfaf","inv-accent":"#ecebe7","inv-accent-ink":"#38450f","inv-action":"#ecebe7","inv-action-ink":"#38450f"},
"burgundy-bubblegum": {"bg":"#3d0012","surface":"#4d0519","ink":"#ff99d3","muted":"#c77aa0","accent":"#ff99d3","accent-ink":"#3d0012","action":"#ff99d3","action-ink":"#3d0012","inv-bg":"#ff99d3","inv-surface":"#f08dc4","inv-ink":"#3d0012","inv-muted":"#6a263c","inv-accent":"#3d0012","inv-accent-ink":"#ff99d3","inv-action":"#3d0012","inv-action-ink":"#ff99d3"},
"bottle-green-gold": {"bg":"#133621","surface":"#1a4429","ink":"#f4d96c","muted":"#b8a95f","accent":"#f4d96c","accent-ink":"#133621","action":"#f4d96c","action-ink":"#133621","inv-bg":"#f4d96c","inv-surface":"#e3cd67","inv-ink":"#133621","inv-muted":"#495c33","inv-accent":"#133621","inv-accent-ink":"#f4d96c","inv-action":"#133621","inv-action-ink":"#f4d96c"},
"cobalt-field": {"bg":"#0034eb","surface":"#002fd6","ink":"#ffffff","muted":"#dfe4f2","accent":"#e9e34a","accent-ink":"#111111","action":"#e9e34a","action-ink":"#111111","inv-bg":"#ffffff","inv-surface":"#edf3ff","inv-ink":"#0034eb","inv-muted":"#265ff1","inv-accent":"#0034eb","inv-accent-ink":"#ffffff","inv-action":"#0034eb","inv-action-ink":"#ffffff"},
"mustard-field": {"bg":"#e1ac10","surface":"#cd9d0f","ink":"#0b0b0b","muted":"#42350d","accent":"#24338f","accent-ink":"#ffffff","action":"#24338f","action-ink":"#ffffff","inv-bg":"#0b0b0b","inv-surface":"#17140f","inv-ink":"#e1ac10","inv-muted":"#9f7b1f","inv-accent":"#e1ac10","inv-accent-ink":"#0b0b0b","inv-action":"#e1ac10","inv-action-ink":"#0b0b0b"},
"carmine-field": {"bg":"#9c1220","surface":"#8a101c","ink":"#ffffff","muted":"#f0b9bd","accent":"#ffd9dc","accent-ink":"#9c1220","action":"#ffd9dc","action-ink":"#9c1220","inv-bg":"#ffffff","inv-surface":"#faefee","inv-ink":"#9c1220","inv-muted":"#b34846","inv-accent":"#9c1220","inv-accent-ink":"#ffffff","inv-action":"#9c1220","inv-action-ink":"#ffffff"},
"pink-field-lime": {"bg":"#ff87ab","surface":"#ff739d","ink":"#0a0a0a","muted":"#631e36","accent":"#def915","accent-ink":"#0a0a0a","action":"#0a0a0a","action-ink":"#ff87ab","inv-bg":"#0a0a0a","inv-surface":"#181213","inv-ink":"#ff87ab","inv-muted":"#b9657d","inv-accent":"#def915","inv-accent-ink":"#0a0a0a","inv-action":"#def915","inv-action-ink":"#0a0a0a"},
"sky-field-navy": {"bg":"#6bb4ee","surface":"#7dbdf0","ink":"#0e1822","muted":"#1b3d5c","accent":"#ff5a1f","accent-ink":"#0e1822","action":"#0e1822","action-ink":"#6bb4ee","inv-bg":"#0e1822","inv-surface":"#14212e","inv-ink":"#6bb4ee","inv-muted":"#558fbe","inv-accent":"#ff5a1f","inv-accent-ink":"#0e1822","inv-action":"#ff5a1f","inv-action-ink":"#0e1822"},
"orange-field-black": {"bg":"#ff7a1f","surface":"#ff8633","ink":"#050f16","muted":"#5a2306","accent":"#050f16","accent-ink":"#ff7a1f","action":"#050f16","action-ink":"#ff7a1f","inv-bg":"#050f16","inv-surface":"#14171a","inv-ink":"#ff7a1f","inv-muted":"#cc672a","inv-accent":"#ff7a1f","inv-accent-ink":"#050f16","inv-action":"#ff7a1f","inv-action-ink":"#050f16"},
"tobacco-olive": {"bg":"#472913","surface":"#55321a","ink":"#e4e0cf","muted":"#b7a48a","accent":"#525e1b","accent-ink":"#e4e0cf","action":"#e4e0cf","action-ink":"#472913","inv-bg":"#c79a3e","inv-surface":"#b88f3c","inv-ink":"#111111","inv-muted":"#332a1e","inv-accent":"#111111","inv-accent-ink":"#c79a3e","inv-action":"#111111","inv-action-ink":"#c79a3e"},
"poster-chapters": {"bg":"#e6e0d2","surface":"#dcd5c5","ink":"#1a1a1a","muted":"#5f5b52","accent":"#18bc42","accent-ink":"#1a1a1a","action":"#1a1a1a","action-ink":"#e6e0d2","inv-bg":"#1a1a1a","inv-surface":"#262525","inv-ink":"#e6e0d2","inv-muted":"#918d85","inv-accent":"#18bc42","inv-accent-ink":"#1a1a1a","inv-action":"#18bc42","inv-action-ink":"#1a1a1a"},
"blue-red-black": {"bg":"#ffffff","surface":"#f3f3f3","ink":"#0a0a0a","muted":"#6b6b6b","accent":"#1f30e0","accent-ink":"#ffffff","action":"#1f30e0","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#171717","inv-ink":"#ffffff","inv-muted":"#979797","inv-accent":"#ffffff","inv-accent-ink":"#0a0a0a","inv-action":"#ffffff","inv-action-ink":"#0a0a0a"},
"charcoal-primaries": {"bg":"#191919","surface":"#242424","ink":"#ffffff","muted":"#9a9a9a","accent":"#0c7866","accent-ink":"#ffffff","action":"#0c7866","action-ink":"#ffffff","inv-bg":"#ffe927","inv-surface":"#edd92c","inv-ink":"#191919","inv-muted":"#5f5a2d","inv-accent":"#0c7866","inv-accent-ink":"#ffffff","inv-action":"#0c7866","inv-action-ink":"#ffffff"},
"ultramarine-yellow": {"bg":"#1423b8","surface":"#1220a6","ink":"#ffffff","muted":"#e4e2d6","accent":"#1fc27a","accent-ink":"#0a0a0a","action":"#1fc27a","action-ink":"#0a0a0a","inv-bg":"#f7dd47","inv-surface":"#e5d35f","inv-ink":"#1423b8","inv-muted":"#2c52b4","inv-accent":"#1423b8","inv-accent-ink":"#f7dd47","inv-action":"#1423b8","inv-action-ink":"#f7dd47"},
"candy-chapters": {"bg":"#ffd249","surface":"#f5c63c","ink":"#0a0a0a","muted":"#5e4a0a","accent":"#f53fd6","accent-ink":"#0a0a0a","action":"#f53fd6","action-ink":"#0a0a0a","inv-bg":"#0a0a0a","inv-surface":"#171510","inv-ink":"#ffd249","inv-muted":"#977e34","inv-accent":"#f53fd6","inv-accent-ink":"#0a0a0a","inv-action":"#f53fd6","inv-action-ink":"#0a0a0a"},
"orange-sky-lime": {"bg":"#ff8126","surface":"#ff8d3a","ink":"#111111","muted":"#5c2a05","accent":"#de194b","accent-ink":"#ffffff","action":"#111111","action-ink":"#ff8126","inv-bg":"#161615","inv-surface":"#241d18","inv-ink":"#ff8126","inv-muted":"#d06d29","inv-accent":"#de194b","inv-accent-ink":"#ffffff","inv-action":"#de194b","inv-action-ink":"#ffffff"},
"post-red-mustard": {"bg":"#e2dccf","surface":"#d7d0c1","ink":"#2a1110","muted":"#6b524c","accent":"#4a6f84","accent-ink":"#ffffff","action":"#4a6f84","action-ink":"#ffffff","inv-bg":"#2a1110","inv-surface":"#361d1b","inv-ink":"#e2dccf","inv-muted":"#9e8e85","inv-accent":"#4a6f84","inv-accent-ink":"#ffffff","inv-action":"#4a6f84","inv-action-ink":"#ffffff"},
"mint-orange-grey": {"bg":"#ffffff","surface":"#f5f5f5","ink":"#000000","muted":"#666666","accent":"#a7ff9c","accent-ink":"#000000","action":"#000000","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#a7ff9c","inv-accent-ink":"#000000","inv-action":"#a7ff9c","inv-action-ink":"#000000"},
"report-blocks": {"bg":"#ffffff","surface":"#f6f6f6","ink":"#000000","muted":"#5f5f5f","accent":"#fe7141","accent-ink":"#000000","action":"#fe7141","action-ink":"#000000","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#fe7141","inv-accent-ink":"#000000","inv-action":"#fe7141","inv-action-ink":"#000000"},
"card-pastels": {"bg":"#f7f7f5","surface":"#ebebe8","ink":"#000000","muted":"#666662","accent":"#d0106f","accent-ink":"#ffffff","action":"#d0106f","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#f7f7f5","inv-muted":"#828280","inv-accent":"#d0106f","inv-accent-ink":"#ffffff","inv-action":"#d0106f","inv-action-ink":"#ffffff"},
"mint-pink-accounts": {"bg":"#f8f8f8","surface":"#eeeeee","ink":"#0a0a0a","muted":"#6a6a6a","accent":"#1f6b3a","accent-ink":"#ffffff","action":"#1f6b3a","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#171717","inv-ink":"#f8f8f8","inv-muted":"#939393","inv-accent":"#1f6b3a","inv-accent-ink":"#ffffff","inv-action":"#1f6b3a","inv-action-ink":"#ffffff"},
"swap-pastels": {"bg":"#c3d7e5","surface":"#d0e0eb","ink":"#141414","muted":"#4a5068","accent":"#0f5257","accent-ink":"#ffffff","action":"#0f5257","action-ink":"#ffffff","inv-bg":"#141414","inv-surface":"#1e1f20","inv-ink":"#c3d7e5","inv-muted":"#808b94","inv-accent":"#c3d7e5","inv-accent-ink":"#141414","inv-action":"#c3d7e5","inv-action-ink":"#141414"},
"blush-sage-slate": {"bg":"#a6b3bc","surface":"#b2bdc5","ink":"#1c1c1c","muted":"#38424a","accent":"#2c3620","accent-ink":"#e3e2da","action":"#2c3620","action-ink":"#e3e2da","inv-bg":"#2c3620","inv-surface":"#39422d","inv-ink":"#ffffff","inv-muted":"#a8ada3","inv-accent":"#ffffff","inv-accent-ink":"#2c3620","inv-action":"#ffffff","inv-action-ink":"#2c3620"},
"candy-on-black": {"bg":"#000000","surface":"#0d0d0d","ink":"#ffffff","muted":"#999999","accent":"#93f1f7","accent-ink":"#000000","action":"#93f1f7","action-ink":"#000000","inv-bg":"#9ae187","inv-surface":"#8bcc7a","inv-ink":"#000000","inv-muted":"#24391e","inv-accent":"#000000","inv-accent-ink":"#9ae187","inv-action":"#000000","inv-action-ink":"#9ae187"},
"sky-sand": {"bg":"#cbe4f2","surface":"#bcdcee","ink":"#0a0a0a","muted":"#44606e","accent":"#b0298f","accent-ink":"#ffffff","action":"#b0298f","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#151616","inv-ink":"#cbe4f2","inv-muted":"#79878f","inv-accent":"#b0298f","inv-accent-ink":"#ffffff","inv-action":"#b0298f","inv-action-ink":"#ffffff"},
"studio-grey": {"bg":"#a8a8aa","surface":"#b4b4b6","ink":"#0e0e0e","muted":"#3a3a3c","accent":"#1f5c49","accent-ink":"#ffffff","action":"#1f5c49","action-ink":"#ffffff","inv-bg":"#1b1b1c","inv-surface":"#232324","inv-ink":"#a8a8aa","inv-muted":"#8c8c8e","inv-accent":"#a8a8aa","inv-accent-ink":"#1b1b1c","inv-action":"#a8a8aa","inv-action-ink":"#1b1b1c"},
"walnut-tan": {"bg":"#2b2216","surface":"#382d1e","ink":"#e4e2da","muted":"#a39a8b","accent":"#bda38b","accent-ink":"#2b2216","action":"#bda38b","action-ink":"#2b2216","inv-bg":"#bda38b","inv-surface":"#af9781","inv-ink":"#111111","inv-muted":"#36312c","inv-accent":"#111111","inv-accent-ink":"#bda38b","inv-action":"#111111","inv-action-ink":"#bda38b"}
};
(() => {
const q = new URLSearchParams(location.search), root = document.documentElement;
const id = q.get('palette') || 'white-link-blue';
for (const [k, v] of Object.entries(PALETTES[id] || PALETTES['white-link-blue'])) root.style.setProperty('--p-' + k, v);
root.dataset.ground = q.get('ground') || 'sand';
if (q.get('radius')) root.dataset.radius = q.get('radius');
if (q.get('tone')) root.style.setProperty('--tone', '#' + q.get('tone')); // e.g. ?tone=c8f2e6&toneink=0f2d27: a second brand tone
if (q.get('toneink')) root.style.setProperty('--tone-ink', '#' + q.get('toneink'));
root.dataset.palette = id;
addEventListener('DOMContentLoaded', () => {
if (q.get('bar') === '0') document.body.classList.add('no-bar');
if (q.get('labels') === '0') document.body.classList.add('no-labels');
const only = q.get('only');
if (only) { const list = only.split(','); const main = document.querySelector('main'); main.style.display = 'flex'; main.style.flexDirection = 'column'; document.querySelectorAll('[data-g]').forEach(el => { const i = list.indexOf(el.dataset.g); if (i < 0) el.hidden = true; else el.style.order = i; }); }
const bar = document.querySelector('.demo-bar');
if (bar) {
const sel = bar.querySelector('select[name=palette]');
Object.keys(PALETTES).forEach(k => sel.add(new Option(k, k, false, k === id)));
const gsel = bar.querySelector('select[name=ground]');
gsel.value = root.dataset.ground;
bar.addEventListener('change', () => { const u = new URL(location); u.searchParams.set('palette', sel.value); u.searchParams.set('ground', gsel.value); location = u; });
}
});
})();
</script>
<script src="backgrounds.js"></script>
</head>
<body class="bg-paper">
<div class="demo-bar"><span>palette</span><select name="palette"></select><span>lit</span><select name="ground"><option>sand</option><option>sky</option><option>sage</option><option>painting</option></select></div>
<main>
<!-- 6 topglow: on a wrapper here (on <body> with bg-paper in a real page): the first screen, under the nav -->
<div class="bg-topglow" data-g="topglow">
<span class="demo-tag">topglow</span>
<header class="nav">
<div class="container nav__bar">
<a class="nav__logo" href="#">Northgate</a>
<nav class="nav__links" aria-label="Main">
<div class="nav__item"><a class="nav__link" href="#">Product</a></div>
<div class="nav__item"><a class="nav__link" href="#">Solutions</a></div>
<div class="nav__item"><a class="nav__link" href="#">Pricing</a></div>
<div class="nav__item"><a class="nav__link" href="#">Docs</a></div>
</nav>
<div class="nav__actions"><a class="btn btn--secondary btn--sm" href="#">Log in</a><a class="btn btn--primary btn--sm" href="#">Start free</a></div>
</div>
</header>
<section class="section section--hero" style="padding-bottom: 72px">
<div class="container">
<div class="section-head section-head--center" style="margin-bottom: 48px">
<span class="pill">New <span class="muted">Shift swaps are here</span></span>
<h1>Schedules your team can trust</h1>
<p>Build the weekly rota in minutes, let people swap shifts on their phone, and see who is on at any hour.</p>
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Book a demo</a></div>
</div>
<div class="mini" style="max-width: 980px; margin-inline: auto; padding: 20px">
<h4>Week 14 <span>Kitchen, 12 people</span></h4>
<div class="week">
<div class="h"></div><div class="h">Mon</div><div class="h">Tue</div><div class="h">Wed</div><div class="h">Thu</div><div class="h">Fri</div>
<div class="n">Ana</div><div class="c">07:00 – 15:00</div><div class="c">07:00 – 15:00</div><div></div><div class="c">11:00 – 19:00</div><div class="c">11:00 – 19:00</div>
<div class="n">Jonas</div><div></div><div class="c">15:00 – 23:00</div><div class="c">15:00 – 23:00</div><div class="c">15:00 – 23:00</div><div></div>
<div class="n">Mira</div><div class="c">11:00 – 19:00</div><div></div><div class="c">07:00 – 15:00</div><div></div><div class="c">07:00 – 15:00</div>
</div>
</div>
</div>
</section>
</div>
<!-- 1 mist -->
<section class="section bg-mist" data-g="mist">
<span class="demo-tag">mist</span>
<div class="container">
<div class="section-head"><h2>One place for the rota, the swaps and the hours</h2><p>Managers plan the week. Staff see their shifts, trade them and clock in. Payroll gets the numbers without anyone retyping them.</p></div>
<div class="grid">
<article class="card span-4"><h3>Plan the week</h3><p>Drag people into shifts. Northgate warns you about overtime and missing cover before you publish.</p>
<div class="mini" style="margin-top: 20px"><div class="rowline"><span class="av">A</span><div><b>Ana</b><small>Mon to Wed, 07:00</small></div><span class="tag on">Set</span></div><div class="rowline"><span class="av">J</span><div><b>Jonas</b><small>Tue to Thu, 15:00</small></div><span class="tag">Open</span></div></div></article>
<article class="card span-4"><h3>Swap in two taps</h3><p>Staff ask a colleague to take a shift. The manager only approves what breaks a rule.</p>
<div class="mini stack" style="margin-top: 20px"><div class="bubble">Can anyone take my Thursday close?</div><div class="bubble me">I can. Mira</div></div></article>
<article class="card span-4"><h3>Hours that add up</h3><p>Clock-in times roll into one sheet per person, ready for payroll at month end.</p>
<div class="mini" style="margin-top: 20px"><h4>March <span>Total</span></h4><div class="rowline"><span class="av">M</span><div><b>Mira</b></div><span>148 h</span></div><div class="rowline"><span class="av">A</span><div><b>Ana</b></div><span>162 h</span></div></div></article>
</div>
</div>
</section>
<!-- 3 grain -->
<section class="section bg-grain" data-g="grain">
<span class="demo-tag">grain</span>
<div class="container grid grid--middle">
<div class="span-5">
<div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Rules</span><h2>Say the rules once, and the rota follows them</h2><p>Minimum rest, maximum hours, who may close alone. Northgate checks every change against them.</p>
<div class="btn-row"><a class="btn btn--primary" href="#">Set up rules</a><a class="link" href="#">Read the guide</a></div></div>
</div>
<div class="span-6 start-7">
<div class="mini" style="max-width: 440px; margin-left: auto"><h4>Rest rules <span>Kitchen</span></h4>
<div class="sw">Minimum 11 hours between shifts<i class="on"></i></div><div class="sw">No more than 5 days in a row<i class="on"></i></div><div class="sw">Two people to close<i></i></div><div class="sw">Warn about overtime<i class="on"></i></div></div>
</div>
</div>
</section>
<!-- 2 halo -->
<section class="section bg-halo" data-g="halo">
<span class="demo-tag">halo</span>
<div class="container">
<div class="section-head section-head--center" style="margin-bottom: 0">
<span class="t-label muted">Shift swaps</span>
<h2 style="max-width: 18ch">Cover a missed shift before the day starts</h2>
<p>When someone calls in sick, Northgate messages the people who can take the shift and fills it with the first yes.</p>
<div class="btn-row"><a class="btn btn--primary btn--arrow" href="#">See how swaps work</a></div>
</div>
</div>
</section>
<!-- 13 horizon -->
<section class="section bg-horizon" data-g="horizon">
<span class="demo-tag">horizon</span>
<div class="container">
<div class="section-head section-head--center" style="margin-bottom: 0">
<span class="pill">Security</span>
<h2 style="max-width: 20ch">Staff data stays where it belongs</h2>
<p>Hosted in the EU, encrypted at rest, and every change to a rota is logged with who made it.</p>
<div class="btn-row"><a class="btn btn--primary" href="#">Read the security page</a><a class="btn btn--secondary" href="#">Contact sales</a></div>
</div>
</div>
</section>
<!-- 4 paper: the page ground is on <body> (class bg-paper) and sets no tokens; this wrapper only carries the label -->
<div data-g="paper" style="position: relative">
<span class="demo-tag">paper (on body)</span>
<section class="section">
<div class="container">
<div class="section-head"><h2>From first rota to full payroll in a week</h2></div>
<ol class="grid steps">
<li class="span-4"><span class="step-num">1</span><h3 class="t-card">Add your people</h3><p>Paste a list or import it from your payroll tool. Roles and contracts come with it.</p></li>
<li class="span-4"><span class="step-num">2</span><h3 class="t-card">Build the first week</h3><p>Start from last month's pattern, change what is different, and publish.</p></li>
<li class="span-4"><span class="step-num">3</span><h3 class="t-card">Send hours to payroll</h3><p>Approved hours export as one file, in the format your payroll tool takes.</p></li>
</ol>
</div>
</section>
</div>
<!-- 5 dusk -->
<section class="section bg-dusk" data-g="dusk">
<span class="demo-tag">dusk</span>
<div class="container grid grid--middle">
<figure class="quote span-7" style="margin: 0"><blockquote>"We used to lose a Sunday every month to the rota. Now it takes twenty minutes, and nobody argues about the hours."</blockquote>
<figcaption class="person" style="margin-top: 24px"><span class="avatar">LM</span><span>Lena Moser, operations lead at a restaurant group</span></figcaption></figure>
<div class="span-4 start-9 stat-aside"><div class="t-stat">64%</div><p class="muted t-small" style="margin-top: 8px">fewer scheduling messages after the first month, across 2,400 teams</p></div>
</div>
</section>
<!-- 7 ruled -->
<section class="section bg-ruled" data-g="ruled">
<span class="demo-tag">ruled</span>
<div class="container grid grid--middle">
<div class="span-5"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Hours</span><h2>One sheet per person, checked every night</h2><p>Clock-in times roll into a sheet for each person, and anything off the rules waits for a manager before payroll sees it.</p></div></div>
<div class="span-6 start-7"><div class="mini" style="max-width: 460px; margin-left: auto"><h4>Mira <span>March</span></h4><div class="rowline"><span class="av">W</span><div><b>Week 10</b><small>5 shifts, no flags</small></div><span>38 h</span></div><div class="rowline"><span class="av">W</span><div><b>Week 11</b><small>5 shifts, 1 flag</small></div><span>41 h</span></div><div class="rowline"><span class="av">W</span><div><b>Week 12</b><small>4 shifts, no flags</small></div><span>31 h</span></div></div></div>
</div>
</section>
<!-- 10 band -->
<section class="section bg-band" data-g="band">
<span class="demo-tag">band</span>
<div class="container grid grid--middle">
<div class="span-6"><div class="big-stat">11 h</div><h2 style="margin-top: 20px; max-width: 16ch">saved on every rota, every month</h2><p class="t-lead" style="margin-top: 16px; max-width: 44ch">The average across the first hundred teams on Northgate. They plan once and let the rules do the checking.</p><div class="btn-row" style="margin-top: 28px"><a class="btn btn--primary btn--arrow" href="#">Read the study</a></div></div>
<div class="span-5 start-8"><div class="mini"><h4>This month <span>Saved</span></h4><div class="rowline"><span class="av">K</span><div><b>Kitchen</b><small>12 people</small></div><span>9 h</span></div><div class="rowline"><span class="av">B</span><div><b>Bar</b><small>7 people</small></div><span>6 h</span></div><div class="rowline"><span class="av">F</span><div><b>Front of house</b><small>14 people</small></div><span>14 h</span></div></div></div>
</div>
</section>
<!-- 8 dots -->
<section class="section bg-dots" data-g="dots">
<span class="demo-tag">dots</span>
<div class="container">
<div class="section-head section-head--center"><h2>Used every day by teams of every size</h2><p>From a single kitchen to a hospital with forty wards.</p></div>
<div class="stats4 center" style="text-align: center">
<div><b>2,400</b><span class="muted t-small">teams</span></div><div><b>61,000</b><span class="muted t-small">people on a rota</span></div><div><b>1.9 M</b><span class="muted t-small">shifts a month</span></div><div><b>4.8</b><span class="muted t-small">average app rating</span></div>
</div>
</div>
</section>
<!-- 11 field -->
<section class="section bg-field bg-inset" data-g="field">
<span class="demo-tag">field</span>
<div class="container">
<div class="section-head section-head--center"><h2>Built for the shift you actually run</h2><p>Split shifts, on-call days, a floating pool. Set each one up the way your team works.</p></div>
<div class="grid grid--middle" style="max-width: 920px; margin-inline: auto">
<div class="mini span-6"><h4>Shift pattern <span>Bar</span></h4><div class="sw">Split shift<i class="on"></i></div><div class="sw">On-call weekends<i></i></div><div class="sw">Floating pool<i class="on"></i></div></div>
<div class="mini span-6"><h4>This week <span>Bar</span></h4><div class="rowline"><span class="av">L</span><div><b>Leo</b><small>Split, 11:00 and 18:00</small></div><span class="tag on">On</span></div><div class="rowline"><span class="av">S</span><div><b>Sofia</b><small>On call, Sat</small></div><span class="tag">Off</span></div></div>
</div>
</div>
</section>
<!-- 12 stage: a vivid panel for a visual; every word on it sits on a card -->
<section class="section" data-g="stage">
<span class="demo-tag">stage</span>
<div class="container grid grid--middle">
<div class="span-5"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Time clock</span><h2>Clock in from the phone, at the door</h2><p>People clock in where they work. The time lands on the rota next to the shift they were planned for.</p><div class="btn-row"><a class="btn btn--primary" href="#">See the app</a><a class="link" href="#">Download</a></div></div></div>
<div class="span-6 start-7"><div class="bg-stage" style="min-height: 400px">
<div class="mini" style="--stage-w: 420px"><h4>Today <span>Kitchen</span></h4><div class="rowline"><span class="av">A</span><div><b>Ana</b><small>In at 06:58</small></div><span class="tag on">On</span></div><div class="rowline"><span class="av">J</span><div><b>Jonas</b><small>Starts 15:00</small></div><span class="tag">Later</span></div><div class="rowline"><span class="av">M</span><div><b>Mira</b><small>Day off</small></div><span class="tag">Off</span></div></div>
</div></div>
</div>
</section>
<!-- 10 band, tinted: the second tone is a pale tint of the accent -->
<section class="section bg-band bg-tint" data-g="band-tint">
<span class="demo-tag">band + tint</span>
<div class="container grid grid--middle">
<div class="span-6"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Payroll</span><h2>Hours go to payroll without a spreadsheet</h2><p>Approved hours export in the format your payroll tool takes: one file, checked against every contract.</p><div class="btn-row"><a class="btn btn--primary" href="#">See the export</a></div></div></div>
<div class="span-5 start-8"><div class="mini"><h4>March export <span>Ready</span></h4><div class="rowline"><span class="av">K</span><div><b>Kitchen</b><small>12 people, 1,480 h</small></div><span class="tag on">Sent</span></div><div class="rowline"><span class="av">B</span><div><b>Bar</b><small>7 people, 760 h</small></div><span class="tag">Check</span></div></div></div>
</div>
</section>
<!-- 12 photo -->
<section class="section bg-photo bg-inset" data-g="photo" style="--g-photo-pos-m: 88% 40%">
<img class="bg-photo__img" src="https://picsum.photos/id/853/2000/1300" alt="A lone rock in a calm blue sea beneath a pastel sky" loading="lazy" decoding="async">
<span class="demo-tag">photo</span>
<div class="container">
<div style="max-width: 560px; display: grid; gap: 16px; justify-items: start">
<h2>Made by a small team on the coast</h2>
<p class="t-lead">Fourteen people in two offices write Northgate, answer every message themselves and take their own rota seriously.</p>
<div class="btn-row"><a class="btn btn--primary" href="#">Meet the team</a></div>
</div>
</div>
</section>
<!-- 15 liquid -->
<section class="section bg-liquid" data-g="liquid">
<span class="demo-tag">liquid</span>
<div class="container">
<div class="section-head section-head--center" style="margin-bottom: 0">
<h2 style="max-width: 16ch">Try this week's rota for free</h2>
<p>No card, no setup call. Add your team and publish the first week today.</p>
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Talk to us</a></div>
</div>
</div>
</section>
<!-- 14 deep -->
<section class="section bg-deep" data-g="deep">
<span class="demo-tag">deep</span>
<div class="container">
<div class="section-head section-head--center"><h2>Ask the rota a question</h2><p>"Who can close on Friday?" Northgate answers from the rules and the calendar, and offers to fill the gap.</p></div>
<div class="grid" style="max-width: 920px; margin-inline: auto">
<div class="mini span-6 stack"><h4>Assistant <span>Today</span></h4><div class="bubble me">Who can close the bar on Friday?</div><div class="bubble">Leo and Sofia. Leo has 11 h rest, Sofia is on call. Want me to offer it to Leo?</div></div>
<div class="mini span-6"><h4>Friday <span>Bar</span></h4><div class="rowline"><span class="av">L</span><div><b>Leo</b><small>Rested, 11 h</small></div><span class="tag on">Offer</span></div><div class="rowline"><span class="av">S</span><div><b>Sofia</b><small>On call</small></div><span class="tag">Skip</span></div></div>
</div>
</div>
</section>
<!-- 9 arcs: the close -->
<section class="section bg-arcs" data-g="arcs">
<span class="demo-tag">arcs</span>
<div class="container">
<div class="cta-type">
<h2>Plan the next week in ten minutes</h2>
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Book a demo</a></div>
</div>
</div>
</section>
</main>
<footer class="footer"><div class="container"><p class="credits">Photograph: Stephen Radford, Unsplash License.</p></div></footer>
</body>
</html>
@@ -0,0 +1,794 @@
{
"about": "16 section and page grounds drawn in CSS (backgrounds.css). One class on a section, a wrapper, body or a visual's panel. The twelve section grounds set the page tokens for their children; the two page grounds (paper, topglow) set none and only paint. Each works on light and dark palettes and keeps ink 7:1 and muted 4.5:1 on its text zone (check.mjs measures it). pick.mjs deals a page's grounds (at most 2 beyond the flat tokens, no page ground; paper and grain are marked dealt:false and never dealt, since a ground with grain is the page's one effect; --effect also skips the grounds marked texture), for software, tech and gaming pages only (VI-41, VI-42): a product, service or place page is flat and no ground is listed for it (a product page with a technical subject asks with --kind tech and is dealt as software). Grounds list the sections they fit (sections): pick.mjs deals those; strict ones go nowhere else, byRole ones (band, field) also fit by role when the plan has none of their ids. Grounds marked coloured (band, field, stage; grain is never dealt) count toward the one coloured ground every page of five sections or more holds.",
"version": 6,
"weights": {
"1": "calm: reads as the page",
"2": "medium: a visible change of ground",
"3": "loud: the one section a screen remembers"
},
"kinds": {
"soft": "paints no colour of its own and fades into the page at the top and bottom; neighbours anything",
"hard": "a band with its own colour and edges (add bg-inset for a rounded panel on the grid)",
"page": "a ground for the whole page (body or one wrapper), not for one section",
"panel": "a rounded panel behind one visual (bg-stage): vivid, with no text of its own; every word on it sits on a card"
},
"roles": [
"hero",
"body",
"proof",
"band",
"close",
"footer"
],
"modifiers": {
"bg-inset": "a hard ground as a rounded panel whose left and right edges sit on the container's",
"bg-photo--left": "the photograph's scrim from the left, for words in the middle of the left side",
"bg-photo--light": "the scrim from the light side and dark words, for a pale photograph",
"bg-tint": "with bg-band or bg-field: the second tone is a pale tint of the accent's own colour (a mint for a teal accent) instead of the lit ground, so the band is coloured on a sand page",
"bg-bleed": "on a dark page a band or a field is inset automatically (a pale slab is the brightest thing there); bg-bleed keeps it full-bleed"
},
"knobs": {
"--g-hue": "the colour the tints lean to; default the accent with a hue guard (never violet or lavender)",
"--g-k": "strength of the tints, 1 by default",
"--tone / --tone-ink": "the second brand tone and its text colour for bg-band and bg-field; default the lit ground",
"--g-photo": "url(...) for bg-photo when no <img class=\"bg-photo__img\"> child is used",
"--g-screen": "a ground section's minimum height (default min(88svh, 860px) for a hard ground, min(76svh, 760px) for a soft one); 0px = the words' own height",
"--g-photo-pos": "where the subject of the photograph sits (object-position); --g-photo-pos-m on a phone",
"--scrim-h": "how high the photograph's scrim stays deep: backgrounds.js measures the words and sets it; a value the section sets is kept"
},
"grounds": [
{
"id": "mist",
"class": "bg-mist",
"name": "Mist",
"kind": "soft",
"weight": 1,
"animated": false,
"mood": "airy, soft colour fields like a wash of light in two neighbouring hues",
"where": "a section head over a row of three open cards; a feature list; any section that needs a little colour",
"roles": [
"hero",
"body"
],
"types": [
"software"
],
"pairs": [
"paper",
"grain",
"band",
"horizon",
"photo",
"stage"
],
"avoids": [
"halo",
"topglow",
"liquid",
"dusk"
],
"textZone": [
0,
0,
1,
1
]
},
{
"id": "halo",
"class": "bg-halo",
"name": "Halo",
"kind": "soft",
"weight": 1,
"animated": false,
"mood": "calm, one light behind one thought",
"where": "a centred statement or a closing line: a heading, a few lines and a button. Move it with --halo-x and --halo-y to where the heading sits",
"roles": [
"hero",
"body",
"close"
],
"types": [
"software"
],
"pairs": [
"paper",
"ruled",
"dots",
"grain",
"horizon",
"photo",
"stage"
],
"avoids": [
"mist",
"liquid",
"arcs"
],
"textZone": [
0,
0,
1,
1
]
},
{
"id": "grain",
"class": "bg-grain",
"name": "Grain",
"kind": "hard",
"weight": 2,
"animated": false,
"mood": "tactile, a tinted field with a fine film grain, a light corner and two neighbouring hues",
"where": "a split with text on the left and one UI card on the right (the Figma tile); a feature on a field",
"roles": [
"body",
"band"
],
"types": [
"software"
],
"pairs": [
"paper",
"mist",
"ruled",
"halo"
],
"avoids": [
"band",
"field",
"dusk",
"stage"
],
"textZone": [
0,
0,
1,
1
],
"coloured": true,
"dealt": false
},
{
"id": "paper",
"class": "bg-paper",
"name": "Paper",
"kind": "page",
"weight": 1,
"animated": false,
"mood": "quiet, a page ground that is never flat",
"where": "on body or one wrapper: fine grain and a faint tint at the top and bottom. Soft grounds are transparent, so it shows through them; hard grounds cover it",
"roles": [
"page"
],
"types": [
"software"
],
"pairs": [
"mist",
"halo",
"grain",
"dusk",
"ruled",
"dots",
"band",
"photo",
"stage"
],
"avoids": [],
"textZone": [
0,
0,
1,
1
],
"dealt": false
},
{
"id": "dusk",
"class": "bg-dusk",
"name": "Dusk",
"kind": "hard",
"weight": 1,
"animated": false,
"mood": "gentle, one tone becomes the next with no edge: both ends melt into whatever the page paints there",
"where": "a quote with one big number, a proof section, any section between two plain ones. Set --g-to for the tone, --g-from and --g-next to melt from or into a colour instead of the page",
"roles": [
"body",
"proof"
],
"types": [
"software"
],
"pairs": [
"paper",
"ruled",
"halo"
],
"avoids": [
"mist",
"grain"
],
"textZone": [
0,
0,
1,
1
]
},
{
"id": "topglow",
"class": "bg-topglow",
"name": "Top glow",
"kind": "page",
"weight": 1,
"animated": false,
"mood": "clean, light under the nav that the page lifts out of",
"where": "a wrapper around the nav and the hero: a glow from the top edge and a thin lit line. The hero's ground for software and service pages",
"roles": [
"hero"
],
"types": [
"software"
],
"pairs": [
"paper",
"dusk",
"ruled",
"grain",
"band",
"horizon"
],
"avoids": [
"mist",
"liquid"
],
"textZone": [
0,
0,
1,
1
]
},
{
"id": "ruled",
"class": "bg-ruled",
"name": "Ruled",
"kind": "soft",
"weight": 1,
"animated": false,
"mood": "precise, a grid that is the page's own frame: its first and last rules lie on the container's edges, it fades toward the top and bottom and is clear behind the heading",
"where": "a centred hero or statement, a section with one big visual. Never over a table, pricing, a list or any section that draws row rules of its own",
"roles": [
"hero",
"body"
],
"types": [
"software"
],
"pairs": [
"paper",
"halo",
"dusk",
"grain",
"band",
"stage"
],
"avoids": [
"dots",
"arcs"
],
"textZone": [
0,
0,
1,
1
],
"sections": [
"hero-centred",
"hero-split",
"hero-editorial",
"hero-product",
"product-statement",
"chapter",
"features-2x2",
"bento",
"tabs-tour",
"integrations-grid",
"calendar-frame",
"split-text-left",
"split-text-right",
"closing-type"
],
"strict": true
},
{
"id": "dots",
"class": "bg-dots",
"name": "Dots",
"kind": "soft",
"weight": 1,
"animated": false,
"mood": "quiet texture: a dot field in the margins and corners, clear behind the heading and the words",
"where": "a centred section with a row of figures, a logo row, an integrations section: a dot field and a pool of light behind the heading",
"roles": [
"body",
"proof",
"hero"
],
"types": [
"software"
],
"pairs": [
"paper",
"halo",
"band",
"horizon",
"dusk",
"stage"
],
"avoids": [
"ruled",
"arcs"
],
"textZone": [
0,
0,
1,
1
]
},
{
"id": "arcs",
"class": "bg-arcs",
"name": "Arcs",
"kind": "soft",
"weight": 1,
"animated": false,
"mood": "open, rings that rise from below like x.ai's footer",
"where": "the closing call to action, or the section above the footer: a heading and a button over rings",
"roles": [
"close",
"footer"
],
"types": [
"software"
],
"sections": [
"closing-type"
],
"pairs": [
"paper",
"mist",
"band",
"grain",
"photo"
],
"avoids": [
"ruled",
"dots",
"halo",
"horizon",
"liquid"
],
"textZone": [
0,
0,
1,
1
],
"strict": true
},
{
"id": "band",
"class": "bg-band",
"name": "Band",
"kind": "hard",
"weight": 3,
"animated": false,
"mood": "warm, a coloured band in the page's second tone, lit from one corner and deeper in the other",
"where": "one big number and a UI card; one statement with a button. The tone is the lit ground (sand, sky, sage, painting) or --tone. On a dark page it is the one light band",
"roles": [
"band"
],
"types": [
"software"
],
"sections": [
"quote-stat",
"product-statement",
"split-text-left",
"split-text-right",
"chapter",
"customer-proof-row",
"steps",
"pricing-three",
"pricing-two",
"comparison-table",
"timeline-rail",
"services-list",
"people-row",
"features-2x2",
"integrations-grid",
"rooms-list"
],
"pairs": [
"paper",
"mist",
"halo",
"dusk",
"ruled",
"dots"
],
"avoids": [
"field",
"grain",
"stage"
],
"textZone": [
0,
0,
1,
1
],
"variants": [
{
"id": "band-tint",
"class": "bg-band bg-tint",
"textZone": [
0,
0,
1,
1
]
}
],
"byRole": true,
"coloured": true
},
{
"id": "field",
"class": "bg-field",
"name": "Field",
"kind": "hard",
"weight": 3,
"animated": false,
"mood": "rich, a grainy gradient in one family of colour (a peach beside sand, a cyan beside sky) with a light sweep: Figma's sand tile",
"where": "a centred heading over one or two UI cards; best as a rounded inset panel (bg-inset)",
"roles": [
"band",
"hero"
],
"types": [
"software"
],
"sections": [
"features-2x2",
"bento",
"tabs-tour",
"split-text-left",
"split-text-right",
"calendar-frame",
"integrations-grid",
"steps",
"pricing-three",
"pricing-two",
"comparison-table",
"timeline-rail",
"services-list",
"people-row",
"hero-centred",
"hero-editorial",
"chapter",
"product-statement"
],
"pairs": [
"paper",
"halo",
"ruled",
"dots",
"dusk"
],
"avoids": [
"band",
"grain",
"stage"
],
"textZone": [
0,
0,
1,
1
],
"variants": [
{
"id": "field-tint",
"class": "bg-field bg-tint",
"textZone": [
0,
0,
1,
1
]
}
],
"byRole": true,
"coloured": true,
"texture": true
},
{
"id": "stage",
"class": "bg-stage",
"name": "Stage",
"kind": "panel",
"weight": 2,
"animated": false,
"mood": "vivid, the accent as a grainy mesh that one card floats on: Figma's colour tiles",
"where": "the panel behind the one UI card or window beside a section's text (it is a panel, not a section: <div class=\"bg-stage\"><div class=\"mini\">). Never behind a heading: every word on it is on a card. On a strong-colour page (data-tint away) it takes the accent's hue, on a grey accent (data-lowchroma) the lit ground's colour. --stage-w is the card's width",
"roles": [
"body",
"hero"
],
"types": [
"software"
],
"sections": [
"hero-split",
"hero-cycle",
"hero-product",
"split-text-left",
"split-text-right",
"tabs-tour",
"calendar-frame",
"hero-editorial",
"chapter",
"product-statement",
"integrations-grid"
],
"pairs": [
"paper",
"mist",
"halo",
"dusk",
"ruled",
"dots"
],
"avoids": [
"field",
"grain",
"band",
"liquid"
],
"textZone": null,
"coloured": true,
"strict": true,
"texture": true
},
{
"id": "photo",
"class": "bg-photo",
"name": "Photo",
"kind": "hard",
"weight": 3,
"animated": false,
"mood": "calm and large, a photograph under a quiet scrim",
"where": "a media band, the hero of a place or product page, a closing photo. Put the photograph in as <img class=\"bg-photo__img\" src alt> (or --g-photo: url(...) where no img fits); words sit lower left (bg-photo--left: left side). A pale photograph takes bg-photo--light (dark words on a light scrim). Credits go in the footer line",
"roles": [
"hero",
"band",
"close"
],
"types": [
"software"
],
"sections": [
"product-statement",
"quote-stat",
"chapter",
"closing-type"
],
"needs": "a vetted photograph with a calm area (scripts/photos.mjs pick)",
"pairs": [
"paper",
"halo",
"mist",
"dusk"
],
"avoids": [
"horizon",
"deep",
"liquid"
],
"textZone": [
0,
0.6,
0.6,
1
],
"variants": [
{
"id": "photo-left",
"class": "bg-photo bg-photo--left",
"textZone": [
0,
0,
0.45,
1
]
},
{
"id": "photo-light",
"class": "bg-photo bg-photo--light",
"textZone": [
0,
0.6,
0.6,
1
]
},
{
"id": "photo-white",
"class": "bg-photo",
"textZone": [
0,
0.6,
0.6,
1
],
"note": "a white photograph under the dark scrim: the worst case for white words"
},
{
"id": "photo-left-white",
"class": "bg-photo bg-photo--left",
"textZone": [
0,
0,
0.45,
1
]
}
],
"strict": true
},
{
"id": "horizon",
"class": "bg-horizon",
"name": "Horizon",
"kind": "hard",
"weight": 3,
"animated": false,
"mood": "dramatic, a dark band with a lit curve like a planet's rim",
"where": "a statement the page is sure of (security, scale, a promise) with a button above the horizon; the page's one dark band. Words stay above the lit curve, whose rim is brightest at the top",
"roles": [
"band",
"close"
],
"types": [
"software"
],
"sections": [
"product-statement",
"quote-stat",
"chapter",
"closing-type"
],
"pairs": [
"paper",
"halo",
"dusk",
"mist",
"dots"
],
"avoids": [
"deep",
"photo",
"arcs"
],
"textZone": [
0,
0,
1,
0.58
],
"strict": true
},
{
"id": "deep",
"class": "bg-deep",
"name": "Deep",
"kind": "hard",
"weight": 2,
"animated": false,
"mood": "quiet and dark, near-black with a cool pool at the top, a lit lip and grain like x.ai",
"where": "a feature section with two or three UI cards (chat, code, voice): the dark ground UI reads best on",
"roles": [
"band",
"hero",
"close"
],
"types": [
"software"
],
"sections": [
"hero-split",
"hero-centred",
"hero-cycle",
"features-2x2",
"bento",
"tabs-tour",
"split-text-left",
"split-text-right",
"calendar-frame",
"integrations-grid",
"closing-type"
],
"pairs": [
"paper",
"halo",
"dots",
"mist"
],
"avoids": [
"horizon",
"photo"
],
"textZone": [
0,
0,
1,
1
],
"strict": true,
"texture": true
},
{
"id": "liquid",
"class": "bg-liquid",
"name": "Liquid",
"kind": "soft",
"weight": 2,
"animated": true,
"mood": "alive, two slow blobs of two neighbouring hues drifting; transforms only",
"where": "a call to action or a hero with a heading and two buttons. One a page; it is the page's moving thing, so no other looping effect in view",
"roles": [
"hero",
"close"
],
"types": [
"software"
],
"sections": [
"hero-centred",
"hero-editorial",
"hero-split",
"closing-type"
],
"pairs": [
"paper",
"dusk",
"ruled",
"dots"
],
"avoids": [
"mist",
"halo",
"topglow",
"arcs",
"photo",
"stage"
],
"textZone": [
0,
0,
1,
1
],
"checkFrames": [
"liquid-mid",
"liquid-end"
],
"strict": true,
"texture": true
}
]
}
@@ -0,0 +1,205 @@
#!/usr/bin/env node
/*
taste-skill backgrounds pick.mjs - deals a page's grounds at random, in a calm rhythm.
node <skill>/assets/backgrounds/pick.mjs --type software --sections hero-split,logo-row,features-2x2,bento,quote-stat,pricing-three,faq,closing-band
[--roles hero,body,body,proof,close] [--effect] [--seed S] [--mode light|dark] [--history .taste-skill/bg-history.json] [--json]
Grounds are dealt to software, tech and gaming pages only (VI-41, VI-42). --type product, service or place
(site-kind other) is answered with plain for every section and no page ground: those pages are flat colour and
photographs. A product page with a technical subject says so with --kind tech (or software, gaming) and is dealt as software.
--sections takes the plan's section ids (nav and footer ids are skipped); a section the rhythm already
gives a band keeps it: bento:dark (.ground-inverse), bento:band (lit ground), bento:surface (.ground-surface),
and is dealt nothing; --roles takes roles instead
(hero, body, proof, close). No page ground is dealt: the page keeps its flat tokens, and every section
that is not given a ground stays plain on them (VI-42: at most 2 CSS grounds a page beyond the flat tokens).
The grain ground and the paper page ground are never dealt (a ground with grain is the page's one effect,
chosen by hand under effects-usage.md); --effect says another effect is already dealt, and the textured
grounds (field, stage, deep, liquid) are skipped too. The result is a list: one ground or "plain"
per section. A section that brings its own ground (hero-photo, photo-band, closing-photo, closing-band)
is kept as it is and counts as a strong neighbour. Also an ES module: import { deal } from './pick.mjs'.
The rhythm rules (references/backgrounds.md) are checked on every deal, by random attempts until one
holds: 2 grounds a page (the cap, VI-42); each ground once; two grounds that list each other in "avoids" never
share a page; no two neighbours both strong (a hard ground, or weight 2 or 3); never three dealt
grounds in a row (moot at two); at most one loud ground (weight 3) up to six sections, two up to ten; at most two
dark ones; a page of five sections or more holds at least one strong ground and at least one coloured
one (band, field or stage: never a page of pale washes and dark bands only). A ground used in
the last 20 deals is less likely. Grounds that list their sections (index.json) go to those; band and
field also fit by role when the plan has none of their ids, so every plan gets its colour. Dusk
melts into the page at both ends, so it needs no particular neighbour.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const TYPES = ['software', 'product', 'place', 'service'];
const FLAT_TYPES = new Set(['product', 'place', 'service']); // flat pages: no ground is dealt (VI-41)
const TECH_KINDS = new Set(['tech', 'software', 'gaming']);
const DARK = new Set(['horizon', 'deep', 'photo']);
/* a section id -> its role, or null when it is skipped, or 'own' when it brings its own ground */
export function roleOf(id) {
if (/^(nav|footer)/.test(id)) return null;
if (/^(hero-photo|photo-band|media-band|closing-photo|closing-band)$/.test(id)) return 'own';
if (/^hero/.test(id)) return 'hero';
if (/^(closing|close)/.test(id)) return 'close';
if (/^(quote|stat|logo|customer-proof|press)/.test(id)) return 'proof';
return 'body';
}
const OWN_LOUD = new Set(['hero-photo', 'photo-band', 'media-band', 'closing-photo']);
function strHash(s) { let h = 2166136261 >>> 0; for (const ch of String(s)) { h ^= ch.codePointAt(0); h = Math.imul(h, 16777619); } return h >>> 0; }
export function random(seed) {
let a = strHash(seed) || 1;
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const strong = g => !!g && (g.kind === 'hard' || g.weight >= 2);
/**
index: backgrounds index.json (its "grounds"). opts: { type, kind, sections: [ids] | roles: [roles], seed, history: [ids] }.
Returns { seed, page: null, picks: [{ section, role, ground | null, own? }], notes }.
*/
export function deal(index, opts = {}) {
let type = opts.type;
if (!TYPES.includes(type)) throw new Error(`pick: --type is one of ${TYPES.join(', ')}`);
if (type === 'product' && TECH_KINDS.has(opts.kind)) type = 'software'; // only a technical product page is dealt as tech (VI-41); place and service stay flat
const flat = FLAT_TYPES.has(type);
const ids = opts.sections && opts.sections.length ? opts.sections : (opts.roles || []);
// an id may carry a flag for a section the page's rhythm already gives a ground (sections.md): id:dark is a
// .ground-inverse band (strong, dark), id:band a lit-ground band (strong), id:surface a .ground-surface band (calm).
// Such a section keeps that ground and is dealt none.
const slots = ids.map(id => {
const [name, flag = ''] = String(id).split(':'), r = opts.sections ? roleOf(name) : name;
return { section: id, name, flag, role: r && ['dark', 'band', 'surface'].includes(flag) ? 'own' : r };
}).filter(s => s.role);
if (!slots.length) throw new Error('pick: give --sections (the plan\'s ids) or --roles');
for (const s of slots) if (!['hero', 'body', 'proof', 'close', 'own'].includes(s.role)) throw new Error(`pick: unknown role "${s.role}" (hero, body, proof, close)`);
const n = slots.length;
if (flat) { // product, service and place stay flat: plain for every section, no page ground (VI-41)
return { seed: String(opts.seed ?? ''), page: null, picks: slots.map(s => ({ section: s.section, role: s.role, ground: null, ...(s.role === 'own' ? { own: true } : {}) })), notes: ['pick: a ' + opts.type + ' page is flat (VI-41): every section stays plain, no page ground.' + (opts.type === 'product' ? ' A technical subject: --kind tech' : '')] };
}
const byId = new Map(index.grounds.map(g => [g.id, g]));
// never dealt: paper (the page ground) and grain (a grain ground is the page's one effect, VI-42); with opts.effect the textured grounds go too
const pool = index.grounds.filter(g => g.dealt !== false && g.id !== 'paper' && g.id !== 'grain' && !(opts.effect && g.texture) && g.types.includes(type));
const seed = opts.seed != null && opts.seed !== '' ? String(opts.seed) : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rand = random(seed);
const recent = (opts.history || []).slice(-20);
const maxLoud = n <= 6 ? 1 : 2;
const notes = [];
// a ground that lists its sections (index.json "sections") goes to those ids. A "strict" one (photo, horizon, deep, liquid,
// arcs, stage, ruled) goes nowhere else. A byRole one (band, field) also fits by role when NONE of its listed sections is in
// the plan, so a plan without those ids still gets its colour.
const listedIn = g => slots.some(s => g.sections.includes(s.name));
const fits = (g, slot) => {
if (!(opts.sections && g.sections)) return fitsRole(g, slot.role);
if (g.sections.includes(slot.name)) return true;
return !!g.byRole && !g.strict && !listedIn(g) && fitsRole(g, slot.role);
};
const fitsRole = (g, role) => role === 'hero' ? g.roles.includes('hero')
: role === 'close' ? g.roles.includes('close')
: g.roles.some(r => r === 'body' || r === 'band' || r === 'proof') && (role !== 'proof' || g.roles.includes('proof') || g.roles.includes('body'));
const weightOf = (g, nbrs, used) => {
const uses = recent.filter(x => x === g.id).length;
let w = Math.pow(0.35, uses);
if (recent.lastIndexOf(g.id) >= recent.length - 5 && recent.includes(g.id)) w *= 0.5;
for (const nb of nbrs) if (nb && (g.pairs || []).includes(nb.id)) w *= 1.6;
return w;
};
const choose = (cands, nbrs, used) => {
const ws = cands.map(g => weightOf(g, nbrs, used)), tot = ws.reduce((s, x) => s + x, 0);
let r = rand() * tot, i = 0;
while (i < ws.length - 1 && (r -= ws[i]) > 0) i++;
return cands[i];
};
const valid = pl => { // pl: ground or null per slot
const dealt = pl.filter(Boolean);
if (dealt.length < Math.min(2, n) || dealt.length > 2) return false;
if (new Set(dealt.map(g => g.id)).size !== dealt.length) return false;
for (const a of dealt) for (const b of dealt) if (a !== b && (a.avoids || []).includes(b.id)) return false;
const strongAt = pl.map((g, i) => strong(g) || (slots[i].role === 'own' && slots[i].flag !== 'surface'));
for (let i = 1; i < n; i++) if (strongAt[i] && strongAt[i - 1]) return false;
// a section with its own ground (not a calm surface band) counts as grounded for the run of three and the page total
const occ = pl.map((g, i) => !!g || (slots[i].role === 'own' && slots[i].flag !== 'surface'));
for (let i = 2; i < n; i++) if (occ[i] && occ[i - 1] && occ[i - 2]) return false;
if (occ.filter(Boolean).length > 5) return false;
const loud = pl.filter((g, i) => (g && g.weight === 3) || OWN_LOUD.has(slots[i].name)).length;
if (loud > maxLoud) return false;
if (dealt.filter(g => DARK.has(g.id)).length + slots.filter(s => s.flag === 'dark').length > 2) return false;
if (n >= 5 && !strongAt.some(Boolean)) return false;
if (needColour && !hasColour(pl)) return false;
return true;
};
// a page of five sections or more holds a coloured ground (band, field, stage) besides the dark ones: the page is
// never one pale wash. A band the rhythm already gives (id:band) counts. Relaxed after 450 failed attempts.
const COLOURED = new Set(index.grounds.filter(g => g.coloured && g.dealt !== false && g.id !== 'grain').map(g => g.id));
let needColour = n >= 5;
const hasColour = pl => pl.some(g => g && COLOURED.has(g.id)) || slots.some(s => s.flag === 'band');
let plan = null;
for (let t = 0; t < 600 && !plan; t++) {
if (t === 450 && needColour) { needColour = false; notes.push('pick: no deal with a coloured ground found; the page may be all pale'); }
const free = slots.map((s, i) => (s.role === 'own' ? -1 : i)).filter(i => i >= 0);
if (free.length < 2) { notes.push('pick: fewer than two sections can take a ground; the page ground only'); break; }
const want = Math.min(free.length, 2);
const at = new Set();
const hero = free.find(i => slots[i].role === 'hero'), close = [...free].reverse().find(i => slots[i].role === 'close');
if (hero != null && rand() < 0.8) at.add(hero);
if (close != null && close !== hero && rand() < 0.5) at.add(close);
while (at.size < want) at.add(free[Math.floor(rand() * free.length)]);
const pl = new Array(n).fill(null);
let ok = true;
for (const i of [...at].sort((a, b) => a - b)) {
const used = pl.filter(Boolean);
const cands = pool.filter(g => fits(g, slots[i]) &&!used.some(u => u.id === g.id || (u.avoids || []).includes(g.id) || (g.avoids || []).includes(u.id)));
if (!cands.length) { ok = false; break; }
pl[i] = choose(cands, [pl[i - 1], pl[i + 1]], used);
}
if (ok && valid(pl)) plan = pl;
}
if (!plan) { notes.push('pick: no calm deal found; every section stays plain on the page ground'); plan = new Array(n).fill(null); }
return { seed, page: null, picks: slots.map((s, i) => ({ section: s.section, role: s.role, ground: plan[i] ? plan[i].id : null, ...(s.role === 'own' ? { own: true } : {}) })), notes };
}
export function readIndex() { return JSON.parse(fs.readFileSync(path.join(HERE, 'index.json'), 'utf8')); }
/* ---------------------------------------------------------------- command line */
// a path compared by its real location, so a symlinked install (install.sh) is the same file
const realOf = (p) => { try { return fs.realpathSync(p).toLowerCase(); } catch { return path.resolve(p).toLowerCase(); } };
if (process.argv[1] && realOf(process.argv[1]) === realOf(fileURLToPath(import.meta.url))) {
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); };
const list = k => (opt(k) && opt(k) !== true ? String(opt(k)).split(',').map(s => s.trim()).filter(Boolean) : null);
const die = m => { console.error(m); process.exit(1); };
const histFile = opt('history') && opt('history') !== true ? opt('history') : null;
let hist = {};
if (histFile && fs.existsSync(histFile)) { try { hist = JSON.parse(fs.readFileSync(histFile, 'utf8')); } catch { hist = {}; } }
if (Array.isArray(hist)) hist = { picks: hist };
hist.grounds = Array.isArray(hist.grounds) ? hist.grounds : [];
let out;
try {
out = deal(readIndex(), { type: opt('type'), kind: opt('kind') === true ? undefined : opt('kind'), sections: list('sections'), roles: list('roles'), effect: !!opt('effect'), seed: opt('seed') === true ? undefined : opt('seed'), history: hist.grounds.map(x => x.id) });
} catch (e) { die(e.message); }
for (const x of out.notes) console.error(x);
if (histFile) {
const at = new Date().toISOString();
hist.grounds.push(...out.picks.filter(p => p.ground).map(p => ({ id: p.ground, at, seed: out.seed })));
hist.grounds = hist.grounds.slice(-200);
fs.mkdirSync(path.dirname(path.resolve(histFile)), { recursive: true });
fs.writeFileSync(histFile, JSON.stringify(hist, null, 1) + '\n');
}
if (opt('json')) console.log(JSON.stringify(out));
else {
const dark = opt('mode') === 'dark';
const label = p => p.own ? '(its own ground)' : !p.ground ? 'plain'
: p.ground === 'stage' ? 'bg-stage (a panel behind the visual: <div class="bg-stage"> round the card, never on the <section>)'
: 'bg-' + p.ground + (dark && (p.ground === 'band' || p.ground === 'field') ? ' (inset by itself on a dark page; bg-bleed keeps it full-bleed)' : '');
console.log(out.page ? `page ${out.page} seed ${out.seed}` : `page flat (no page ground; the flat tokens only) seed ${out.seed}`);
out.picks.forEach((p, i) => console.log(`${String(i + 1).padStart(2)}. ${p.section.padEnd(18)} ${label(p)}`));
}
}
-105
View File
@@ -1,105 +0,0 @@
/* tasteskill / base.css
A neutral foundation: reset, token names, fluid type steps, a 12-column grid, states and the motion
helpers from references/motion.md. It has no look of its own. Copy it into the project, replace the
token values with the ones on the direction sheet, and build on top. Delete what you do not use. */
/* ---------- tokens (replace with the dealt palette and type system) ---------- */
:root {
--bg: #ffffff;
--surface: #f2f2f2;
--ink: #111111;
--muted: #5c5c5c;
--accent: #111111;
--accent-ink: #ffffff;
--font-display: system-ui, sans-serif;
--font-text: system-ui, sans-serif;
--step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem);
--step-body: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
--step-lead: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);
--step-head: clamp(2rem, 1.3rem + 3.2vw, 4rem);
--step-display: clamp(3rem, 1rem + 9vw, 9rem); /* set from the hero blueprint */
--margin: clamp(1rem, 2.2vw, 2rem); /* page side margin: tight, so content reaches the edges */
--gutter: clamp(1rem, 2vw, 2rem); /* column gap */
--section: clamp(5rem, 12vw, 13rem); /* space between sections */
--radius: 0px; /* one radius for the whole page: 0, or large, decided once */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals of 800ms and longer, line rises, openings */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */
--t-press: 140ms;
--t-hover: 180ms;
--t-ui: 240ms;
--t-reveal: 800ms;
--t-open: 1200ms; /* picture openings and clip wipes */
--stagger-line: 90ms;
--stagger-item: 70ms;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: auto; }
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font-text); font-size: var(--step-body); line-height: 1.5; font-kerning: normal; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, video, svg, canvas { display: block; max-width: 100%; }
img, video { height: auto; background: var(--surface); }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; }
:target { scroll-margin-top: 6rem; }
::selection { background: var(--accent); color: var(--accent-ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* ---------- type ---------- */
h1, h2, h3 { font-family: var(--font-display); font-weight: inherit; text-wrap: balance; hyphens: none; }
.display { font-size: var(--step-display); line-height: 0.95; letter-spacing: -0.035em; }
.head { font-size: var(--step-head); line-height: 1.02; letter-spacing: -0.025em; }
.lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; }
.small { font-size: var(--step-small); line-height: 1.45; }
.muted { color: var(--muted); }
p { max-width: 62ch; text-wrap: pretty; }
p + p { margin-top: 0.9em; }
.nums { font-variant-numeric: tabular-nums; }
.only-wide { display: none; }
@media (min-width: 48em) { .only-wide { display: inline; } }
/* ---------- layout ---------- */
.wrap { padding-inline: var(--margin); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
.section { padding-block: calc(var(--section) / 2); }
.section + .section { margin-top: 0; }
.bleed { margin-inline: calc(var(--margin) * -1); }
.cover { width: 100%; height: 100%; object-fit: cover; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ---------- controls ---------- */
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: 0 1.4rem; background: var(--accent); color: var(--accent-ink); border-radius: var(--radius); text-decoration: none; white-space: nowrap; transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
.button:active { transform: scale(0.97); }
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
.link-plain { text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 0.07em; }
@media (hover: hover) and (pointer: fine) {
.link:hover { text-decoration-color: currentColor; }
}
.field { width: 100%; min-height: 3rem; padding: 0.7rem 0.9rem; background: var(--surface); border: 0; border-radius: var(--radius); font-size: max(1rem, var(--step-body)); }
/* ---------- motion helpers (content stays visible without JavaScript) ---------- */
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.lines [data-line] { display: block; }
.shutter { overflow: hidden; }
@media (prefers-reduced-motion: no-preference) {
.js [data-rise] { opacity: 0; transform: translateY(18px); animation: ad-rise var(--t-reveal) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 90ms + 120ms); }
.js .lines [data-line] { transform: translateY(105%); animation: ad-line 900ms var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 110ms + 150ms); }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
.js .shutter[data-reveal] { opacity: 1; transform: none; clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
.js .shutter[data-reveal] img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
.js .shutter[data-reveal].is-in { clip-path: inset(0); }
.js .shutter[data-reveal].is-in img { transform: none; }
}
@keyframes ad-rise { to { opacity: 1; transform: none; } }
@keyframes ad-line { to { transform: none; } }
-37
View File
@@ -1,37 +0,0 @@
// tasteskill / base.js — the small amount of script the motion helpers in base.css need.
// Put this line in <head> so the hidden states only apply when scripting works:
// <script>document.documentElement.classList.add('js')</script>
// Then load this file at the end of <body> (or with defer).
// Reveal once when an element enters the viewport. Never replays, never leaves content hidden.
(() => {
const targets = document.querySelectorAll('[data-reveal]');
if (!targets.length) return;
if (!('IntersectionObserver' in window)) { targets.forEach((el) => el.classList.add('is-in')); return; }
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-in');
io.unobserve(entry.target);
}
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
targets.forEach((el) => io.observe(el));
// safety net: if anything is still hidden a few seconds after load (observer never fired), show it
window.addEventListener('load', () => setTimeout(() => {
targets.forEach((el) => { const r = el.getBoundingClientRect(); if (r.top < innerHeight && r.bottom > 0) el.classList.add('is-in'); });
}, 2500));
})();
// Optional, for "colour by chapter": each chapter paints its own full-bleed ground in CSS
// (style="background: var(--field-2); color: var(--field-2-ink)"), so the colour changes exactly at
// section edges. This only lets a fixed header take the ink of the chapter under it. Give each chapter
// data-ink="#hex" and style the header with color: var(--header-ink, var(--ink)).
// Never recolour the whole page from an observer: mid-change, a paragraph sits on two colours.
(() => {
const chapters = document.querySelectorAll('[data-ink]');
if (!chapters.length || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver((entries) => {
for (const entry of entries) if (entry.isIntersecting) document.documentElement.style.setProperty('--header-ink', entry.target.dataset.ink);
}, { rootMargin: '0px 0px -92% 0px', threshold: 0 });
chapters.forEach((el) => io.observe(el));
})();
-619
View File
@@ -1,619 +0,0 @@
// tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data.
// They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet
// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no
// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables:
// --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default),
// --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track),
// --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative
// Usage (after this file loads):
// Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact })
// Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices]
// Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' })
// Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside
// Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar
// Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent
// Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target
// Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark
// Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day
// Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day
// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans
// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no
// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its
// label set where no series passes.
// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed)
// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a
// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat:
// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step.
// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more:
// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in
// the card's header, or pass legend: element (or a selector) to have it filled.
// Numbers follow the page's language (<html lang>). Every chart gets role="img" and an aria-label that states what it shows.
(() => {
const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0);
const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb;
const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; };
const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; };
const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; };
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
// every text value that reaches innerHTML goes through esc: a label from a database is text, never markup
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const fmt = {
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v),
ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`,
// a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4'
signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)),
// minutes since midnight as a 24-hour time: 680 → '11:20'
clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter
};
const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number)));
// Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value.
function ticks(min, max, count = 4) {
if (max === min) max = min + 1;
const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag;
const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step;
const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10));
return out;
}
// x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that
// lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels
// ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00.
function xPicks(labels, span, at) {
const n = labels.length;
if (at) return at.filter((i) => i >= 0 && i < n);
if (n <= 1) return n ? [0] : [];
const longest = Math.max(...labels.map((l) => String(l).length));
const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18))));
if (n <= cap) return [...Array(n).keys()];
const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null;
const d = mins ? mins[1] - mins[0] : 0;
let first = null, land = null;
for (let k = 1; k < n; k++) {
const stepM = k * d, clock = mins && d > 0;
if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue;
// start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps
let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0;
if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0));
const count = Math.floor((n - 1 - s) / k) + 1;
if (count > cap) continue;
if (first == null) first = { k, s };
if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; }
}
const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 };
const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i);
return out;
}
// colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step)
const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none';
const rgb = (el, value) => {
el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value;
const c = getComputedStyle(swatchProbe).color; swatchProbe.remove();
const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null;
return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number);
};
const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; };
// text width, measured in the chart's own face (falls back to an estimate when the chart is hidden)
const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; };
const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; };
function frame(el, h) {
el.innerHTML = '';
const w = Math.max(240, el.clientWidth || 600);
const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el);
return { s, w, h };
}
function tooltip(el) {
let t = el.querySelector('.chart-tip');
if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); }
return t;
}
function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) {
const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
for (const v of yt) {
const yy = y(v);
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink });
}
const n = labels.length;
for (const i of xPicks(labels, w - padL, at)) {
const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i);
// the first and last labels are pulled inside the plot; none runs past either edge
let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2;
left = Math.max(band ? 0 : padL, Math.min(w - tw, left));
if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue;
text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink });
}
}
// Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader
// line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps
// it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched
// (close markers often need the first label on the left so the next leader runs clear): full labels first, then
// short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console.
function lane(s, items, w, maxRows = 3) {
const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2);
// fixed captions first, then spans, then markers from left to right
const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0));
const opts = order.map((it) => {
const out = [];
for (const str of [it.label, it.short].filter(Boolean)) {
const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0;
const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]];
for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 });
}
return out;
});
const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap)
&& !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3)
&& !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3)));
let best = null, steps = 0;
const solve = (i, placed, allowShort, allowDrop) => {
if (++steps > 20000) return false;
if (i === order.length) { best = placed.slice(); return true; }
for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
return false;
};
for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; }
const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped);
return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 };
}
function prepNotes(s, annotations = [], x, w, extra = []) {
const items = [];
for (const a of annotations || []) {
if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour });
else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour });
}
items.push(...extra);
return { items, ...lane(s, items.filter((it) => it.label), w) };
}
// shaded spans go under the data; marker lines and the label rows over it
function notesUnder(g, el, N, { padT, h, padB }) {
const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07);
for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g);
}
function notesOver(g, el, N, { padT, h, padB }) {
const ink = css(el, '--axis-ink', '#6b6b6b');
// a marker line runs to the plot's foot, or stops above the bar it points at (it.end)
for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
for (const p of N.placed) {
const base = padT - 9 - p.row * ROW;
if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g);
text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink });
}
const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set();
for (const d of N.dropped) {
if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); }
if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); }
}
}
// does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 }
function hits(b, lines) {
for (const pts of lines) for (let k = 1; k < pts.length; k++) {
const a = pts[k - 1], c = pts[k]; if (!a || !c) continue;
if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; }
const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0]));
for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; }
}
return false;
}
const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3);
// a reference line (a budget, an SLO) with its label at an end where no series passes
function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) {
const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
for (const r of [].concat(refs || [])) {
const yy = y(r.value);
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g);
if (!r.label) continue;
const tw = measure(s, r.label, 11), right = r.at !== 'left';
const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy }));
const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0];
text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink });
}
}
// One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart.
function paintRect(parent, st, x, y, w, h, r = 0) {
let fill = st.fill || 'none';
if (st.hatch) {
const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8);
const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s));
svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p);
fill = `url(#${id})`;
}
const inset = st.stroke ? (st.width || 1) / 2 : 0;
const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent);
if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent);
return n;
}
const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}';
function legendEl(items, target) {
if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); }
const ul = document.createElement('ul'); ul.className = 'chart-legend';
for (const it of items) {
const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' });
it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li);
}
const t = typeof target === 'string' ? document.querySelector(target) : target;
if (t) t.replaceChildren(ul);
return ul;
}
const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r);
// charts are laid out with measured text: draw again once the page's fonts have arrived
function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } }
const heightOf = (h) => (typeof h === 'function' ? h() : h);
function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) {
el._fmt = format;
const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3')));
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value));
// max caps the range (a flat series with one spike): values above it are clipped and their peak labelled
const top = max ?? Math.max(...all);
const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4);
const tick = tickFormat || format;
const lastVal = series[0].values.filter((v) => v != null).at(-1);
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0;
const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1);
// the peaks clipped by max are labelled in the rows above the plot, where the line leaves it
const peaks = [];
series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } }));
const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; };
const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]]));
let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null;
// the today label goes inside the plot on whichever side of its line no series crosses; failing that, above
if (today != null && todayLabel) {
const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y);
const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b }));
todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null;
if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); }
}
const g = svg('g', {}, s);
axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick });
notesUnder(g, el, N, { padT, h, padB });
series.forEach((sr, k) => {
const col = colourOf(sr, k);
const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))]));
// step: a balance that only changes on given days holds its value until the next one
const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), '');
if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); }
if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g);
else {
// a forecast: the line after today is dashed, so a projection never reads as a fact
const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8);
const defs = svg('defs', {}, g);
const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a);
const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b);
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g);
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g);
if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); }
}
if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); }
});
references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) });
notesOver(g, el, N, { padT, h, padB });
// hover: a rule, a dot per series and a bare tooltip
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${esc(labels[i])}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : esc(format(sr.values[i]))}</b> ${series.length > 1 ? esc(sr.name) : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
const last = series[0].values.filter((v) => v != null).at(-1);
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
};
draw(); watch(el, draw); settle(draw);
const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null;
return { legend: lg };
}
function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) {
el._fmt = format;
const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i);
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format;
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62);
const x = (i) => padL + band * i + band / 2;
const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows);
const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1));
const g = svg('g', {}, s);
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick });
notesUnder(g, el, N, { padT, h, padB });
for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4;
const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9');
values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); });
references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) });
notesOver(g, el, N, { padT, h, padB });
s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`);
};
draw(); watch(el, draw); settle(draw);
}
// Two directions on one scale: what went out above the zero line and what came in below it (sold and bought,
// income and spend). The axis says which is which, with signed ticks and a caption on each side of zero.
// mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged).
function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) {
if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); }
up = up || labels.map(() => 0); down = down || labels.map(() => 0);
const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } };
const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side);
const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v));
const draw = () => {
const { s, w, h } = frame(el, heightOf(height));
const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down);
const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10));
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62);
const x = (i) => padL + band * i + band / 2;
// the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left
const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows);
const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0]));
const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b');
const bwid = measure(s, below, 11) + 9;
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] });
svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink });
notesUnder(g, el, N, { padT, h, padB });
for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4;
labels.forEach((lab, i) => {
const o = partial === i ? 0.5 : null;
if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); }
if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); }
});
svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g);
references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) });
notesOver(g, el, N, { padT, h, padB });
s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`);
};
draw(); watch(el, draw); settle(draw);
const items = [];
if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) });
if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) });
if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) {
el.innerHTML = '';
const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value));
const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`;
const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap);
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${esc(label)}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${esc(sub)}</div>` : ''}`;
wrap.appendChild(t); el.appendChild(wrap);
}
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
// label instead, ink for the top item, for a ranked list of money in a narrow card.
function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) {
el.innerHTML = '';
const max = Math.max(...rows.map((r) => r.value)) || 1;
const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`;
const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1;
rows.forEach((r, k) => {
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px';
const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right';
if (thin) {
const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`;
li.append(a, b, bar);
} else {
const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0';
const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`;
track.append(bar, a); li.append(track, b);
}
list.appendChild(li);
});
el.appendChild(list);
}
// Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is
// already committed (bills to come), and an overrun past the track's end in the negative colour.
// One row per category: label and figures on one line, the 8px track under them.
function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) {
el.innerHTML = '';
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums';
const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0))));
for (const r of rows) {
const over = r.spent > r.planned;
const li = document.createElement('li');
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
top.innerHTML = `<span>${esc(r.label)}</span><span><b style="font-weight:500">${esc(format(r.spent))}</b> <span style="color:var(--muted,#5f6368)">of ${esc(format(r.planned))} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${esc(note(r))}</span></span>`;
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
const pct = (v) => (v / r.planned) * 100;
const spent = Math.min(r.spent, r.planned);
track.innerHTML = `<span style="position:absolute;inset:0 auto 0 0;width:${pct(spent)}%;border-radius:999px;background:var(--chart-1,#2f6fdf)"></span>`
+ (r.committed ? `<span style="position:absolute;top:0;bottom:0;left:${pct(spent)}%;width:${Math.min(pct(r.committed), 100 - pct(spent))}%;background:var(--chart-1,#2f6fdf);opacity:.35"></span>` : '')
+ (over ? `<span style="position:absolute;top:-3px;bottom:-3px;left:100%;width:1px;background:var(--text,#111)"></span><span style="position:absolute;top:0;bottom:0;left:calc(100% + 2px);width:max(12px, ${pct(r.spent - r.planned)}%);border-radius:0 999px 999px 0;background:var(--negative,#c4242a)"></span>` : '');
track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`);
li.append(top, track); list.appendChild(li);
}
el.appendChild(list);
}
// Share against a target: one row per part, the current share as a bar and a 1px tick at the target,
// so drift reads at a glance (never two stacked bars to compare).
function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) {
el.innerHTML = '';
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums';
for (const r of rows) {
const drift = r.value - r.target;
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
li.innerHTML = `<span>${esc(r.label)}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${esc(format(r.value))}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}</span></span>`;
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
list.appendChild(li);
}
el.appendChild(list);
}
// shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline.
// A subline needs a 40-44px row pitch; one-line labels sit on 32px.
function rowLabel(g, s, el, r, { y0, pitch, lw }) {
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); }
else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink });
}
const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d));
// day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short
// label with today always shown and its neighbours left out, so no two headers ever touch
function dayHeads(g, s, el, days, { x0, cw, y, today }) {
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1)));
const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6;
const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw));
labs.forEach((t, i) => {
if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return;
text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted });
});
}
// The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one
// chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision).
function kindStyle(el, k = {}) {
const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf');
const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {};
const dash = k.dash ? { dash: '3 2' } : {};
if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash };
if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash };
if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash };
if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash };
return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash };
}
// Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind.
// rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }]
// and { group: 'Brand' } rows for a team subheader
// kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } }
// days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index;
// holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons.
// A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the
// days before it), and is always in the bar's tooltip.
function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) {
const draw = () => {
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30;
const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2;
const { s, w, h } = frame(el, height);
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2)));
const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw;
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf');
const g = svg('g', {}, s);
// weekends one tone step darker, faint day lines, the day header (today in ink)
for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g);
for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today });
for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); }
if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
let y0 = head;
for (const r of rows) {
if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; }
rowLabel(g, s, el, r, { y0, pitch, lw });
const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from);
bs.forEach((b, k) => {
const kind = kinds[b.kind] || {}, st = kindStyle(el, kind);
const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2;
const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g);
paintRect(grp, st, x0, by, x1 - x0, barHeight, 4);
const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink;
const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w;
// over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words
const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {};
if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo });
else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo });
else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted });
titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {});
if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); }
});
y0 += pitch;
}
s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`);
};
draw(); watch(el, draw); settle(draw);
const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind)));
const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } }));
if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
// Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare
// stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip.
// rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }]
// cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash)
function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) {
const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3);
const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; };
const draw = () => {
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26;
const { s, w, h } = frame(el, head + rows.length * pitch);
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22)));
const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111');
const g = svg('g', {}, s);
dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today });
rows.forEach((r, k) => {
const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw });
const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2;
r.values.forEach((v, i) => {
const cx = lw + i * cw + 1;
if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; }
const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3);
text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink });
titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {});
});
});
s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`);
};
draw(); watch(el, draw); settle(draw);
const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level)));
const ST = styles();
const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) }));
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
// When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and
// a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written.
// rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }]
// at or compare may be null (it did not sell out); step: minutes between axis labels (60).
function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) {
const o = minutes(open), c = minutes(close);
const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } };
const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent);
const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent);
const draw = () => {
const narrow = (el.clientWidth || 600) < 560, axisH = 24;
const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32);
const { s, w, h } = frame(el, rows.length * pitch + axisH);
const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26)));
const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0));
const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0);
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0');
const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16));
const g = svg('g', {}, s), plotH = rows.length * pitch;
// the axis: one step from the opening, the labels thinned by the same rule as the charts
const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m);
const picks = xPicks(marks.map(fmt.clock), x1 - x0);
for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); }
rows.forEach((r, k) => {
const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2;
if (narrow) {
const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0;
text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink });
if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 ');
if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted });
} else rowLabel(g, s, el, r, { y0, pitch, lw });
svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
const cm = minutes(r.compare), am = minutes(r.at);
if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {});
if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {});
if (narrow) return;
if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); }
else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
});
s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`);
};
draw(); watch(el, draw); settle(draw);
const items = [];
if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) });
if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) });
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
}
function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); }
window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt };
})();
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en" data-radius="soft">
<head>
<script>window.__tpGlMax = 16; /* a gallery shows many live effects at once; a page of a site keeps the default 4 (fx.js) */</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Component lab</title>
<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=Geist:wght@100..900&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../system.css">
<link rel="stylesheet" href="./base.css">
<style>
/* one component on its own, with a chosen effect (or object) and palette:
_lab.html?c=fx-chart&fx=ruled-grounds&palette=white-link-blue&w=640 */
:root { --font-display: "Geist", system-ui, sans-serif; --font-text: "Geist", system-ui, sans-serif; --font-mono: "Geist Mono", ui-monospace, monospace; --display-weight: 560; --display-tracking: -0.03em; }
body { min-height: 100vh; margin: 0; }
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); font: 13px/1.4 var(--font-text); color: var(--muted); }
.bar select { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-sm); height: 30px; padding: 0 8px; }
.stage { padding: clamp(16px, 4vw, 48px); margin-inline: auto; }
</style>
</head>
<body>
<div class="bar">
<label>Part <select id="c"></select></label>
<label>Effect <select id="fx"></select></label>
<label>Palette <select id="pal"></select></label>
</div>
<main class="stage" id="stage"></main>
<script type="module">
import fx from '../effects/fx.js';
const PARTS = ['fx-feature-card', 'fx-dither-object', 'fx-headline', 'fx-button', 'fx-prompt', 'fx-icon-tiles', 'fx-stat', 'fx-globe', 'fx-map', 'fx-chart', 'fx-bars', 'fx-band', 'fx-spotlight', 'fx-photo', 'fx-avatar', 'fx-search', 'fx-logos', 'fx-tabs', 'fx-pricing', 'fx-progress'];
const PALETTES = {
'blue-black-cornflower': { bg: '#0a0d14', surface: '#131824', ink: '#e9edf3', muted: '#8891a1', accent: '#5ab9fc', accentInk: '#0a0d14' },
'graphite-safety-orange': { bg: '#18191b', surface: '#222427', ink: '#eff0f1', muted: '#9c9ea3', accent: '#ff4a1f', accentInk: '#18191b' },
'white-link-blue': { bg: '#ffffff', surface: '#f4f4f4', ink: '#111111', muted: '#6b6b6b', accent: '#1a4be0', accentInk: '#ffffff' },
'moss-sand': { bg: '#ddd6c6', surface: '#d2cab8', ink: '#2a2a26', muted: '#59574e', accent: '#4f6238', accentInk: '#ffffff' },
'cobalt-field': { bg: '#0034eb', surface: '#002fd6', ink: '#ffffff', muted: '#dfe4f2', accent: '#e9e34a', accentInk: '#111111' },
};
const q = new URLSearchParams(location.search);
const part = PARTS.includes(q.get('c')) ? q.get('c') : PARTS[0];
const palId = PALETTES[q.get('palette')] ? q.get('palette') : 'blue-black-cornflower';
const p = PALETTES[palId];
const s = document.documentElement.style;
const action = fx.color.contrast(p.accent, p.bg) >= 2.2 ? p.accent : p.ink;
Object.entries({ '--p-bg': p.bg, '--p-surface': p.surface, '--p-ink': p.ink, '--p-muted': p.muted, '--p-accent': p.accent, '--p-accent-ink': p.accentInk, '--p-action': action, '--p-action-ink': action === p.accent ? p.accentInk : p.bg }).forEach(([k, v]) => s.setProperty(k, v));
s.colorScheme = fx.color.isDark(p.bg) ? 'dark' : 'light';
if (q.has('fallback')) fx.config.forceFallback = true;
// one part at a time: at most 4 live WebGL contexts on this page
fx.config.maxLive = 4;
if (q.has('w')) document.getElementById('stage').style.maxWidth = q.get('w') + 'px';
const base = `./${part}/`;
const [meta, html, mod] = await Promise.all([fetch(base + 'meta.json').then(r => r.json()), fetch(base + 'component.html').then(r => r.text()), import(base + 'component.js')]);
const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: base + 'component.css' });
document.head.append(link);
await new Promise(r => { link.onload = link.onerror = r; });
const choices = meta.effects?.length ? meta.effects : meta.objects || [];
const chosen = choices.includes(q.get('fx')) ? q.get('fx') : meta.default;
const stage = document.getElementById('stage');
stage.innerHTML = html;
const root = stage.firstElementChild;
// the chosen effect goes where the part reads it: data-cx-object, data-cx-fill, or data-cx-effect on the root
// the dither object: one figure set to the chosen object (one live object, as on a page); no caption under it
if (meta.objects) {
const fig = root.matches('[data-cx-object]') ? root : root.querySelector('[data-cx-object]');
fig.dataset.cxObject = chosen;
if (q.get('render') === 'studio') fig.dataset.cxRender = 'studio';
fig.querySelector('.cx-obj__stage').removeAttribute('aria-label');
fig.style.maxWidth = '720px';
}
// without ?fx= the markup keeps its own effects (the feature card mixes a landscape and a grain: setting
// the default on every panel would paint the grain panel as a landscape too)
else if (!choices.includes(q.get('fx'))) { /* as written */ }
else if (root.querySelector('[data-cx-fill]')) root.querySelectorAll('[data-cx-fill]').forEach(el => { el.dataset.cxFill = chosen; });
else if (root.querySelector('[data-cx-effect]')) root.querySelectorAll('[data-cx-effect]').forEach(el => { el.dataset.cxEffect = chosen; });
else root.dataset.cxEffect = chosen;
// the map draws its baked streets; an effect named with ?fx= shows its fallback ground (no map data) instead
if (part === 'fx-map' && choices.includes(q.get('fx'))) delete root.dataset.cxMap;
await document.fonts.ready;
window.__part = mod.default(root);
setTimeout(() => { document.documentElement.dataset.ready = '1'; }, 1200);
const fill = (sel, list, cur, key) => {
const el = document.getElementById(sel);
el.innerHTML = list.map(v => `<option${v === cur ? ' selected' : ''}>${v}</option>`).join('');
el.addEventListener('change', () => { q.set(key, el.value); if (key === 'c') q.delete('fx'); location.search = q.toString(); });
};
fill('c', PARTS, part, 'c');
// a part whose default is none (the map's streets, the photograph as it is) lists none first, so the
// select never shows an effect that is not on
fill('fx', meta.default === 'none' ? ['none', ...choices] : choices, chosen, 'fx');
fill('pal', Object.keys(PALETTES), palId, 'palette');
</script>
</body>
</html>
@@ -0,0 +1,142 @@
/*
taste-skill components - shared base. Link after system.css. Every component reads the page tokens
(--bg --surface --ink --muted --accent --accent-ink --action --line, the radius, type and space
scales) and adds only the few steps below. Classes start with cx-.
*/
.cx {
--cx-card: color-mix(in oklab, var(--ink) 3.5%, var(--bg));
--cx-panel: color-mix(in oklab, var(--ink) 6.5%, var(--bg));
--cx-raise: color-mix(in oklab, var(--ink) 9%, var(--bg));
--cx-line: color-mix(in oklab, var(--ink) 9%, transparent);
--cx-line-2: color-mix(in oklab, var(--ink) 16%, transparent);
--cx-hi: color-mix(in oklab, var(--ink) 7%, transparent);
--cx-soft: color-mix(in oklab, var(--muted) 72%, var(--bg));
--cx-shadow:
0 0 0 1px var(--cx-line),
0 1px 2px rgb(0 0 0 / .08),
0 8px 24px -8px rgb(0 0 0 / .22),
0 24px 56px -24px rgb(0 0 0 / .32);
--cx-ease: cubic-bezier(.2, .7, .2, 1);
--cx-mono: var(--font-mono, ui-monospace, monospace);
color: var(--ink);
font-family: var(--font-text);
}
.cx *, .cx *::before, .cx *::after { box-sizing: border-box; }
/* a card the system way, with a hairline top light so it does not read as a flat grey box */
.cx-card {
position: relative;
background: var(--cx-card);
border: 1px solid var(--cx-line);
border-radius: var(--r-lg);
box-shadow: inset 0 1px 0 var(--cx-hi);
}
.cx-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font: 500 var(--t-label)/1.4 var(--font-text); color: var(--muted); letter-spacing: .01em;
}
.cx-title { font: 500 var(--t-card)/1.3 var(--font-text); letter-spacing: -.01em; color: var(--ink); margin: 0; }
.cx-text { font-size: var(--t-small); line-height: 1.55; color: var(--muted); margin: 0; text-wrap: pretty; }
.cx-num { font-variant-numeric: tabular-nums; }
.cx-kbd {
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
border-radius: var(--r-sm); border: 1px solid var(--cx-line-2); background: var(--cx-panel);
font: 500 11px/1 var(--cx-mono); color: var(--muted);
}
.cx-sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* a mono icon from an SVG file (Simple Icons): takes the text colour */
.cx-glyph {
display: inline-block; width: var(--size, 20px); height: var(--size, 20px); flex: none;
background-color: currentColor;
-webkit-mask: var(--src) center / contain no-repeat; mask: var(--src) center / contain no-repeat;
}
/* ---------------------------------------------------------------- fill (see shared.js) */
/* the overlay is the next sibling of the filled element; fill() sets its left, top, width and height */
.cx-fill {
position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; user-select: none;
}
.cx-fill--screen { mix-blend-mode: screen; }
.cx-fill--multiply { mix-blend-mode: multiply; }
.cx-filled [data-fill]:not(.cx-knock *),
.cx-filled--all:not(.cx-knock),
.cx-filled--all :not(.cx-fill, .cx-fill *) { color: transparent; }
.cx-filled [data-fill="box"]:not(.cx-knock *) { background-color: transparent; }
.cx-knock {
position: absolute !important; inset: 0 !important; margin: 0 !important;
width: auto !important; height: auto !important; max-width: none !important;
transform: none !important; animation: none !important; transition: none !important;
}
.cx-knock, .cx-knock * {
background-color: transparent !important; border-color: transparent !important;
box-shadow: none !important; outline: none !important; text-shadow: none !important;
text-decoration-color: transparent !important; opacity: 1 !important; filter: none !important;
animation: none !important; transition: none !important;
}
.cx-knock canvas, .cx-knock video { visibility: hidden !important; }
.cx-knock img { visibility: hidden !important; }
.cx-knock [data-fill] img { visibility: visible !important; }
.cx-fill--screen .cx-knock { mix-blend-mode: multiply; background-color: #000 !important; }
.cx-fill--screen .cx-knock, .cx-fill--screen .cx-knock * { color: #000 !important; }
.cx-fill--screen .cx-knock--all, .cx-fill--screen .cx-knock--all *,
.cx-fill--screen .cx-knock [data-fill], .cx-fill--screen .cx-knock [data-fill] * { color: #fff !important; }
.cx-fill--screen .cx-knock [data-fill] img { filter: brightness(0) invert(1) !important; }
.cx-fill--multiply .cx-knock { mix-blend-mode: screen; background-color: #fff !important; }
.cx-fill--multiply .cx-knock, .cx-fill--multiply .cx-knock * { color: #fff !important; }
.cx-fill--multiply .cx-knock--all, .cx-fill--multiply .cx-knock--all *,
.cx-fill--multiply .cx-knock [data-fill], .cx-fill--multiply .cx-knock [data-fill] * { color: #000 !important; }
.cx-fill--multiply .cx-knock [data-fill] img { filter: brightness(0) !important; }
.cx-knock [data-fill="box"], .cx-knock .cx-glyph { background-color: currentColor !important; }
/* inside a filled box, its own glyphs do not punch holes */
.cx-knock [data-fill="box"] .cx-glyph { background-color: transparent !important; }
@media (forced-colors: active) {
.cx-fill { display: none; }
.cx-filled [data-fill], .cx-filled--all, .cx-filled--all * { color: CanvasText !important; }
}
/* ---------------------------------------------------------------- ring and glow */
/* an effect host shown only in its padding: a lit border of width --ring */
.cx-ring {
position: absolute; inset: calc(-1 * var(--ring, 1.5px)); padding: var(--ring, 1.5px);
border-radius: inherit; pointer-events: none; z-index: 0;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
/* an effect host blurred into a soft light behind an object */
.cx-glow {
position: absolute; pointer-events: none; z-index: -1;
filter: blur(var(--blur, 24px)); opacity: var(--glow, .5);
transition: opacity .5s var(--cx-ease);
}
/* ---------------------------------------------------------------- pause (see pauser() in shared.js) */
/* a solid disc in one corner of a looping visual, never a see-through blur; 32px in a 40px hit area */
.cx-pause {
--cx-pause-in: 12px;
position: absolute; z-index: 6; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
border-radius: 999px; border: 1px solid var(--cx-line-2, color-mix(in oklab, var(--ink) 16%, transparent));
background: var(--bg); color: var(--ink); cursor: pointer;
box-shadow: 0 1px 2px rgb(0 0 0 / .12), 0 4px 12px -4px rgb(0 0 0 / .25);
opacity: .82; transition: opacity .2s var(--cx-ease, ease), border-color .2s var(--cx-ease, ease);
}
.cx-pause::before { content: ""; position: absolute; inset: -4px; border-radius: inherit; }
.cx-pause:hover, .cx-pause:focus-visible { opacity: 1; border-color: color-mix(in oklab, var(--ink) 30%, transparent); }
.cx-pause:focus-visible { outline: 2px solid var(--action, var(--accent)); outline-offset: 2px; }
.cx-pause svg { fill: currentColor; }
.cx-pause__on, .cx-pause.is-paused .cx-pause__off { display: none; }
.cx-pause.is-paused .cx-pause__on { display: block; margin-left: 1px; }
.cx-pause--br { right: var(--cx-pause-in); bottom: var(--cx-pause-in); }
.cx-pause--bl { left: var(--cx-pause-in); bottom: var(--cx-pause-in); }
.cx-pause--tr { right: var(--cx-pause-in); top: var(--cx-pause-in); }
.cx-pause--tl { left: var(--cx-pause-in); top: var(--cx-pause-in); }
.cx-pause--inline { position: relative; flex: none; margin-left: auto; width: 28px; height: 28px; box-shadow: none; }
.cx-paused, .cx-paused *, .cx-paused *::before, .cx-paused *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) { .cx-pause { display: none; } }
@media (forced-colors: active) { .cx-pause { border-color: ButtonText; } }
@@ -0,0 +1,42 @@
/* fx-avatar: the speaker's avatar ring is the effect, with its own soft glow; it moves to whoever speaks */
.cx-call { --ava: 64px; --pad: 16px; display: grid; gap: var(--s-6); padding: var(--s-6); width: min(100%, 460px); isolation: isolate; }
.cx-call__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-4); }
.cx-call__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
.cx-call__meta { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
.cx-call__live {
display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; border-radius: 999px;
border: 1px solid var(--cx-line-2); font-size: 12px; font-weight: 500;
}
.cx-call__live span { width: 6px; height: 6px; border-radius: 9px; background: var(--accent); animation: cx-call-blink 2.4s ease-in-out infinite; }
@keyframes cx-call-blink { 50% { opacity: .35; } }
.cx-call__people { display: flex; justify-content: space-between; gap: var(--s-3); margin: 0; padding: 0; list-style: none; }
.cx-call__person { position: relative; display: grid; justify-items: center; gap: 10px; }
.cx-call__ava {
position: relative; z-index: 2; display: grid; place-items: center; width: var(--ava); height: var(--ava); border-radius: 999px;
background: color-mix(in oklab, var(--accent) var(--a), var(--cx-raise)); color: var(--ink);
font: 500 15px/1 var(--font-text); letter-spacing: .02em;
box-shadow: inset 0 0 0 1px var(--cx-line);
}
.cx-call__name { font-size: var(--t-label); color: var(--muted); transition: color .3s var(--cx-ease); }
.cx-call__person.is-speaking .cx-call__name { color: var(--ink); }
.cx-call__fx {
position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; border-radius: 999px;
width: calc(var(--ava) + 2 * var(--pad)); height: calc(var(--ava) + 2 * var(--pad));
transform: translate(var(--fx-x, -999px), var(--fx-y, 0));
transition: transform .55s cubic-bezier(.3, .9, .3, 1);
/* a 2.5px ring 2px off the avatar, then a soft glow fading out */
-webkit-mask: radial-gradient(closest-side, transparent calc(100% - var(--pad) + 1.5px), #000 calc(100% - var(--pad) + 2.5px), #000 calc(100% - var(--pad) + 4.5px), rgb(0 0 0 / .32) calc(100% - var(--pad) + 5.5px), transparent 100%);
mask: radial-gradient(closest-side, transparent calc(100% - var(--pad) + 1.5px), #000 calc(100% - var(--pad) + 2.5px), #000 calc(100% - var(--pad) + 4.5px), rgb(0 0 0 / .32) calc(100% - var(--pad) + 5.5px), transparent 100%);
}
.cx-call__status { display: flex; align-items: center; gap: 10px; margin: 0; font-size: var(--t-small); color: var(--muted); }
.cx-call__status [data-speaker] { color: var(--ink); font-weight: 500; margin-right: -4px; }
.cx-call__eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; }
.cx-call__eq i { width: 3px; height: 100%; border-radius: 2px; background: var(--accent); transform-origin: bottom; animation: cx-call-eq 1.1s ease-in-out infinite; }
.cx-call__eq i:nth-child(2) { animation-delay: -.35s; }
.cx-call__eq i:nth-child(3) { animation-delay: -.7s; }
@keyframes cx-call-eq { 0%, 100% { transform: scaleY(.35); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) {
.cx-call__live span, .cx-call__eq i { animation: none; }
.cx-call__fx { transition: none; }
}
@media (max-width: 459px) { .cx-call { --ava: 52px; --pad: 14px; padding: var(--s-5); } }
@@ -0,0 +1,17 @@
<article class="cx cx-card cx-call" data-cx-seed="11" aria-label="Call: Design review">
<header class="cx-call__head">
<div>
<h3 class="cx-call__title">Design review</h3>
<p class="cx-call__meta">4 people, started 12 minutes ago</p>
</div>
<span class="cx-call__live"><span aria-hidden="true"></span>Live</span>
</header>
<ul class="cx-call__people">
<li class="cx-call__person is-speaking"><span class="cx-call__ava" style="--a: 38%" aria-hidden="true">MO</span><span class="cx-call__name">Maya</span></li>
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 0%" aria-hidden="true">JK</span><span class="cx-call__name">Jonas</span></li>
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 18%" aria-hidden="true">AR</span><span class="cx-call__name">Amara</span></li>
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 8%" aria-hidden="true">LT</span><span class="cx-call__name">Leo</span></li>
</ul>
<p class="cx-call__status"><span class="cx-call__eq" aria-hidden="true"><i></i><i></i><i></i></span><span data-speaker>Maya</span> is speaking</p>
<div class="cx-call__fx" aria-hidden="true"></div>
</article>
@@ -0,0 +1,55 @@
// fx-avatar: one small effect host shaped by a radial mask into a ring with a soft glow; it sits on the
// speaking person's avatar and glides to the next speaker every few seconds (the card never moves).
// A pause button at the end of the status line holds the ring, the turns and the level bars.
import { mount, palette, accentPalette, color, whileVisible, bag, pauser, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'gradient-mesh': { speed: 0.35, lines: 0, warp: 0.8, softness: 0.4, tint: 1, calm: 0 },
'silk-folds': { speed: 0.3, folds: 0.7, sheen: 0.7, scale: 1.2, grain: 0, calm: 0 },
'aurora-curtains': { speed: 0.25, bands: 3, height: 0.8, intensity: 1.6, stars: 0, calm: 0 },
'grain-gradient': { speed: 1, softness: 0.5, warp: 0.7, grain: 0.3, grainMotion: 0, tint: 1, contrast: 0.8, calm: 0 },
};
export default function init(root) {
const b = bag();
const host = root.querySelector('.cx-call__fx');
const people = [...root.querySelectorAll('.cx-call__person')];
const name = root.querySelector('[data-speaker]');
const page = palette(root);
const ring = accentPalette(page, { min: 1, lift: 0.2 });
const p = b.add(pauser(root, { label: 'the call preview', into: root.querySelector('.cx-call__status') }));
const effect = root.dataset.cxEffect || 'gradient-mesh';
b.add(p.add(mount(host, effect, {
palette: { ...ring, bg: color.mix(page.accent, page.ink, 0.25) },
seed: root.dataset.cxSeed,
params: tuned(PARAMS, effect, { speed: 0.3, calm: 0 }),
})));
let i = Math.max(0, people.findIndex(p => p.classList.contains('is-speaking')));
const place = () => {
const ava = people[i].querySelector('.cx-call__ava');
const r = root.getBoundingClientRect(), a = ava.getBoundingClientRect();
const pad = (host.offsetWidth - a.width) / 2;
root.style.setProperty('--fx-x', `${(a.left - r.left - pad).toFixed(1)}px`);
root.style.setProperty('--fx-y', `${(a.top - r.top - pad).toFixed(1)}px`);
};
const speak = n => {
people[i].classList.remove('is-speaking');
i = n;
people[i].classList.add('is-speaking');
name.textContent = people[i].querySelector('.cx-call__name').textContent;
place();
};
// the first placement without a glide
host.style.transition = 'none';
place();
requestAnimationFrame(() => requestAnimationFrame(() => { host.style.transition = ''; }));
const ro = new ResizeObserver(place);
ro.observe(root);
b.add(() => ro.disconnect());
const order = [0, 2, 1, 0, 3, 2];
let step = 0;
b.add(whileVisible(root, { every: 3400, run: () => { step = (step + 1) % order.length; speak(order[step] % people.length); } }));
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-avatar",
"name": "Speaker ring",
"for": "A call, presence or team card: the active person's avatar gets a thin ring of the effect with its own soft glow, and the ring glides to whoever speaks next.",
"effects": ["gradient-mesh", "silk-folds", "aurora-curtains", "grain-gradient"],
"default": "gradient-mesh",
"how": "One small effect host (avatar plus 16px) shaped by a radial mask into a 2.5px ring and a fading glow; it moves by transform, so one canvas serves every avatar.",
"rules": ["One lit ring at a time.", "The ring moves only on a state change (a new speaker, a status), never on a timer faster than 3 s.", "Avatars are the people's own photos or initials, never stock faces."],
"a11y": "The speaker is named in text next to the bars; the ring and bars are decorative. Reduced motion holds the first speaker and stops the bars."
}
@@ -0,0 +1,22 @@
/* fx-band: a divider band between two sections, its effect kept to the far side of the headline */
.cx-band {
position: relative; isolation: isolate; overflow: hidden; display: flex; align-items: center;
min-height: 320px; padding: var(--s-7) var(--s-8); border-radius: var(--r-lg);
background: var(--cx-card); border: 1px solid var(--cx-line);
}
/* the effect lives in the right part and fades in from the copy's side; the script sets its left edge
48px past the copy's widest line (this 52% is the picture before it runs) */
.cx-band__fx {
position: absolute; top: 0; right: 0; bottom: 0; left: 52%; z-index: 0;
-webkit-mask: linear-gradient(90deg, transparent, #000 22%); mask: linear-gradient(90deg, transparent, #000 22%);
}
.cx-band__copy { position: relative; z-index: 1; display: grid; justify-items: start; gap: var(--s-3); max-width: 460px; }
.cx-band__title { font-size: var(--t-h2); line-height: 1.08; margin: 0; }
.cx-band__text { color: var(--muted); font-size: var(--t-body); line-height: 1.55; margin: 0 0 var(--s-2); max-width: 40ch; }
@media (max-width: 899px) {
.cx-band { padding: var(--s-6); min-height: 460px; align-items: flex-start; }
.cx-band__fx { left: 0; top: 46%; -webkit-mask: linear-gradient(180deg, transparent, #000 30%); mask: linear-gradient(180deg, transparent, #000 30%); }
}
@media (max-width: 639px) {
.cx-band { padding: var(--s-5); min-height: 480px; }
}
@@ -0,0 +1,9 @@
<section class="cx cx-band" data-cx-seed="6" aria-labelledby="cx-band-title">
<div class="cx-band__fx" aria-hidden="true"></div>
<div class="cx-band__copy">
<span class="cx-eyebrow">Reporting</span>
<h2 class="cx-band__title" id="cx-band-title">See every payment in one ledger</h2>
<p class="cx-band__text">Filter by customer, currency or date, then export to CSV or your accounting tool.</p>
<a class="link" href="#">How reporting works</a>
</div>
</section>
@@ -0,0 +1,51 @@
// fx-band: a quiet line effect on the band's far side (topographic contours by default), softer than
// the ink so the headline stays the strongest thing in the band. On a wide band the effect starts 48px
// past the end of the copy (measured, so a longer line of text pushes it right); stacked, it sits under
// the copy. Every effect here is still: a drift so slow that nobody sees it is not worth a frame loop
// on every screen, so the band is drawn once, kept as a picture (no WebGL context at rest) and needs no
// pause button.
import { still, palette, tone, color, bag } from '../shared.js';
const GAP = 48;
// every effect the band takes, tuned quiet: thin lines, no accent floods, no calm hole (the host is
// already kept off the copy), no motion. ridgeline only when the page names it (it reads as a record cover).
const PARAMS = {
'contour-lines': { levels: 20, scale: 1.3, lineWidth: 1, indexEvery: 5, opacity: 0.42, speed: 0, tint: 0.45, shade: 0.25, edge: 0.35, calm: 0 },
'line-engraving': { pitch: 6, source: 1, cross: 0.3, drift: 0, size: 1.1, calm: 0 },
'dot-terrain': { spacing: 12, dot: 2.2, speed: 0, opacity: 0.7, crest: 0.6, haze: 0.2, calm: 0 },
'soft-light-bars': { speed: 0, glow: 0.6, haze: 0.3, grain: 0.4, calm: 0 },
ridgeline: { lines: 30, peak: 0.55, envelope: 0.36, span: 0.9, focus: 0.58, lineWidth: 1, speed: 0, calm: 0 },
};
export default function init(root) {
const b = bag();
const host = root.querySelector('.cx-band__fx');
const copy = root.querySelector('.cx-band__copy');
const page = palette(root);
const card = tone(page, 0.035);
// the copy's right edge is its widest line, not its max-width box
const place = () => {
if (getComputedStyle(root).flexDirection === 'column' || root.clientWidth < 900) { host.style.left = ''; return; }
const rr = root.getBoundingClientRect();
let right = 0;
for (const n of copy.children) {
const range = document.createRange();
range.selectNodeContents(n);
for (const r of range.getClientRects()) right = Math.max(right, r.right);
}
host.style.left = `${Math.round(right - rr.left + GAP)}px`;
};
place();
const ro = new ResizeObserver(place);
ro.observe(root);
document.fonts?.ready.then(place);
b.add(() => { ro.disconnect(); host.style.left = ''; });
const effect = root.dataset.cxEffect || 'contour-lines';
b.add(still(host, effect, {
palette: { ...page, bg: card, ink: color.mix(page.ink, card, 0.35) },
seed: root.dataset.cxSeed,
// thin lines need more strength on a dark ground to read at all
params: { ...(PARAMS[effect] || { calm: 0 }), ...(effect === 'contour-lines' && color.isDark(page.bg) ? { opacity: 0.62 } : {}), speed: 0, drift: 0 },
}));
return b;
}
@@ -0,0 +1,22 @@
{
"id": "fx-band",
"name": "Divider band",
"for": "A short band between two sections that names the next part of the page: an eyebrow, one headline, one line and a link, with quiet topographic lines on the far side.",
"effects": [
"contour-lines",
"line-engraving",
"dot-terrain",
"soft-light-bars",
"ridgeline"
],
"default": "contour-lines",
"how": "The effect host starts 48px past the end of the copy's widest line (measured) and fades in from that side; stacked, it sits under the copy. Each effect has quiet settings in component.js, all still: the band is drawn once and kept as a picture (no WebGL context and no frames at rest), so it needs no pause button.",
"rules": [
"At most one band per page, never two in a row.",
"Quiet line or dot effects only: no blobs or bokeh.",
"ridgeline only when the page names it (alone it reads as a record cover).",
"No line starts under the text: keep 48px clear after the copy.",
"The band never moves with scroll."
],
"a11y": "A labelled section with a real heading and link; the lines are decorative and still."
}
@@ -0,0 +1,48 @@
/* fx-bars: a bar chart on a labelled scale whose bars are windows onto one still, low-contrast grain in the accent */
.cx-bars { display: grid; gap: var(--s-5); padding: var(--s-6); min-width: 0; }
.cx-bars__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-5); flex-wrap: wrap; }
.cx-bars__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
.cx-bars__sub { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
.cx-bars__total { display: grid; justify-items: end; gap: 4px; }
.cx-bars__label { font-size: var(--t-label); color: var(--muted); }
.cx-bars__value { font: 560 32px/1 var(--font-display); letter-spacing: -0.03em; }
.cx-bars__delta { font-size: var(--t-label); color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
/* the scale sits in a 40px column left of the plot; its rules run under the bars */
.cx-bars__chart { --scale-w: 40px; display: grid; grid-template-columns: var(--scale-w) minmax(0, 1fr); row-gap: var(--s-3); }
.cx-bars__scale { position: relative; grid-row: 1; grid-column: 1 / -1; pointer-events: none; }
.cx-bars__scale span { position: absolute; left: var(--scale-w); right: 0; height: 0; border-top: 1px dashed var(--cx-line); }
.cx-bars__scale span.is-base { border-top: 1px solid var(--cx-line-2); }
.cx-bars__scale span::before {
content: attr(data-v); position: absolute; right: calc(100% + 8px); top: 0; transform: translateY(-50%);
font: 400 11px/1 var(--cx-mono); color: var(--muted); white-space: nowrap;
}
.cx-bars__plot { position: relative; isolation: isolate; grid-row: 1; grid-column: 2; height: 220px; }
.cx-bars__cols { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: clamp(4px, 1.2%, 10px); }
.cx-bars__col { position: relative; flex: 1; height: 100%; display: flex; align-items: flex-end; }
.cx-bars__bar {
position: relative; width: 100%; height: var(--h); border-radius: var(--r-sm) var(--r-sm) 0 0;
background: var(--accent);
}
.cx-bars.is-live .cx-bars__bar { background: transparent; }
/* the latest month is marked by a thin ring set off from the bar (outside the effect window), never by
veiling the others: a veil of the card colour turns a warm accent muddy on a coloured ground */
.cx-bars__col:last-child .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--accent); }
.cx-bars__col:hover .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--cx-line-2); }
.cx-bars__col:last-child:hover .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--accent); }
/* a bar's value shows on hover; October's is in the header, so its tag stays hidden */
.cx-bars__tag {
position: absolute; left: 50%; bottom: calc(var(--h) + 8px); transform: translateX(-50%); z-index: 3;
font: 500 11px/1 var(--cx-mono); color: var(--ink); white-space: nowrap; opacity: 0; transition: opacity .15s var(--cx-ease);
padding: 3px 5px; border-radius: var(--r-sm); background: var(--cx-card);
}
.cx-bars__col:not(:last-child):hover .cx-bars__tag { opacity: 1; }
.cx-bars__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.cx-bars__axis { grid-row: 2; grid-column: 2; display: flex; gap: clamp(4px, 1.2%, 10px); font: 400 11px/1 var(--cx-mono); color: var(--muted); }
.cx-bars__axis span { flex: 1; text-align: center; }
@media (max-width: 639px) {
.cx-bars { padding: var(--s-5); }
.cx-bars__total { justify-items: start; }
.cx-bars__plot { height: 180px; }
.cx-bars__chart { --scale-w: 36px; }
}
@@ -0,0 +1,22 @@
<article class="cx cx-card cx-bars" data-cx-seed="4">
<header class="cx-bars__head">
<div>
<h3 class="cx-bars__title">Monthly revenue</h3>
<p class="cx-bars__sub">Nov 2025 to Oct 2026</p>
</div>
<div class="cx-bars__total">
<span class="cx-bars__label">October</span>
<span class="cx-bars__value cx-num">$91.2k</span>
<span class="cx-bars__delta">+9.7% on September</span>
</div>
</header>
<div class="cx-bars__chart">
<div class="cx-bars__scale" aria-hidden="true"></div>
<div class="cx-bars__plot" aria-hidden="true">
<div class="cx-bars__cols"><div class="cx-bars__col" style="--h:42.1%"><span class="cx-bars__tag">$42.1k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:47.8%"><span class="cx-bars__tag">$47.8k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:51.2%"><span class="cx-bars__tag">$51.2k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:49.4%"><span class="cx-bars__tag">$49.4k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:57.9%"><span class="cx-bars__tag">$57.9k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:63.3%"><span class="cx-bars__tag">$63.3k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:61.0%"><span class="cx-bars__tag">$61.0k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:69.8%"><span class="cx-bars__tag">$69.8k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:75.6%"><span class="cx-bars__tag">$75.6k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:74.2%"><span class="cx-bars__tag">$74.2k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:83.1%"><span class="cx-bars__tag">$83.1k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:91.2%"><span class="cx-bars__tag">$91.2k</span><div class="cx-bars__bar"></div></div></div>
<div class="cx-bars__fx"></div>
</div>
<div class="cx-bars__axis" aria-hidden="true"></div>
</div>
<table class="cx-sr" data-table><caption>Revenue by month, thousands of dollars</caption><tbody></tbody></table>
</article>
@@ -0,0 +1,35 @@
// fx-bars: one effect canvas over the plot, masked to the bars, on a 0 / 50k / 100k scale. The effect
// is a still, low-contrast grain in the accent (no motion, no streaks), so the bars read as one flat
// printed material, drawn once and kept as a picture (no WebGL context at rest). The latest month
// carries a ring; its value is the header figure.
import { still, palette, accentPalette, windows, bag } from '../shared.js';
// revenue by month in thousands of dollars (sample data: replace with the product's own)
const DATA = [42.1, 47.8, 51.2, 49.4, 57.9, 63.3, 61.0, 69.8, 75.6, 74.2, 83.1, 91.2];
const MONTHS = ['N', 'D', 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O'];
const FULL = ['Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'];
const MAX = 100;
const TICKS = [[100, '100k'], [50, '50k'], [0, '0']];
export default function init(root) {
const b = bag();
const cols = root.querySelector('.cx-bars__cols');
const host = root.querySelector('.cx-bars__fx');
const page = palette(root);
cols.innerHTML = DATA.map(v => `<div class="cx-bars__col" style="--h:${(v / MAX * 100).toFixed(1)}%"><span class="cx-bars__tag">$${v.toFixed(1)}k</span><div class="cx-bars__bar"></div></div>`).join('');
root.querySelector('.cx-bars__scale').innerHTML = TICKS.map(([v, l]) => `<span class="${v ? '' : 'is-base'}" data-v="${l}" style="top:${(1 - v / MAX) * 100}%"></span>`).join('');
root.querySelector('.cx-bars__axis').innerHTML = MONTHS.map(m => `<span>${m}</span>`).join('');
root.querySelector('[data-table] tbody').innerHTML = DATA.map((v, i) => `<tr><th>${FULL[i]}</th><td>${v.toFixed(1)}</td></tr>`).join('');
const effect = root.dataset.cxEffect || 'grain-gradient';
if (effect === 'flat') return b; // the plain accent bars
const bars = [...root.querySelectorAll('.cx-bars__bar')];
b.add(windows(host, bars));
const s = b.add(still(host, effect, {
palette: accentPalette(page, { min: 1, lift: 0.05 }),
seed: root.dataset.cxSeed,
params: { speed: 0, grainMotion: 0, grain: 0.3, contrast: 0.16, tint: 0.35, softness: 0.85, warp: 0.25, calm: 0 },
}));
s.ready.then(() => root.classList.add('is-live'));
b.add(() => root.classList.remove('is-live'));
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-bars",
"name": "Bar chart with grain bars",
"for": "A revenue or usage card: every bar is a window onto one still, low-contrast grain in the accent, so the bars read as one printed material while their heights stay exact on a labelled scale.",
"effects": ["grain-gradient", "flat"],
"default": "grain-gradient",
"how": "windows(): one effect host over the plot, masked to the bar boxes, in a palette within a few steps of the accent; the grain is still (speed 0, grainMotion 0). A 0 / 50k / 100k scale sits left of the plot. The latest bar carries a thin ring and its value is the header figure; hover shows any other value. 'flat' mounts nothing.",
"rules": ["Bars start at zero on a labelled scale.", "The header names the month it shows, and the change matches the data.", "Mark the latest bar with a ring, never by veiling the others.", "No glass, satin or metal streaks in the bars, and no motion inside them.", "Real numbers. Invented ones are placeholders covered by the page's one placeholder note and listed in the report, never a sample tag in the chart."],
"a11y": "The plot is decorative; a hidden table holds every month and value. The effect is still, so reduced motion changes nothing."
}
@@ -0,0 +1,26 @@
/* fx-button: the system's buttons, with an effect in the fill (primary) or the border (secondary) on hover and focus */
.cx-btns { display: grid; justify-items: center; gap: var(--s-5); text-align: center; }
.cx-btns__row { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-3); }
.cx-fxbtn { position: relative; isolation: isolate; }
.cx-fxbtn > :not(.cx-fxbtn__fx) { position: relative; z-index: 1; }
/* the fill: the effect fades in from the button's middle outward, so it never sits as a smudge at one end */
.cx-fxbtn__fx {
position: absolute; inset: 0; z-index: 0; border-radius: inherit; overflow: hidden;
opacity: 0; transition: opacity .45s var(--cx-ease); pointer-events: none;
}
.cx-fxbtn__fx:not(.cx-ring) {
-webkit-mask: radial-gradient(120% 140% at 50% 50%, #000 40%, rgb(0 0 0 / .55) 100%);
mask: radial-gradient(120% 140% at 50% 50%, #000 40%, rgb(0 0 0 / .55) 100%);
}
.cx-fxbtn__fx.cx-ring { --ring: 1.5px; inset: calc(-1px - var(--ring) + 1px); border-radius: calc(var(--r-btn) + 1px); overflow: visible; }
.cx-fxbtn.is-lit .cx-fxbtn__fx { opacity: 1; }
.cx-fxbtn--edge.is-lit:has(.fx-canvas[style*="opacity: 1"]) { border-color: transparent; }
.cx-fxbtn__arrow { transition: transform var(--dur) var(--ease); }
.cx-fxbtn:hover .cx-fxbtn__arrow, .cx-fxbtn:focus-visible .cx-fxbtn__arrow { transform: translateX(2px); }
/* the system's hover darkening would fight the light: keep the fill */
.cx-fxbtn.btn--primary:hover { background: var(--action); }
@media (max-width: 639px) {
.cx-btns { justify-items: stretch; }
.cx-btns__row { flex-direction: column; align-items: stretch; }
}
@@ -0,0 +1,13 @@
<div class="cx cx-btns">
<div class="cx-btns__row">
<a class="btn btn--primary cx-fxbtn" href="#" data-cx-btn="grain-gradient" data-cx-seed="31">
<span class="cx-fxbtn__fx" aria-hidden="true"></span>
<span>Start for free</span>
<svg class="cx-fxbtn__arrow" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3 8h9.5M8.5 3.5 13 8l-4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
<a class="btn btn--secondary cx-fxbtn cx-fxbtn--edge" href="#" data-cx-btn="gradient-mesh" data-cx-seed="8" data-cx-params='{"speed":0.25,"lines":0,"tint":1,"calm":0}'>
<span class="cx-fxbtn__fx cx-ring" aria-hidden="true"></span>
<span>Book a demo</span>
</a>
</div>
</div>
@@ -0,0 +1,70 @@
// fx-button: an effect fades into the button's fill or border on hover and focus, and runs only then.
// At rest a button holds no WebGL context: the effect is mounted on the first hover or focus, paused
// half a second after the pointer leaves and removed (its context freed) a few seconds later.
import { mount, palette, accentPalette, bag, paramsOf, color } from '../shared.js';
// the fill is even across the button: low contrast, no blobs that settle at one end, a fine grain that
// moves while it is pointed at (the button's own params can override)
const FILL = { speed: 0.35, tint: 0.22, contrast: 0.1, grain: 0.3, softness: 1, scatter: 0, warp: 0.15, grainMotion: 1, calm: 0 };
export default function init(root) {
const b = bag();
root.querySelectorAll('[data-cx-btn]').forEach(btn => {
const host = btn.querySelector('.cx-fxbtn__fx');
const page = palette(btn);
const edge = btn.classList.contains('cx-fxbtn--edge');
// the fill is one even step off the accent (lighter under a dark label, deeper under a light one;
// the label keeps 4.5:1) with a fine grain; the edge is drawn brighter than the ground so it reads
// as light
const ap = accentPalette(page, { lift: 0.07 });
const pal = edge
? { ...accentPalette(page, { min: 1, lift: 0.14 }), bg: color.mix(page.accent, page.bg, 0.15) }
: { ...ap, bg: ap.accent };
const params = edge ? paramsOf(btn) : { ...FILL, ...(paramsOf(btn) || {}) };
let ctrl = null, pending = null, dead = false, pauseT = 0, freeT = 0;
const lit = () => btn.classList.contains('is-lit');
const ensure = () => {
if (ctrl || pending) return;
pending = mount(host, btn.dataset.cxBtn, { palette: pal, seed: btn.dataset.cxSeed, params })
.then(c => {
pending = null;
if (dead) { c.destroy(); return; }
ctrl = c;
if (!lit()) c.pause();
}, err => { pending = null; console.warn('fx-button:', err); });
};
const free = () => { ctrl?.destroy(); ctrl = null; };
const on = () => {
clearTimeout(pauseT);
clearTimeout(freeT);
btn.classList.add('is-lit');
ensure();
ctrl?.resume();
};
const off = () => {
if (btn.matches(':hover') || btn.matches(':focus-visible')) return;
btn.classList.remove('is-lit');
clearTimeout(pauseT);
clearTimeout(freeT);
pauseT = setTimeout(() => ctrl?.pause(), 500);
freeT = setTimeout(free, 4000);
};
const focus = () => { if (btn.matches(':focus-visible')) on(); };
btn.addEventListener('pointerenter', on);
btn.addEventListener('pointerleave', off);
btn.addEventListener('focus', focus);
btn.addEventListener('blur', off);
b.add(() => {
dead = true;
clearTimeout(pauseT);
clearTimeout(freeT);
free();
btn.classList.remove('is-lit');
btn.removeEventListener('pointerenter', on);
btn.removeEventListener('pointerleave', off);
btn.removeEventListener('focus', focus);
btn.removeEventListener('blur', off);
});
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-button",
"name": "Effect button",
"for": "The one primary call to action: on hover and focus a slow effect fades into its fill; a secondary button can carry the effect in its border instead.",
"effects": ["grain-gradient", "gradient-mesh", "film-light-leak", "caustics", "soft-light-bars"],
"default": "grain-gradient",
"how": "An effect host inside the button (fill) or a cx-ring host around it (border), in a palette within a few steps of the accent; the fill is centred (calm 0, low scatter, a soft radial mask) and low in contrast. Mounted paused, it runs only while pointed at or focused.",
"rules": ["Only the primary call to action, or one button pair, per page.", "The label keeps 4.5:1 on every frame of the fill.", "The fill is even across the button: no bright patch at one end.", "Nothing moves until hover or focus.", "Demo notes ('hover to see it') belong to the showcase, never to the component markup."],
"a11y": "A real link with its own text; the effect host is aria-hidden. Keyboard focus lights it as hover does. Reduced motion shows a still frame."
}
@@ -0,0 +1,47 @@
/* fx-chart: an area chart with a soft accent fade (or a still dot ruling) under the line; the chart keeps one grid, its own */
.cx-chart { display: grid; gap: var(--s-5); padding: var(--s-6); min-width: 0; }
.cx-chart__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-5); flex-wrap: wrap; }
.cx-chart__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
.cx-chart__sub { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
.cx-chart__total { display: grid; justify-items: end; gap: 4px; }
.cx-chart__label { font-size: var(--t-label); color: var(--muted); }
.cx-chart__value { font: 560 32px/1 var(--font-display); letter-spacing: -0.03em; }
.cx-chart__delta { font-size: var(--t-label); color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
.cx-chart__plot { position: relative; height: 220px; outline-offset: 6px; cursor: crosshair; }
.cx-chart__grid { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.cx-chart__grid span { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed var(--cx-line-2); }
.cx-chart__grid span:last-child { border-top-style: solid; }
.cx-chart__grid span::after {
content: attr(data-v); position: absolute; left: 0; bottom: 6px; font: 400 11px/1 var(--cx-mono); color: var(--muted);
}
.cx-chart__grid span:last-child::after { display: none; }
.cx-chart__area { position: absolute; inset: 0; z-index: 0; }
/* the plain choice (and the picture before the script): an accent area fading to the card */
.cx-chart__area:not([data-fx-mounted]) { background: linear-gradient(to bottom, color-mix(in oklab, var(--accent) 26%, transparent), transparent); }
.cx-chart__line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; z-index: 2; }
.cx-chart__line path { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
.cx-chart__cursor {
position: absolute; top: 0; bottom: 0; width: 1px; z-index: 3; background: var(--cx-line-2);
opacity: 0; transition: opacity .15s var(--cx-ease); pointer-events: none;
}
.cx-chart__dot {
position: absolute; left: -5px; width: 11px; height: 11px; border-radius: 99px; background: var(--accent);
border: 2px solid var(--cx-card); box-shadow: 0 0 0 1px var(--accent);
}
/* pinned inside the plot, 14px to the side of the point (left of it on the right half) */
.cx-chart__tip {
position: absolute; top: 0; z-index: 4; transform: translateX(14px); white-space: nowrap;
height: 30px; display: inline-flex; align-items: center; padding: 0 10px;
border-radius: var(--r-sm); background: var(--bg); box-shadow: var(--cx-shadow);
font-size: 12px; color: var(--ink); opacity: 0; transition: opacity .15s var(--cx-ease); pointer-events: none;
}
.cx-chart__tip.is-left { transform: translateX(calc(-100% - 14px)); }
.cx-chart__tip b { font-weight: 500; font-variant-numeric: tabular-nums; }
.cx-chart__tip span { color: var(--muted); margin-right: 8px; }
.cx-chart.is-reading .cx-chart__cursor, .cx-chart.is-reading .cx-chart__tip { opacity: 1; }
.cx-chart__axis { display: flex; justify-content: space-between; font: 400 11px/1 var(--cx-mono); color: var(--muted); margin-top: calc(-1 * var(--s-3)); }
@media (max-width: 639px) {
.cx-chart { padding: var(--s-5); }
.cx-chart__total { justify-items: start; }
.cx-chart__plot { height: 180px; }
}
@@ -0,0 +1,25 @@
<article class="cx cx-card cx-chart" data-cx-seed="12">
<header class="cx-chart__head">
<div>
<h3 class="cx-chart__title">API requests</h3>
<p class="cx-chart__sub">Last 14 days, all regions</p>
</div>
<div class="cx-chart__total">
<span class="cx-chart__label">Total</span>
<span class="cx-chart__value cx-num" data-total>27.8M</span>
<span class="cx-chart__delta">+12.4% on the 14 days before</span>
</div>
</header>
<div class="cx-chart__plot" tabindex="0" role="img" aria-label="Area chart of requests per day for the last 14 days. Use the left and right arrow keys to read each day.">
<div class="cx-chart__grid" aria-hidden="true"></div>
<div class="cx-chart__area" aria-hidden="true"></div>
<svg class="cx-chart__line" aria-hidden="true" preserveAspectRatio="none"><path/></svg>
<div class="cx-chart__cursor" aria-hidden="true"><span class="cx-chart__dot"></span></div>
<div class="cx-chart__tip" aria-live="polite"></div>
</div>
<div class="cx-chart__axis" aria-hidden="true"></div>
<table class="cx-sr" data-table>
<caption>Requests per day</caption>
<tbody></tbody>
</table>
</article>
@@ -0,0 +1,95 @@
// fx-chart: the area under the line is a soft accent fade (the default, plain CSS) or a window onto a
// still, fine dot ruling in the accent ('ruled-grounds'); the line, the chart's own grid and the reading
// stay crisp. A halftone screen was tried here and rejected: its tone runs across the frame, not down
// from the line, so half the area went empty. Nothing in it loops: the ruling is drawn once and kept as a
// picture (no WebGL context at rest).
import { still, palette, tone, color, bag } from '../shared.js';
// requests per day (sample data: replace with the product's own)
const DATA = [1.82, 1.91, 1.88, 2.04, 2.21, 1.68, 1.59, 1.98, 2.11, 2.19, 2.26, 2.38, 1.89, 1.84].map(v => v * 1e6);
const MAX = 2.5e6;
export default function init(root) {
const b = bag();
const plot = root.querySelector('.cx-chart__plot');
const area = root.querySelector('.cx-chart__area');
const line = root.querySelector('.cx-chart__line');
const path = line.querySelector('path');
const cursor = root.querySelector('.cx-chart__cursor');
const dot = root.querySelector('.cx-chart__dot');
const tip = root.querySelector('.cx-chart__tip');
const grid = root.querySelector('.cx-chart__grid');
const axis = root.querySelector('.cx-chart__axis');
const page = palette(root);
const days = DATA.map((_, i) => { const d = new Date(2026, 9, 18 + i); return d; });
const fmtDay = d => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const fmtM = v => `${(v / 1e6).toFixed(2)}M`;
root.querySelector('[data-total]').textContent = `${(DATA.reduce((a, v) => a + v, 0) / 1e6).toFixed(1)}M`;
grid.innerHTML = [[2e6, '2M'], [1e6, '1M'], [0, '0']].map(([v, l]) => `<span data-v="${l}" style="top:${(1 - v / MAX) * 100}%"></span>`).join('');
axis.innerHTML = [0, 4, 8, 13].map(i => `<span>${fmtDay(days[i])}</span>`).join('');
root.querySelector('[data-table] tbody').innerHTML = DATA.map((v, i) => `<tr><th>${fmtDay(days[i])}</th><td>${fmtM(v)}</td></tr>`).join('');
// a smooth line through the points (monotone-ish cubic), in a 0..1000 x 0..1000 box
const pts = DATA.map((v, i) => [i / (DATA.length - 1) * 1000, (1 - v / MAX) * 1000]);
const d = pts.map((p, i) => {
if (!i) return `M${p[0]},${p[1]}`;
const a = pts[i - 1], dx = (p[0] - a[0]) / 2.6;
return `C${a[0] + dx},${a[1]} ${p[0] - dx},${p[1]} ${p[0]},${p[1]}`;
}).join(' ');
line.setAttribute('viewBox', '0 0 1000 1000');
path.setAttribute('d', d);
const areaPath = `${d} L1000,1000 L0,1000 Z`;
const svgMask = `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" preserveAspectRatio="none"><path d="${areaPath}"/></svg>`)}")`;
const fade = 'linear-gradient(to bottom, #000 0%, rgb(0 0 0 / .55) 100%)';
Object.assign(area.style, {
maskImage: `${svgMask}, ${fade}`, webkitMaskImage: `${svgMask}, ${fade}`,
maskSize: '100% 100%', webkitMaskSize: '100% 100%', maskRepeat: 'no-repeat', webkitMaskRepeat: 'no-repeat',
maskComposite: 'intersect', webkitMaskComposite: 'source-in',
});
// the ruling takes a palette near the card: the dots are the accent, the ground one step off the card
const card = tone(page, 0.035);
const effect = root.dataset.cxEffect || 'fade';
const PARAMS = {
'ruled-grounds': { cell: 12, major: 0, ink: 1, focus: 0, speed: 0, grain: 0.3, calm: 0 },
};
// 'fade' draws no effect: the CSS accent fade stays
if (effect !== 'fade') {
b.add(still(area, effect, {
palette: { ...page, bg: color.mix(card, page.accent, color.isDark(page.bg) ? 0.1 : 0.06), surface: card, ink: page.accent },
seed: root.dataset.cxSeed,
params: PARAMS[effect] || { speed: 0, drift: 0, calm: 0 },
}));
}
// reading: pointer or arrow keys. The tooltip is pinned inside the plot beside the point, on the
// side with room, so it never covers the header above the chart.
let at = -1;
const show = i => {
at = Math.max(0, Math.min(DATA.length - 1, i));
const x = at / (DATA.length - 1) * 100, y = (1 - DATA[at] / MAX) * 100;
cursor.style.left = `${x}%`;
dot.style.top = `calc(${y}% - 5.5px)`;
tip.style.left = `${x}%`;
tip.style.top = `clamp(0px, calc(${y}% - 15px), calc(100% - 30px))`;
tip.classList.toggle('is-left', x > 55);
tip.innerHTML = `<span>${fmtDay(days[at])}</span><b>${fmtM(DATA[at])}</b>`;
root.classList.add('is-reading');
};
const hide = () => root.classList.remove('is-reading');
const move = e => { const r = plot.getBoundingClientRect(); show(Math.round((e.clientX - r.left) / r.width * (DATA.length - 1))); };
const key = e => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); show((at < 0 ? DATA.length - 1 : at) + (e.key === 'ArrowRight' ? 1 : -1)); }
if (e.key === 'Home') { e.preventDefault(); show(0); }
if (e.key === 'End') { e.preventDefault(); show(DATA.length - 1); }
if (e.key === 'Escape') hide();
};
plot.addEventListener('pointermove', move);
plot.addEventListener('pointerleave', hide);
plot.addEventListener('keydown', key);
plot.addEventListener('focus', () => show(at < 0 ? DATA.length - 1 : at));
plot.addEventListener('blur', hide);
b.add(() => { plot.removeEventListener('pointermove', move); plot.removeEventListener('pointerleave', hide); plot.removeEventListener('keydown', key); });
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-chart",
"name": "Area chart",
"for": "A metrics card in a product or infrastructure section: one metric over 14 days, a crisp accent line over a soft accent fade, the chart's own grid, and a reading for any day by pointer or arrow keys.",
"effects": ["fade", "ruled-grounds"],
"default": "fade",
"how": "'fade' is plain CSS: the area under the line fades from the accent to the card. 'ruled-grounds' masks a still accent dot ruling to the area (an SVG mask of the area path and a top-to-bottom fade). The line is a plain SVG path on top. Pointer and arrow keys read each day; the tooltip is pinned inside the plot beside the point.",
"rules": ["Real numbers (invented ones are covered by the page's one placeholder note, never a sample tag in the chart); axis labels on the data's own scale.", "The fill stays inside the area, never behind the labels, and is still: no moving traces, no pulses.", "The chart has one grid, its own; never an effect that draws a second one (grid-pulse) or a tone that runs across the frame (halftone-screen leaves half the area empty).", "One live chart per screen."],
"a11y": "The plot is a focusable image with a label; arrow keys, Home and End read each day into a polite live region; a hidden table holds all values."
}
@@ -0,0 +1,7 @@
/* fx-dither-object: a real object drawn in ordered dither on its own stage, never with a caption under it.
One figure per page, as the hero picture or the visual of one split section. */
.cx-obj { margin: 0; display: grid; gap: var(--s-3); min-width: 0; }
.cx-obj__stage {
position: relative; isolation: isolate; aspect-ratio: 4 / 3; overflow: hidden;
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line);
}
@@ -0,0 +1,3 @@
<figure class="cx cx-obj" data-cx-object="headphones" data-cx-seed="3">
<div class="cx-obj__stage" role="img" aria-label="Over-ear headphones, drawn in dots"></div>
</figure>
@@ -0,0 +1,480 @@
// fx-dither-object: a real thing (headphones, an office chair, a camera, a drone) built in code as a lit
// 3D form and printed through an 8x8 ordered dither in the page tones: the ground, a shade, the ink, the
// accent and a deep accent. It is the picture for a product that has no photograph yet.
// idle: the object sways slowly on its spot (about 26 s a swing)
// hover: the object turns to follow the pointer and the light comes from it; each object answers in
// its own way: sound rings out of the headphones' cups, the chair's seat swivels after the
// pointer, the camera's lens zooms and its focus ring turns, the drone's rotors spin up and it
// lifts off its legs
// Cost: the shader runs once per dither cell, not per screen pixel. The canvas has one pixel per cell
// (a 3 CSS px cell: a ninth of the CSS pixels, whatever the screen's DPR) and is scaled up with
// pixelated sampling, so a hero-sized object is well under 0.3 megapixels. Rays that miss the object's
// bounding sphere skip the march; the floor traces a shadow only right under the object. 20 frames a
// second, paused off screen, in a hidden tab and under the pause button (fx.shader). Under reduced
// motion it is drawn once and kept as a picture (still() in shared.js): no context, no frames. One per
// page.
import { fx, palette, tone, color, bag, pauser, still, reduced } from '../shared.js';
/* ------------------------------------------------------------------ shader */
// WebGL1 and WebGL2 alike: no bit operators; on WebGL2 loops start at ZERO so the compiler does not
// unroll map() (WebGL1 wants a constant there)
const COMMON = /* glsl */ `
#if __VERSION__ >= 300
#define ZERO int(min(u_time, 0.0))
#else
#define ZERO 0
#endif
float g_hov; float g_t; vec2 g_m; float g_a; float g_b;
vec3 g_ro; vec3 g_rd; vec3 g_uu; vec3 g_vv; vec3 g_ww; float g_hitT; float g_hit;
float sdBox(vec3 p, vec3 b) { vec3 q = abs(p) - b; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0); }
float sdCap(vec3 p, vec3 a, vec3 b, float r) { vec3 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h) - r; }
float sdCylY(vec3 p, float r, float h) { vec2 d = vec2(length(p.xz) - r, abs(p.y) - h); return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); }
vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); }
vec2 opU(vec2 a, vec2 b) { return a.x < b.x ? a : b; }
// a world point on the screen, in the same units as uv
vec2 proj(vec3 p) { vec3 q = p - g_ro; return vec2(dot(q, g_uu), dot(q, g_vv)) * u_focal / max(dot(q, g_ww), 0.01); }
`;
// materials: 0 metal or plastic, 1 accent paint, 2 dark (rubber, cushions, fabric), 3 light (white
// plastic), 4 glass (a lens: dark with a sharp glint)
// each object: setup() sets its moving parts from the pointer (g_hov, g_m) and time (g_t); map(p) is its
// distance field (distance, material); extra(uv) is a drawn layer over the picture (sound, a spinning
// rotor), 0..1
const OBJECTS = {
headphones: {
cam: { target: [0, 0.74, 0], dist: 4.7, pitch: 0.2, yaw: 0.62, focal: 2.0 },
bound: [0, 0.78, 0, 1.12],
follow: 1,
label: 'Over-ear headphones, drawn in dots',
glsl: /* glsl */ `
void setup() {}
// one ear cup; q.x runs outward from the cup's middle plane
vec2 cup(vec3 q) {
float taper = 1.0 - 0.14 * clamp((q.x + 0.02) / 0.12, 0.0, 1.0);
vec2 rr = vec2(0.40, 0.33) * taper;
float e = (length(q.yz / rr) - 1.0) * rr.y;
vec2 w = vec2(e + 0.04, abs(q.x) - 0.06);
vec2 res = vec2(min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.04, 1.0);
// the cushion: a soft ring on the inner side
vec3 c = q + vec3(0.15, 0.0, 0.0);
float ring = (length(c.yz / vec2(0.30, 0.235)) - 1.0) * 0.235;
res = opU(res, vec2(length(vec2(ring, c.x * 1.15)) - 0.07, 2.0));
// the fork: a thin U round the cup's upper half, a pivot at each end
float f = (length(q.yz / vec2(0.47, 0.40)) - 1.0) * 0.40;
float fork = max(length(vec2(f, q.x)) - 0.026, -q.y);
fork = min(fork, length(vec3(q.x, q.y, abs(q.z) - 0.40)) - 0.042);
return opU(res, vec2(fork, 0.0));
}
vec2 map(vec3 p) {
vec2 res = cup(vec3(abs(p.x) - 0.60, p.y - 0.44, p.z));
// the band: an arc from fork to fork whose legs run straight down into a slider on each fork's crown
// (the arc alone stopped short of the forks and left a gap), and a soft pad under its crown
float by = p.y - clamp(p.y - 0.91, -0.06, 0.0);
float r = length(vec2(p.x, by - 0.91));
vec2 d = abs(vec2(r - 0.60, p.z)) - vec2(0.018, 0.06);
float band = max(length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - 0.018, 0.91 - by);
float slider = sdBox(vec3(abs(p.x) - 0.60, p.y - 0.86, p.z), vec3(0.024, 0.036, 0.064)) - 0.018;
res = opU(res, vec2(min(band, slider), 0.0));
// the pad's lower ends are rounded into the band, not cut flat
vec2 e = abs(vec2(r - 0.545, p.z)) - vec2(0.02, 0.064);
float padBox = length(max(e, 0.0)) + min(max(e.x, e.y), 0.0) - 0.03;
vec2 ru = max(vec2(0.05 + padBox, 0.05 + 1.2 - p.y), 0.0);
float pad = min(-0.05, max(padBox, 1.2 - p.y)) + length(ru);
return opU(res, vec2(pad, 2.0));
}
// sound: rings that run outward from each cup while the headphones are pointed at
float waves(vec2 uv, float sx) {
vec3 c = vec3(sx * 0.72, 0.44, 0.0);
vec2 pc = proj(c);
vec2 pd = proj(c + vec3(sx * 0.4, 0.0, 0.0)) - pc;
float pl = length(pd);
if (pl < 1e-4) return 0.0;
pd /= pl;
vec2 d = uv - pc;
float r = length(d);
float cone = smoothstep(0.25, 0.75, dot(d, pd) / max(r, 1e-4)) * clamp(pl * 6.0, 0.0, 1.0);
float ph = fract(r * 15.0 - g_t * 1.5);
float ring = smoothstep(0.38, 0.5, ph) * (1.0 - smoothstep(0.5, 0.64, ph));
float fade = smoothstep(0.11, 0.17, r) * (1.0 - smoothstep(0.22, 0.42, r));
return ring * cone * fade;
}
float extra(vec2 uv) {
if (g_hit > 0.5 || g_hov < 0.01) return 0.0;
return max(waves(uv, 1.0), waves(uv, -1.0)) * g_hov * 0.95;
}
`,
},
chair: {
cam: { target: [0, 0.66, -0.02], dist: 3.75, pitch: 0.24, yaw: 0.78, focal: 2.0 },
bound: [0, 0.64, -0.04, 0.98],
follow: 0.35,
label: 'An office chair on five castors, drawn in dots',
glsl: /* glsl */ `
// the seat and back swivel after the pointer; the base stays put
void setup() { g_a = -g_hov * g_m.x * 2.6; }
vec2 map(vec3 p) {
// the base: five legs in a star, a castor at each end, the gas column
float a = atan(p.z, p.x) + 0.35;
float sec = 1.2566371;
a = mod(a + sec * 0.5, sec) - sec * 0.5;
float r = length(p.xz);
vec3 q = vec3(r * cos(a), p.y, r * sin(a));
float leg = sdCap(q, vec3(0.05, 0.135, 0.0), vec3(0.43, 0.088, 0.0), 0.03);
float hub = sdCylY(p - vec3(0.0, 0.13, 0.0), 0.07, 0.035) - 0.01;
float col = sdCylY(p - vec3(0.0, 0.36, 0.0), 0.028, 0.2);
vec2 res = vec2(min(min(leg, hub), col), 0.0);
float castor = length(vec3(q.x - 0.445, q.y - 0.046, q.z)) - 0.046;
float shroud = sdCylY(p - vec3(0.0, 0.25, 0.0), 0.045, 0.085) - 0.006;
res = opU(res, vec2(min(castor, shroud), 2.0));
vec3 s = p;
s.xz = rot(s.xz, g_a);
float mech = sdBox(s - vec3(0.0, 0.53, 0.0), vec3(0.1, 0.03, 0.11)) - 0.01;
float spine = min(sdCap(s, vec3(0.0, 0.53, -0.08), vec3(0.0, 0.61, -0.33), 0.034), sdCap(s, vec3(0.0, 0.61, -0.33), vec3(0.0, 0.87, -0.38), 0.034));
vec3 m = vec3(abs(s.x) - 0.315, s.y, s.z + 0.03);
float pad = sdBox(m - vec3(0.0, 0.81, 0.0), vec3(0.034, 0.016, 0.13)) - 0.014;
res = opU(res, vec2(min(min(mech, spine), pad), 2.0));
float post = min(sdCap(m, vec3(0.0, 0.61, -0.02), vec3(0.0, 0.8, -0.02), 0.02), sdCap(m, vec3(-0.13, 0.6, -0.02), vec3(0.0, 0.61, -0.02), 0.02));
res = opU(res, vec2(post, 0.0));
// the seat and the back: one upholstery in the accent; the back leans back and wraps a little
float seat = sdBox(s - vec3(0.0, 0.63, 0.03), vec3(0.25, 0.035, 0.24)) - 0.05;
vec3 b = s - vec3(0.0, 1.06, -0.4);
b.yz = rot(b.yz, 0.13);
b.z -= 0.45 * b.x * b.x;
float back = sdBox(b, vec3(0.24, 0.22, 0.025)) - 0.04;
return opU(res, vec2(min(seat, back), 1.0));
}
float extra(vec2 uv) { return 0.0; }
`,
},
camera: {
cam: { target: [0.02, 0.36, 0.12], dist: 3.1, pitch: 0.27, yaw: 0.6, focal: 2.0 },
bound: [0.0, 0.38, 0.2, 0.86],
follow: 1,
label: 'A camera with a zoom lens, drawn in dots',
glsl: /* glsl */ `
void setup() {
g_a = g_hov * clamp(0.55 - g_m.y * 1.8, 0.0, 1.0); // the zoom: the pointer high draws the lens out
g_b = g_hov * g_m.x * 3.0; // the focus ring turns with the pointer
}
vec2 map(vec3 p) {
// the body: an accent band below a metal top plate, a rubber grip, two dials and the shutter
float body = sdBox(p - vec3(0.0, 0.33, 0.0), vec3(0.5, 0.27, 0.1)) - 0.05;
vec2 res = vec2(body, p.y > 0.545 ? 0.0 : 1.0);
float hump = sdBox(p - vec3(0.06, 0.68, -0.02), vec3(0.14, 0.08, 0.1)) - 0.03;
float dial = min(sdCylY(p - vec3(0.33, 0.68, 0.0), 0.07, 0.03), sdCylY(p - vec3(-0.2, 0.67, 0.0), 0.055, 0.025)) - 0.006;
res = opU(res, vec2(min(hump, dial), 0.0));
float grip = sdBox(p - vec3(-0.4, 0.31, 0.15), vec3(0.1, 0.24, 0.07)) - 0.05;
res = opU(res, vec2(grip, 2.0));
float shutter = sdCylY(p - vec3(-0.4, 0.665, 0.1), 0.036, 0.02) - 0.006;
res = opU(res, vec2(shutter, 1.0));
// the lens: a barrel as long as the zoom, a ribbed focus ring, the glass set into its front
float L = 0.36 + 0.17 * g_a;
vec3 l = p - vec3(0.06, 0.33, 0.13);
float r = length(l.xy);
vec2 w = vec2(r - 0.2, abs(l.z - L * 0.5) - L * 0.5);
float barrel = min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.016;
float rib = step(L * 0.4, l.z) * step(l.z, L * 0.72);
barrel -= rib * 0.007 * smoothstep(0.25, 0.85, abs(sin(atan(l.y, l.x) * 30.0 + g_b)));
barrel = max(barrel, -max(r - 0.158, L - 0.03 - l.z));
float m = l.z < 0.05 ? 0.0 : (l.z > L - 0.07 ? (r < 0.162 ? 4.0 : 0.0) : 2.0);
return opU(res, vec2(barrel * 0.9, m));
}
float extra(vec2 uv) { return 0.0; }
`,
},
drone: {
cam: { target: [0, 0.36, 0], dist: 3.45, pitch: 0.5, yaw: 0.55, focal: 2.0 },
bound: [0, 0.4, 0, 1.18],
follow: 0.6,
label: 'A camera drone with four rotors, drawn in dots',
glsl: /* glsl */ `
float g_lift;
// world to the drone's own frame: lifted, tipped toward the pointer
vec3 frame(vec3 p) {
vec3 q = p - vec3(0.0, g_lift, 0.0);
q.xy = rot(q.xy, g_b);
q.zy = rot(q.zy, g_a);
return q;
}
void setup() {
g_lift = 0.2 * smoothstep(0.0, 1.0, g_hov) + 0.012 * sin(g_t * 2.2) * g_hov;
g_b = -g_hov * g_m.x * 0.32;
g_a = g_hov * g_m.y * 0.22;
}
vec2 map(vec3 p) {
vec3 q = frame(p);
vec2 res = vec2(sdBox(q - vec3(0.0, 0.3, 0.0), vec3(0.15, 0.055, 0.26)) - 0.065, 1.0);
res = opU(res, vec2(length(q - vec3(0.0, 0.22, 0.31)) - 0.06, q.z > 0.345 ? 4.0 : 2.0));
vec3 a = vec3(abs(q.x), q.y, abs(q.z));
float arm = sdCap(a, vec3(0.1, 0.31, 0.14), vec3(0.52, 0.345, 0.47), 0.028);
float leg = sdCap(a, vec3(0.1, 0.25, 0.13), vec3(0.13, 0.016, 0.17), 0.016);
// the motors in metal (dark motors under a spinning disc printed as a cluster of black marks)
vec3 mq = a - vec3(0.52, 0.375, 0.47);
res = opU(res, vec2(min(min(arm, leg), sdCylY(mq, 0.048, 0.035) - 0.006), 0.0));
// the propellers at rest, two blades each at its own angle; spinning, they are drawn as discs (extra)
if (g_hov < 0.45) {
vec3 bq = mq - vec3(0.0, 0.046, 0.0);
bq.xz = rot(bq.xz, 0.5 + 1.3 * step(0.0, q.x) + 0.8 * step(0.0, q.z));
res = opU(res, vec2(sdBox(bq, vec3(0.27, 0.004, 0.026)) - 0.004, 2.0));
}
return res;
}
float extra(vec2 uv) {
float spin = smoothstep(0.2, 0.6, g_hov);
if (spin < 0.01) return 0.0;
vec3 ro = frame(g_ro);
vec3 rd = g_rd;
rd.xy = rot(rd.xy, g_b);
rd.zy = rot(rd.zy, g_a);
if (abs(rd.y) < 1e-4) return 0.0;
float t = (0.421 - ro.y) / rd.y;
if (t < 0.0 || t > g_hitT) return 0.0;
vec3 h = ro + rd * t;
float r = length(vec2(abs(h.x), abs(h.z)) - vec2(0.52, 0.47));
return (1.0 - smoothstep(0.28, 0.295, r)) * (0.2 + 0.5 * smoothstep(0.235, 0.28, r)) * spin;
}
`,
},
};
const MAIN = /* glsl */ `
vec3 nor(vec3 p) {
vec3 n = vec3(0.0);
for (int i = ZERO; i < 4; i++) {
vec3 e = i == 0 ? vec3(1.0, -1.0, -1.0) : (i == 1 ? vec3(-1.0, -1.0, 1.0) : (i == 2 ? vec3(-1.0, 1.0, -1.0) : vec3(1.0)));
n += e * map(p + 0.0015 * e).x;
}
return normalize(n);
}
float shadow(vec3 ro, vec3 rd) {
float res = 1.0, t = 0.02;
for (int i = ZERO; i < 28; i++) {
float h = map(ro + rd * t).x;
res = min(res, 8.0 * h / t);
t += clamp(h, 0.03, 0.25);
if (res < 0.004 || t > 3.5) break;
}
return clamp(res, 0.0, 1.0);
}
// three tones: ground, shade, ink, by the ordered threshold
vec3 tone3(float v, float th) {
float x = clamp(v, 0.0, 1.0) * 2.0;
// studio: the tones blend smoothly, a shaded render instead of a print
if (u_smooth > 0.5) return x < 1.0 ? mix(u_t0, u_t1, x) : mix(u_t1, u_t2, x - 1.0);
float i = floor(x);
float idx = min(i + step(th, x - i), 2.0);
return idx < 0.5 ? u_t0 : (idx < 1.5 ? u_t1 : u_t2);
}
// the accent part: lit faces in the accent, shade in the deep accent, the deepest shade in the ground
// (dark pages) or the ink (light pages), never a grey
vec3 toneA(float a, float th) {
float x = clamp(a, 0.0, 1.0) * 2.0;
if (u_smooth > 0.5) { vec3 lo = u_dark > 0.5 ? u_t0 : u_t2; return x < 1.0 ? mix(lo, u_t4, x) : mix(u_t4, u_t3, x - 1.0); }
float i = floor(x);
float idx = min(i + step(th, x - i), 2.0);
return idx < 0.5 ? (u_dark > 0.5 ? u_t0 : u_t2) : (idx < 1.5 ? u_t4 : u_t3);
}
void main() {
float cell = max(1.0, u_cell);
vec2 id = floor(gl_FragCoord.xy / cell);
vec2 cpx = (id + 0.5) * cell;
vec2 uv = (cpx - 0.5 * u_res) / u_res.y;
float th = u_smooth > 0.5 ? 0.5 : fx_bayer(id) + 0.5 / 64.0;
g_t = u_time * u_speed;
g_hov = smoothstep(0.0, 1.0, u_mouse.z);
g_m = u_mouse.xy / u_res - 0.5;
g_a = 0.0; g_b = 0.0;
setup();
// camera: a slow sway on its spot; while pointed at, it follows the pointer (as far as the object
// lets it: the chair answers by swivelling its seat instead)
float sway = u_sway * sin(g_t * 0.24);
float yaw = u_yaw0 + mix(sway, g_m.x * 1.3 * u_follow, g_hov);
float pit = u_pitch + g_hov * g_m.y * 0.3 * u_follow;
vec3 ta = u_target;
vec3 ro = ta + u_dist * vec3(sin(yaw) * cos(pit), sin(pit), cos(yaw) * cos(pit));
vec3 ww = normalize(ta - ro);
vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0)));
vec3 vv = cross(uu, ww);
vec3 rd = normalize(uv.x * uu + uv.y * vv + u_focal * ww);
g_ro = ro; g_rd = rd; g_uu = uu; g_vv = vv; g_ww = ww;
// the light: upper left in the world; while pointed at, it comes from the pointer
vec3 Lw = normalize(vec3(-0.55, 0.9, 0.65));
vec3 Lh = normalize(uu * g_m.x * 2.2 + vv * (g_m.y * 1.8 + 1.0) - ww * 1.5);
vec3 L = normalize(mix(Lw, Lh, g_hov));
// march only inside the object's bounding sphere
float mat = -1.0, t = 1e9;
vec3 oc = ro - u_bound.xyz;
float bb = dot(oc, rd), bc = dot(oc, oc) - u_bound.w * u_bound.w, bh = bb * bb - bc;
if (bh > 0.0) {
float sq = sqrt(bh);
float t0 = max(0.05, -bb - sq), t1 = -bb + sq;
t = t0;
for (int i = ZERO; i < 96; i++) {
vec2 h = map(ro + rd * t);
if (h.x < 0.0012 * t) { mat = h.y; break; }
t += h.x * 0.9;
if (t > t1) break;
}
if (mat < 0.0) t = 1e9;
}
float tf = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
bool hit = mat >= 0.0 && t < tf;
g_hit = hit ? 1.0 : 0.0;
g_hitT = hit ? t : tf;
float dark = u_dark;
vec3 col = u_t0;
if (hit) {
vec3 p = ro + rd * t;
vec3 n = nor(p);
float dif = max(dot(n, L), 0.0);
float sh = dif > 0.001 ? shadow(p + n * 0.004, L) : 0.0;
float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0);
float spec = pow(max(dot(reflect(-L, n), -rd), 0.0), 24.0) * sh;
float lit = clamp(0.16 * (0.55 + 0.45 * n.y) + 0.84 * dif * sh, 0.0, 1.0);
if (mat > 0.5 && mat < 1.5) {
// accent paint, its shade in the deep accent
float a = dark > 0.5 ? clamp(lit * 1.1 + 0.14 + rim * 0.1 + spec * 0.3, 0.0, 1.0) : clamp(lit + 0.32 + spec * 0.3, 0.0, 1.0);
col = toneA(a, th);
} else {
// v: 0 the ground .. 1 the ink (light on a dark page, dark on a light one)
float v = dark > 0.5 ? clamp(lit * 0.9 + rim * 0.2 + 0.05, 0.0, 1.0)
: clamp((1.0 - lit) * 0.8 + 0.08 + rim * 0.28, 0.0, 1.0);
float sp = dark > 0.5 ? spec : -spec;
if (mat < 0.5) v = (dark > 0.5 ? 0.22 + 0.78 * v : v) + sp * 0.55;
else if (mat < 2.5) v = (dark > 0.5 ? 0.1 + 0.42 * v : 0.7 + 0.3 * v) + sp * 0.45;
else if (mat < 3.5) v = dark > 0.5 ? 0.3 + 0.7 * v : 0.2 + v * 0.55;
else v = (dark > 0.5 ? 0.06 + rim * 0.25 : 0.94 - rim * 0.2) + sp * 1.1;
col = tone3(v, th);
}
} else if (tf < 1e8) {
// the floor: only the contact shadow right under the object (and on a dark page a faint pool of
// light it stands in), fading out long before the horizon: no carpet, no edge
vec3 p = ro + rd * tf;
vec2 dxz = (p.xz - u_bound.xz) / u_bound.w;
float near = exp(-dot(dxz, dxz) * 4.2);
float sh = near > 0.03 ? shadow(p + vec3(0.0, 0.002, 0.0), L) : 1.0;
float pool = exp(-dot(dxz, dxz) * 6.0);
float v = dark > 0.5 ? 0.1 * pool * (0.15 + 0.85 * sh) : 0.55 * near * (1.0 - sh);
col = tone3(v, th);
}
// the object's own drawn layer (sound, spinning rotors), printed in the shade and the ink
float s = extra(uv);
if (u_smooth > 0.5) col = mix(col, dark > 0.5 ? u_t2 : u_t1, clamp(s, 0.0, 1.0) * 0.8);
else if (s > 0.02 && s > th * 1.1) col = s > 0.55 + th * 0.4 ? (dark > 0.5 ? u_t2 : u_t1) : u_t1;
fxColor = vec4(col, 1.0);
}
`;
const SCHEMA = {
pixel: { default: 3, min: 1, max: 8, step: 1 }, // dither cell, CSS px
sway: { default: 0.45, min: 0, max: 1, step: 0.01 }, // how far the object swings on its spot (radians)
speed: { default: 1, min: 0, max: 2, step: 0.01 }, // idle motion
};
/* the tones: the stage ground, a shade, the ink, the accent (kept apart from the ground) and a deep
accent for the accent part's shaded side */
function tones(page, ground) {
const dark = color.isDark(ground);
const ink = dark ? color.mix(page.ink, ground, 0.06) : page.ink;
let acc = page.accent;
if (color.contrast(acc, ground) < 1.8) acc = color.mix(acc, ink, 0.4);
const deep = dark ? color.mix(acc, ground, 0.5) : color.mix(acc, ink, 0.35);
return { t0: ground, t1: color.mix(ground, ink, dark ? 0.36 : 0.3), t2: ink, t3: acc, t4: deep, dark };
}
/* without WebGL: a line drawing of the same object in the ink, its accent part filled, each shape in
front filled with the ground so no line runs through it; the strokes keep one thin width at any size.
Drawn in a 120 x 90 box, seen from the same side as the 3D view. */
const DRAW = {
headphones: (a, g) => `<path d="M33 55C31 22 47 12 60 12s29 10 27 43" stroke-width="2.4"/><path d="M38 40c2-14 11-20 22-20s20 6 22 20" stroke-width="4.5" opacity=".55"/><path d="M27 58q0-9 7-9M93 58q0-9-7-9"/><rect x="24" y="52" width="17" height="30" rx="8" fill="${a}"/><rect x="38" y="55" width="8" height="24" rx="4" fill="${g}"/><rect x="79" y="52" width="17" height="30" rx="8" fill="${a}"/><rect x="74" y="55" width="8" height="24" rx="4" fill="${g}"/>`,
chair: (a, g) => `<path d="M60 70 31 76M60 70 89 76M60 70l-3 13M60 70 44 64M60 70l16-6"/><circle cx="30" cy="77.5" r="2.6" fill="${g}"/><circle cx="90" cy="77.5" r="2.6" fill="${g}"/><circle cx="57" cy="84.5" r="2.6" fill="${g}"/><path d="M60 70V52M60 50l2-12M39 47V37h9M81 47V37h-9"/><path d="M44 8q16-5 32 0l-2 28q-14 4-28 0z" fill="${a}"/><path d="M34 46q26-8 52 0 2 5-2 7-24 6-48 0-4-2-2-7z" fill="${a}"/>`,
camera: (a, g) => `<path d="M46 26l5-9h18l5 9" fill="${g}"/><rect x="14" y="26" width="92" height="50" rx="6" fill="${g}"/><path d="M14.5 42h91v28a6 6 0 0 1-6 6h-79a6 6 0 0 1-6-6z" fill="${a}"/><rect x="17" y="34" width="16" height="42" rx="6" fill="${g}"/><path d="M84 26v-5h12v5M22 26v-4h7v4"/><circle cx="63" cy="52" r="21" fill="${g}"/><circle cx="63" cy="52" r="16"/><circle cx="63" cy="52" r="10" fill="${g}"/><path d="M58 47q3-3 7-2" opacity=".7"/>`,
drone: (a, g) => `<path d="M60 46 27 27M60 46 93 27M60 46 27 65M60 46 93 65"/><ellipse cx="27" cy="27" rx="19" ry="6"/><ellipse cx="93" cy="27" rx="19" ry="6"/><ellipse cx="27" cy="65" rx="19" ry="6"/><ellipse cx="93" cy="65" rx="19" ry="6"/><circle cx="27" cy="27" r="2.6" fill="${g}"/><circle cx="93" cy="27" r="2.6" fill="${g}"/><circle cx="27" cy="65" r="2.6" fill="${g}"/><circle cx="93" cy="65" r="2.6" fill="${g}"/><rect x="49" y="33" width="22" height="27" rx="8" fill="${a}"/><circle cx="60" cy="64" r="3.6" fill="${g}"/><path d="M52 60l-4 10M68 60l4 10"/>`,
};
const drawing = (kind, ink, accent, ground) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" fill="none" stroke="${ink}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><style>*{vector-effect:non-scaling-stroke}</style>${(DRAW[kind] || DRAW.headphones)(accent, ground)}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") center / min(78%, 520px) auto no-repeat, ${ground}`;
};
/* the object as an effect, so still() can draw it once under reduced motion */
function objectEffect(kind, page, studio = false) {
const o = OBJECTS[kind] || OBJECTS.headphones;
const ground = tone(page, 0.035);
const T = tones(page, ground);
const frag = COMMON + o.glsl + MAIN;
return {
id: `cx-object-${kind}${studio ? '-studio' : ''}`,
params: SCHEMA,
mount(el, { params, seed }) {
const pixel = params.pixel;
return fx.shader(el, {
frag,
schema: SCHEMA,
params,
palette: { ...page, bg: ground },
seed,
// print: one canvas pixel per dither cell on every screen (dpr = 1 / cell), scaled up pixelated.
// studio: a smooth render at one sample per CSS pixel (0.75 past 0.64 megapixels), scaled up smooth
crisp: !studio,
maxDpr: studio ? (el.clientWidth * el.clientHeight > 640000 ? 0.75 : 1) : 1 / pixel,
fps: 20,
still: 0, // reduced motion: the rest view, as designed
pointer: true,
uniforms: ({ dpr, params: pr }) => ({
u_t0: color.vec3(T.t0), u_t1: color.vec3(T.t1), u_t2: color.vec3(T.t2), u_t3: color.vec3(T.t3), u_t4: color.vec3(T.t4),
u_dark: T.dark ? 1 : 0,
u_cell: studio ? 1 : Math.max(1, Math.round(pr.pixel * dpr)),
u_smooth: studio ? 1 : 0,
u_target: o.cam.target, u_dist: o.cam.dist, u_pitch: o.cam.pitch, u_yaw0: o.cam.yaw, u_focal: o.cam.focal,
u_bound: o.bound,
u_follow: o.follow,
}),
// without WebGL: a still line drawing of the same object, so the stage still shows the thing
fallback: () => drawing(kind, color.mix(T.t2, ground, 0.15), T.t3, ground),
});
},
};
}
export default function init(root) {
const b = bag();
const page = palette(root);
const figs = root.matches('.cx-obj') ? [root] : [...root.querySelectorAll('.cx-obj')];
figs.forEach(fig => {
const stage = fig.querySelector('.cx-obj__stage');
const kind = OBJECTS[fig.dataset.cxObject] ? fig.dataset.cxObject : 'headphones';
stage.setAttribute('aria-label', fig.dataset.cxLabel || OBJECTS[kind].label);
// never a caption under the picture: the section copy names the object
const pixel = Math.max(1, Math.round(+(fig.dataset.cxPixel || (stage.clientWidth < 420 ? 2 : 3)) || 3));
// data-cx-render="studio": the same object as a smooth shaded render (a product shot), not a print
const studio = fig.dataset.cxRender === 'studio';
const effect = objectEffect(kind, page, studio);
const seed = fig.dataset.cxSeed;
// the line drawing stands on the stage until the dither canvas covers it: a stage the effect never draws is not an empty card
{ const ground = tone(page, 0.035), T = tones(page, ground); stage.style.background = drawing(kind, color.mix(T.t2, ground, 0.15), T.t3, ground); b.add(() => { stage.style.background = ''; }); }
// reduced motion: one picture, its context freed (no sway, no answer to the pointer, no button)
if (reduced()) {
b.add(still(stage, effect, { seed, params: { pixel } }));
return;
}
const p = b.add(pauser(fig, { label: 'the drawing', corner: 'tr', at: stage }));
fx.mount(stage, effect, { seed, params: { pixel } }).then(ctrl => {
b.add(p.add(ctrl));
// the line drawing without WebGL is still: no pause button
if (ctrl.info?.fallback) p.destroy();
}, err => console.warn('fx-dither-object:', err));
});
return b;
}
export { OBJECTS };
@@ -0,0 +1,22 @@
{
"id": "fx-dither-object",
"name": "Objects in dither",
"for": "The picture of a real thing when there is no photograph of it: headphones, an office chair, a camera or a drone, built in code as a lit 3D form and printed in ordered dither in the page tones (ground, shade, ink, accent and a deep accent). It sways slowly on its spot; pointed at, it turns to the pointer, the light comes from the pointer and the object answers: sound rings out of the cups, the seat swivels, the lens zooms, the rotors spin up and the drone lifts off.",
"effects": [],
"objects": [
"headphones",
"chair",
"camera",
"drone"
],
"default": "headphones",
"how": "data-cx-object on the figure names the object (headphones, chair, camera, drone); data-cx-label can replace the stage's spoken label. Each is a small signed-distance model in its own shader, mounted with fx.shader (lazy WebGL context, paused off screen and in a hidden tab). The shader runs once per dither cell, not per pixel: the canvas has one pixel per 3 CSS px cell (2 px on a stage under 420px) on every screen and is scaled up pixelated, rays that miss the object skip the march, and it draws 20 frames a second. Under reduced motion it is drawn once and kept as a picture (still: no context). The accent paints one part (the ear cups, the seat and back, the camera's body band, the drone's shell), its shaded side in a deep accent. The stage is the card tone with a 4:3 aspect; a pause button sits in its top-right corner. The print is the render to use. data-cx-render=\"studio\" (a smooth shaded render of the same form) exists but is not for pages yet: at one sample per CSS pixel its edges stair-step and the forms read as toys next to a real product shot. No caption under the stage, ever: the section copy says what it is.",
"rules": [
"One object per page (one live WebGL context), as the hero picture or the visual of one split section; never two on a page.",
"Only an object the product really is or sells (headphones for an audio brand, a chair for a furniture maker, a camera for a camera or photo-gear shop, a drone for a drone maker or a survey service that flies them). Never an unrelated object for decoration.",
"No caption under the stage: the section heading or copy names the object and gives one real fact from the client.",
"The print render, never studio: studio is not anti-aliased yet.",
"The object moves inside its stage only; the stage and the card never move, nothing follows scroll."
],
"a11y": "The stage is role=img with a label that names the object (data-cx-label to replace it). A pause button holds the sway and the hover answer; reduced motion shows one still picture (no context) and no button. Without WebGL the stage shows a still line drawing of the same object."
}
@@ -0,0 +1,161 @@
/*
fx-feature-card: a 2x2 of feature cards, each a title and two grey lines on the card fill, then an
inset panel with its own 8px margin (radius = card radius - margin) holding a still effect ground
and one smaller UI window. Two panels (one per row) run a short pointer demo; the others show a
settled still. The cards and panels never move.
*/
.cx-feat { --cx-feat-m: var(--s-2); --cx-feat-h: 340px; display: grid; gap: var(--s-7); }
.cx-feat__head { display: grid; justify-items: center; gap: var(--s-3); text-align: center; }
.cx-feat__h { font-size: var(--t-h2); line-height: 1.08; letter-spacing: -0.03em; margin: 0; max-width: 20ch; text-wrap: balance; }
.cx-feat__sub { margin: 0; max-width: 54ch; font-size: var(--t-lead); line-height: 1.5; color: var(--muted); text-wrap: balance; }
/* each card spans two rows of the grid (text, panel) as a subgrid, so the text zones of a row share
one height and the panels of a row start on one line */
.cx-feat__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-4); }
.cx-feat__card {
display: grid; grid-row: span 2; grid-template-rows: subgrid; row-gap: 0; min-width: 0; overflow: hidden;
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line);
box-shadow: inset 0 1px 0 var(--cx-hi);
}
/* the text zone: same height in every card of a row (the grid row sizes it), never over the panel */
.cx-feat__copy { display: grid; align-content: start; gap: 6px; padding: var(--s-5) var(--s-5) var(--s-5); }
.cx-feat__title { font: 500 var(--t-card)/1.3 var(--font-text); letter-spacing: -0.01em; margin: 0; }
.cx-feat__text { margin: 0; font-size: 15px; line-height: 1.5; color: var(--muted); max-width: 50ch; text-wrap: pretty; }
/* the inset panel: one step off the card, its radius the card's minus its margin */
.cx-feat__panel {
position: relative; isolation: isolate; overflow: hidden; height: var(--cx-feat-h); align-self: end;
margin: 0 var(--cx-feat-m) var(--cx-feat-m); border-radius: calc(var(--r-lg) - var(--cx-feat-m));
background: var(--cx-panel);
}
/* ---- the UI window on the ground (opaque, page ground colour) */
.cx-feat__win {
position: absolute; z-index: 1; display: grid; align-content: start;
background: var(--bg); color: var(--ink); border-radius: var(--r-md);
box-shadow: 0 0 0 1px var(--cx-line-2), 0 2px 4px rgb(0 0 0 / .12), 0 18px 40px -12px rgb(0 0 0 / .45);
font-size: 13px; line-height: 1.4;
}
/* every window starts some way down the panel and runs out through its bottom edge, so it reads as a
cut from a real screen, never as a dialog floating in the middle of an empty ground */
.cx-feat__win--bleed { left: 12%; right: 12%; top: 56px; bottom: -24px; }
.cx-feat__win--narrow { left: 50%; width: min(300px, calc(100% - 64px)); transform: translateX(-50%); top: 44px; bottom: -24px; }
.cx-feat__win--side { left: 9%; right: 22%; top: 72px; bottom: -24px; }
.cx-feat__bar { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 10px 0 16px; border-bottom: 1px solid var(--cx-line); }
.cx-feat__name { font-weight: 500; font-size: 13px; }
/* in a window's bar the title keeps one line (it gives way with an ellipsis, never wraps) */
.cx-feat__bar .cx-feat__name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.cx-feat__chip {
display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: var(--r-sm);
font: 500 11.5px/1 var(--font-text); font-style: normal; color: var(--muted); background: var(--cx-panel);
transition: background-color .3s var(--cx-ease), color .3s var(--cx-ease);
}
.cx-feat__chip::before { content: ""; width: 6px; height: 6px; border-radius: 9px; background: currentColor; opacity: .7; }
.cx-feat__chip.is-on { color: var(--ink); background: color-mix(in oklab, var(--accent) 22%, var(--bg)); }
.cx-feat__chip.is-on::before { background: var(--accent); opacity: 1; }
.cx-feat__btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 14px; margin-left: auto;
border-radius: var(--r-sm); background: var(--action); color: var(--action-ink); font: 500 12.5px/1 var(--font-text);
transition: transform .12s var(--cx-ease), filter .2s var(--cx-ease);
}
.cx-feat__btn.is-down { transform: scale(.96); filter: brightness(.92); }
.cx-feat__btn.is-done::before {
content: ""; width: 9px; height: 5px; margin-top: -3px; border-left: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(-45deg);
}
.cx-feat__btn--wide { margin: 10px 0 0; width: 100%; height: 36px; font-size: 13px; }
.cx-feat__meta { display: grid; gap: 8px; margin: 0; padding: 14px 16px; border-bottom: 1px solid var(--cx-line); }
.cx-feat__meta div { display: grid; grid-template-columns: 88px 1fr; }
.cx-feat__meta dt { color: var(--muted); }
.cx-feat__meta dd { margin: 0; }
.cx-feat__lines { margin: 0; padding: 6px 16px; list-style: none; }
.cx-feat__lines li { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
.cx-feat__lines li + li { border-top: 1px solid var(--cx-line); }
.cx-feat__lines b { font-weight: 500; }
.cx-feat__lines .is-total span { color: var(--muted); }
.cx-feat__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--cx-line); background: var(--cx-card); color: var(--muted); }
.cx-feat__foot span:last-child { color: var(--ink); text-align: right; }
/* payment link */
.cx-feat__pay { gap: 4px; padding: 18px; justify-items: start; }
.cx-feat__pay .cx-feat__name { color: var(--muted); font-weight: 400; }
.cx-feat__amount { font: 560 28px/1.15 var(--font-display); letter-spacing: -0.025em; }
.cx-feat__note { color: var(--muted); font-size: 12px; }
.cx-feat__label { margin-top: 12px; color: var(--muted); font-size: 12px; }
.cx-feat__label + .cx-feat__field { margin-top: 2px; }
.cx-feat__field {
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; height: 38px; margin: 0 0 4px;
padding: 0 12px; border-radius: var(--r-sm); box-shadow: inset 0 0 0 1px var(--cx-line-2); font-size: 12.5px; letter-spacing: .02em;
}
.cx-feat__marks { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
.cx-feat__marks .cx-mark { height: 11px; width: auto; flex: none; fill: currentColor; }
.cx-feat__or { justify-self: center; margin-top: 10px; color: var(--muted); font-size: 12px; }
/* payout: the schedule switch in the window's bar, then the payouts */
.cx-feat__payout .cx-feat__bar { justify-content: space-between; }
.cx-feat__seg { display: grid; grid-template-columns: repeat(3, auto); padding: 2px; border-radius: var(--r-sm); background: var(--cx-panel); }
.cx-feat__seg span { display: grid; place-items: center; height: 26px; padding: 0 10px; border-radius: calc(var(--r-sm) - 2px); color: var(--muted); font-size: 12px; }
.cx-feat__seg .is-on { background: var(--bg); color: var(--ink); font-weight: 500; box-shadow: 0 0 0 1px var(--cx-line-2), 0 1px 2px rgb(0 0 0 / .12); }
.cx-feat__payout .cx-feat__lines li:first-child span { color: var(--ink); }
.cx-feat__payout .cx-feat__lines li:not(:first-child) { color: var(--muted); }
/* invite */
.cx-feat__invite { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--cx-line); }
.cx-feat__input, .cx-feat__select {
display: inline-flex; align-items: center; height: 32px; padding: 0 10px; border-radius: var(--r-sm);
box-shadow: inset 0 0 0 1px var(--cx-line-2); white-space: nowrap;
}
.cx-feat__input { flex: 1; min-width: 0; overflow: hidden; }
.cx-feat__select { gap: 6px; color: var(--muted); }
.cx-feat__invite .cx-feat__btn { margin-left: 0; height: 32px; }
.cx-feat__caret { width: 1px; height: 15px; margin-left: 1px; background: var(--ink); animation: cx-feat-caret 1s steps(1) infinite; }
@keyframes cx-feat-caret { 50% { opacity: 0; } }
.cx-feat__people { margin: 0; padding: 6px 12px; list-style: none; }
.cx-feat__people li { display: flex; align-items: center; gap: 10px; height: 44px; }
.cx-feat__people li + li { border-top: 1px solid var(--cx-line); }
.cx-feat__people em { margin-left: auto; font-style: normal; color: var(--muted); font-size: 12px; }
.cx-feat__people em.cx-feat__chip { color: var(--ink); background: color-mix(in oklab, var(--accent) 22%, var(--bg)); }
.cx-feat__people em.cx-feat__chip::before { background: var(--accent); opacity: 1; }
.cx-feat__ava {
display: grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; flex: none;
background: color-mix(in oklab, var(--accent) var(--a), var(--cx-raise)); font-size: 10.5px; font-weight: 500; letter-spacing: .02em;
}
/* the new invite opens at the top of the list and pushes the others down (never a blank row) */
.cx-feat__new { overflow: hidden; transition: opacity .35s var(--cx-ease), height .35s var(--cx-ease); }
.cx-feat__win:not(.is-added) .cx-feat__new { opacity: 0; height: 0; }
.cx-feat__win:not(.is-added) .cx-feat__new + li { border-top: 0; }
/* ---- the pointer and its click ring (added by the script) */
.cx-feat__ptr {
position: absolute; left: 0; top: 0; z-index: 3; width: 20px; height: 20px; pointer-events: none; opacity: 0;
transition: transform 1.1s cubic-bezier(.45, 0, .2, 1), opacity .3s var(--cx-ease);
filter: drop-shadow(0 2px 3px rgb(0 0 0 / .35));
}
.cx-feat__ptr svg { display: block; width: 20px; height: 20px; }
.cx-feat__ptr path { fill: var(--ink); stroke: var(--bg); stroke-width: 1.5; stroke-linejoin: round; }
.cx-feat__ring {
position: absolute; left: -14px; top: -14px; z-index: 2; width: 28px; height: 28px; border-radius: 99px; pointer-events: none;
border: 1.5px solid color-mix(in oklab, var(--ink) 70%, transparent); opacity: 0;
}
.cx-feat__ring.is-on { animation: cx-feat-ring .5s var(--cx-ease) forwards; }
@keyframes cx-feat-ring { from { opacity: .9; scale: .4; } to { opacity: 0; scale: 1.25; } }
/* the pause sits in the panel's top-right corner, above the window */
.cx-feat__panel > .cx-pause { top: var(--s-3); right: var(--s-3); bottom: auto; }
@media (prefers-reduced-motion: reduce) { .cx-feat__caret { animation: none; } }
@media (max-width: 899px) {
.cx-feat__grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 639px) {
.cx-feat { --cx-feat-h: 300px; gap: var(--s-6); }
.cx-feat__copy { padding: var(--s-5) var(--s-4) var(--s-4); }
/* the ground keeps a clear margin round the window on a phone, as on a wide screen */
.cx-feat__win--bleed, .cx-feat__win--side { left: 28px; right: 28px; top: 52px; }
.cx-feat__win--narrow { top: 44px; width: calc(100% - 56px); }
.cx-feat__invite .cx-feat__select { display: none; }
/* the window title keeps one line: the state chip gives way (the button says Sent) */
.cx-feat__bar .cx-feat__chip { display: none; }
.cx-feat__meta div { grid-template-columns: 76px 1fr; }
}
@@ -0,0 +1,109 @@
<section class="cx cx-feat" aria-labelledby="cx-feat-title">
<header class="cx-feat__head">
<h2 class="cx-feat__h" id="cx-feat-title">Get paid without chasing anyone</h2>
<p class="cx-feat__sub">Invoices, payment links and payouts in one account, with the right access for everyone on your team.</p>
</header>
<div class="cx-feat__grid">
<article class="cx-feat__card">
<div class="cx-feat__copy">
<h3 class="cx-feat__title">Send an invoice in a minute</h3>
<p class="cx-feat__text">Add the customer and what they owe, then send. Reminders go out on their own until it is paid.</p>
</div>
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="day" data-cx-seed="3" data-cx-params='{"horizon":0.4,"side":-1,"slope":10}' data-cx-loop="invoice">
<div class="cx-feat__win cx-feat__win--bleed" data-demo aria-hidden="true">
<div class="cx-feat__bar">
<span class="cx-feat__name">Invoice INV-0142</span>
<span class="cx-feat__chip" data-state>Draft</span>
<span class="cx-feat__btn" data-target><span data-label>Send</span></span>
</div>
<dl class="cx-feat__meta">
<div><dt>Billed to</dt><dd>Northwind Ltd</dd></div>
<div><dt>Due</dt><dd>14 Nov 2026</dd></div>
</dl>
<ul class="cx-feat__lines">
<li><span>Design retainer, October</span><b class="cx-num">$2,400.00</b></li>
<li><span>Hosting, October</span><b class="cx-num">$120.00</b></li>
<li class="is-total"><span>Total</span><b class="cx-num">$2,520.00</b></li>
</ul>
<div class="cx-feat__foot"><span>Reminders</span><span>11 Nov, then every 7 days until paid</span></div>
</div>
</div>
</article>
<article class="cx-feat__card">
<div class="cx-feat__copy">
<h3 class="cx-feat__title">Take payment with a link</h3>
<p class="cx-feat__text">Share a link by email or chat. Your customer pays by card, Apple Pay or bank transfer.</p>
</div>
<div class="cx-feat__panel" data-cx-effect="grain-gradient" data-cx-seed="47">
<div class="cx-feat__win cx-feat__win--narrow cx-feat__pay" aria-hidden="true">
<span class="cx-feat__name">Halden &amp; Co</span>
<span class="cx-feat__amount cx-num">$1,080.00</span>
<span class="cx-feat__note">Invoice INV-0139, due 14 Nov</span>
<span class="cx-feat__label">Email</span>
<span class="cx-feat__field cx-feat__field--first">accounts@northwind.co</span>
<span class="cx-feat__label">Card</span>
<span class="cx-feat__field">
<span class="cx-num">4242 4242 4242 4242</span>
<span class="cx-feat__marks">
<svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 83"> <g> <path d="M132.397094,56.2397455 C132.251036,44.7242808 142.65954,38.2977599 150.500511,34.4772086 C158.556724,30.5567233 161.262627,28.0430004 161.231878,24.5376253 C161.17038,19.1719416 154.805357,16.804276 148.847757,16.7120293 C138.454628,16.5505975 132.412467,19.5178668 127.607952,21.7625368 L123.864273,4.24334875 C128.684163,2.02174043 137.609033,0.084559486 146.864453,-7.10542736e-15 C168.588553,-7.10542736e-15 182.802234,10.7236802 182.879107,27.3511501 C182.963666,48.4525854 153.69071,49.6210438 153.890577,59.05327 C153.959762,61.912918 156.688728,64.964747 162.669389,65.7411565 C165.628971,66.133205 173.800493,66.433007 183.0636,62.1665965 L186.699658,79.11693 C181.718335,80.931115 175.314876,82.6684285 167.343223,82.6684285 C146.895202,82.6684285 132.512402,71.798691 132.397094,56.2397455 M221.6381,81.2078555 C217.671491,81.2078555 214.327548,78.8940005 212.836226,75.342502 L181.802894,1.24533061 L203.511621,1.24533061 L207.831842,13.1835926 L234.360459,13.1835926 L236.866494,1.24533061 L256,1.24533061 L239.303345,81.2078555 L221.6381,81.2078555 M224.674554,59.6067505 L230.939643,29.5804456 L213.781755,29.5804456 L224.674554,59.6067505 M106.076031,81.2078555 L88.9642665,1.24533061 L109.650591,1.24533061 L126.754669,81.2078555 L106.076031,81.2078555 M75.473185,81.2078555 L53.941265,26.7822953 L45.2316377,73.059396 C44.2092367,78.2252115 40.1734431,81.2078555 35.6917903,81.2078555 L0.491982464,81.2078555 L0,78.886313 C7.22599245,77.318119 15.4359498,74.7890215 20.409585,72.083118 C23.4537265,70.4303645 24.322383,68.985166 25.3217224,65.0569935 L41.8185094,1.24533061 L63.68098,1.24533061 L97.1972855,81.2078555 L75.473185,81.2078555" fill="currentColor" transform="translate(128.000000, 41.334214) scale(1, -1) translate(-128.000000, -41.334214) "></path> </g></svg>
<svg class="cx-mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="-0.48 4.104 24.96 15.792" fill="currentColor"><path d="M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z"/></svg>
</span>
</span>
<span class="cx-feat__btn cx-feat__btn--wide">Pay $1,080.00</span>
<span class="cx-feat__or">or pay by bank transfer</span>
</div>
</div>
</article>
<article class="cx-feat__card">
<div class="cx-feat__copy">
<h3 class="cx-feat__title">Get paid out on your schedule</h3>
<p class="cx-feat__text">Money reaches your bank daily, weekly or monthly. You see every payout before it leaves.</p>
</div>
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="dusk" data-cx-seed="9" data-cx-params='{"horizon":0.5,"side":1,"slope":16,"hillY":0.1}'>
<div class="cx-feat__win cx-feat__win--side cx-feat__payout" aria-hidden="true">
<div class="cx-feat__bar">
<span class="cx-feat__name">Payouts</span>
<span class="cx-feat__seg"><span>Daily</span><span class="is-on">Weekly</span><span>Monthly</span></span>
</div>
<ul class="cx-feat__lines">
<li><span>Mon 3 Nov, to account ••42</span><b class="cx-num">$12,480.22</b></li>
<li><span>Mon 27 Oct, paid</span><b class="cx-num">$9,904.10</b></li>
<li><span>Mon 20 Oct, paid</span><b class="cx-num">$11,236.75</b></li>
<li><span>Mon 13 Oct, paid</span><b class="cx-num">$8,718.40</b></li>
<li><span>Mon 6 Oct, paid</span><b class="cx-num">$10,052.65</b></li>
<li><span>Mon 29 Sep, paid</span><b class="cx-num">$9,377.18</b></li>
</ul>
</div>
</div>
</article>
<article class="cx-feat__card">
<div class="cx-feat__copy">
<h3 class="cx-feat__title">Give your team the right access</h3>
<p class="cx-feat__text">Invite your accountant to see the books and nothing else. Every change is kept in the log.</p>
</div>
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="meadow" data-cx-seed="14" data-cx-params='{"horizon":0.36,"side":1,"slope":20,"hillY":0.05}' data-cx-loop="invite">
<div class="cx-feat__win cx-feat__win--bleed" data-demo aria-hidden="true">
<div class="cx-feat__bar">
<span class="cx-feat__name">Invite people</span>
</div>
<div class="cx-feat__invite">
<span class="cx-feat__input"><span data-type></span><i class="cx-feat__caret"></i></span>
<span class="cx-feat__select">Can view<svg viewBox="0 0 12 12" width="10" height="10"><path d="m3 4.5 3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="cx-feat__btn" data-target><span data-label>Invite</span></span>
</div>
<ul class="cx-feat__people">
<li class="cx-feat__new" data-new><span class="cx-feat__ava" style="--a:24%">AN</span><span>ana@northwind.co</span><em class="cx-feat__chip">Invited</em></li>
<li><span class="cx-feat__ava" style="--a:38%">MO</span><span>Maya Okafor</span><em>Owner</em></li>
<li><span class="cx-feat__ava" style="--a:12%">JK</span><span>Jonas Keller</span><em>Admin</em></li>
<li><span class="cx-feat__ava" style="--a:30%">LO</span><span>Luis Ortega</span><em>Can view</em></li>
<li><span class="cx-feat__ava" style="--a:18%">SB</span><span>Sofia Brandt</span><em>Can edit</em></li>
</ul>
</div>
</div>
</article>
</div>
</section>
@@ -0,0 +1,247 @@
// fx-feature-card: every inset panel gets its own still ground (a painted landscape or a soft grain),
// drawn once and kept as a picture: the four grounds render one after another through one WebGL context
// that is freed straight after, so the cards hold no context and draw no frames at rest. The UI window on
// each ground is plain HTML, cut by the panel's bottom edge. Panels marked data-cx-loop run a short
// pointer demo (glide about 1 s, click, the state changes, rest about 3 s) while on screen, with a pause
// button in the panel's corner; the others show a settled still. Reduced motion shows every demo in its
// end state with no pointer.
import { fx, still, palette, color, bag, reduced, held, pauser, paramsOf } from '../shared.js';
// each ground: still (no drift), no calm patch (the window covers the middle)
const GROUNDS = {
'painterly-landscape': { drift: 0, calm: 0, blur: 0.65, brush: 0.55, grain: 0.035 },
'grain-gradient': { speed: 0, grainMotion: 0, grain: 0.45, tint: 0.7, contrast: 0.6, softness: 0.55, calm: 0.3, calmX: 0.5, calmY: 0.5 },
'ruled-grounds': { speed: 0, focus: 0, ink: 0.5, grain: 0.4, calm: 0.4, calmX: 0.5, calmY: 0.6 },
};
/* ------------------------------------------------------------------ the light of each panel */
const ok = (l, c, h) => color.fromLch([l, Math.max(0, c), h]);
const lum = c => color.lab(c)[0];
const coolHue = h => Math.min(265, Math.max(205, h));
/* what the page gives the pictures: its kind, the accent's hue family, its darkest and palest tone */
function read(page) {
const [La, Ca, ha] = color.lch(page.accent);
const [Lb, Cb, hb] = color.lch(page.bg);
const inkLight = lum(page.ink) > lum(page.bg);
return {
kind: fx.paletteKind(page), La, Ca, ha, Lb, Cb, hb,
warm: Ca > 0.05 && (ha < 80 || ha >= 330), // red, orange, amber
cool: Ca > 0.04 && ha >= 190 && ha <= 285, // blue, teal
dark: inkLight ? page.bg : page.ink,
pale: inkLight ? page.ink : page.bg,
};
}
/*
A painted landscape is a picture, so it takes a natural light, near the page but never one hue laid
over everything:
- day: a pale sky (the accent's hue at low chroma when the accent is cool, else almost grey) over
hills that step down to the near slope: graphite under a warm accent (an orange hill reads as rust),
a misty blue-grey under a cool one, the accent itself when it is green or neutral; on a colour page
deeper shades of the page colour.
- dusk: a deep blue sky that lights up in gold only along the horizon, dark hills with a lit crest
(the gold leans toward a warm accent, between amber and gold); on a colour page a light shade of the
page colour (never its complementary accent).
- meadow: daylight as a photograph shows it, whatever the page: a clear blue sky that pales toward the
horizon, a blue far ridge and a sunlit field of grass in front. On a blue colour page the sky is the
page colour itself. The natural picture among the panels, as on x.ai's cards.
*/
const SCENES = {
day(page, k) {
let sky, hill;
if (k.kind === 'colour') {
sky = ok(0.87, Math.min(k.Cb * 0.3, 0.06), k.hb);
hill = ok(0.46, Math.min(k.Cb * 0.8, 0.14), k.hb);
} else {
const h = k.cool ? coolHue(k.ha) : k.kind === 'light' && k.Cb > 0.012 ? k.hb : 235;
const L = k.kind === 'light' ? Math.min(0.93, Math.max(0.8, k.Lb - 0.05)) : 0.84;
sky = ok(L, k.cool ? 0.04 : k.kind === 'light' ? Math.min(k.Cb, 0.03) : 0.012, h);
hill = k.warm ? ok(0.42, 0.008, k.ha)
: k.cool ? ok(Math.min(k.La, 0.62), Math.min(k.Ca * 0.55, 0.07), coolHue(k.ha))
: ok(Math.min(k.La, 0.6), Math.min(k.Ca, 0.1), k.ha);
}
return { bg: sky, surface: color.lighten(sky, -0.04), ink: k.dark, muted: color.mix(sky, k.dark, 0.4), accent: hill, accentInk: k.pale };
},
dusk(page, k) {
// a natural evening: a deep blue sky with a band of gold low on the horizon, whatever the accent
// (a blue glow under a blue accent read as the day panel again, only darker). The gold leans toward a
// warm accent's hue but stays between amber and gold: a red-orange glow paints the lit crests rust.
// A gold that mixes with the cool grey of the hills turns beige, never mauve or violet.
const colourPage = k.kind === 'colour';
const h = colourPage ? k.hb : k.cool ? coolHue(k.ha) : 248;
const c = colourPage ? Math.min(k.Cb * 0.5, 0.065) : 0.04;
const sky = ok(0.26, c, h);
const glow = colourPage ? ok(0.78, Math.min(k.Cb * 0.4, 0.08), k.hb)
: ok(0.8, 0.115, k.warm ? Math.min(80, Math.max(58, k.ha < 180 ? k.ha : 58)) : 74);
return { bg: sky, surface: color.lighten(sky, 0.04), ink: k.pale, muted: ok(0.58, 0.012, h), accent: glow, accentInk: k.dark };
},
meadow(page, k) {
// a sky with chroma above 0.07 is painted as a colour sky over a field of the accent: here a clear
// blue (never toward violet) over sunlit grass
const blue = k.kind === 'colour' && k.hb >= 220 && k.hb <= 270;
const sky = blue ? ok(0.62, Math.min(Math.max(k.Cb * 0.6, 0.09), 0.12), k.hb) : ok(0.66, 0.1, 238);
const grass = ok(0.74, 0.12, 112);
return { bg: sky, surface: color.lighten(sky, -0.05), ink: ok(0.97, 0.01, 238), muted: ok(0.8, 0.03, 238), accent: grass, accentInk: k.dark };
},
};
/* a soft grain: light grounds under a warm accent (a warm accent on a dark ground mixes to rust), deep
ones under a cool accent, the page colour's own shades on a colour page */
function grainGround(page, k) {
if (k.kind === 'colour') {
return { ...page, bg: ok(0.5, Math.min(k.Cb * 0.8, 0.16), k.hb), surface: ok(0.42, Math.min(k.Cb * 0.9, 0.18), k.hb), accent: ok(0.8, Math.min(k.Cb * 0.4, 0.09), k.hb), muted: ok(0.66, 0.04, k.hb) };
}
if (k.kind === 'dark') {
if (k.cool) return { ...page, bg: ok(0.34, Math.min(k.Ca * 0.4, 0.06), k.ha), surface: ok(0.42, Math.min(k.Ca * 0.5, 0.07), k.ha), muted: ok(0.6, 0.03, k.ha) };
return { ...page, bg: ok(0.8, 0.025, k.ha), surface: ok(0.72, 0.04, k.ha), ink: k.dark, muted: ok(0.6, 0.02, k.ha) };
}
return { ...page, bg: color.mix(page.bg, page.accent, 0.16), surface: color.mix(page.bg, page.accent, 0.3), muted: color.mix(page.bg, page.ink, 0.5) };
}
function groundPalette(page, id, scene) {
const k = read(page);
if (id === 'painterly-landscape') return (SCENES[scene] || SCENES.day)(page, k);
if (id === 'grain-gradient') return grainGround(page, k);
return { ...page, bg: color.mix(page.bg, page.accent, 0.16), surface: color.mix(page.bg, page.accent, 0.3), muted: color.mix(page.bg, page.ink, 0.5) };
}
/* ------------------------------------------------------------------ the pointer demo */
const POINTER = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 2.8v13.4l3.5-3.3 2.3 5.1 2.5-1.1-2.3-5h5Z"/></svg>';
/* one demo: a pointer and a click ring in the panel; script(api) runs once per loop */
function demo(panel, script, end) {
const ptr = document.createElement('span');
ptr.className = 'cx-feat__ptr';
ptr.innerHTML = POINTER;
const ring = document.createElement('span');
ring.className = 'cx-feat__ring';
panel.append(ring, ptr);
let alive = true, onScreen = false;
const io = new IntersectionObserver(e => { onScreen = e[e.length - 1].isIntersecting; });
io.observe(panel);
const running = () => onScreen && !document.hidden && !held(panel);
// wait ms of running time: off screen, in a hidden tab or paused, the clock stops
const wait = ms => new Promise(res => {
let acc = 0, last = performance.now();
const step = () => {
if (!alive) return;
const now = performance.now();
if (running()) acc += now - last;
last = now;
if (acc >= ms) res(); else setTimeout(step, running() ? Math.min(100, ms - acc) : 250);
};
step();
});
const move = (x, y, instant) => {
if (instant) ptr.style.transition = 'none';
ptr.style.transform = `translate(${(x - 4).toFixed(1)}px, ${(y - 3).toFixed(1)}px)`;
if (instant) { ptr.getBoundingClientRect(); ptr.style.transition = ''; }
};
const spot = el => {
const pr = panel.getBoundingClientRect(), r = el.getBoundingClientRect();
return [r.left - pr.left + r.width * 0.56, r.top - pr.top + r.height * 0.62];
};
const show = on => { ptr.style.opacity = on ? '1' : '0'; };
const click = async el => {
const [x, y] = spot(el);
// the ring is placed with the translate property and grows with scale: the two compose as
// translate then scale, so the ring grows on the spot (a translate inside transform would be
// scaled too and fly off across the window)
ring.style.translate = `${x.toFixed(1)}px ${y.toFixed(1)}px`;
ring.classList.remove('is-on'); void ring.offsetWidth; ring.classList.add('is-on');
el.classList.add('is-down');
await wait(140);
el.classList.remove('is-down');
};
const api = { wait, move, spot, show, click, panel };
if (reduced()) end();
else {
end(); // the finished picture first, so a panel that is off screen, paused or never seen is never empty
(async () => { while (alive) { await wait(600); await script(api); } })();
}
return { destroy() { alive = false; io.disconnect(); ptr.remove(); ring.remove(); } };
}
const SCRIPTS = {
invoice(panel) {
const btn = panel.querySelector('[data-target]'), label = btn.querySelector('[data-label]');
const state = panel.querySelector('[data-state]');
const reset = () => { btn.classList.remove('is-done'); label.textContent = 'Send'; state.textContent = 'Draft'; state.classList.remove('is-on'); };
const done = () => { btn.classList.add('is-done'); label.textContent = 'Sent'; state.textContent = 'Sent'; state.classList.add('is-on'); };
return {
still: done,
async run({ wait, move, spot, show, click, panel: p }) {
reset();
const [x, y] = spot(btn);
show(false);
move(x - 150, y + 150, true);
await wait(900);
show(true);
move(x, y);
await wait(1300);
await click(btn);
label.textContent = 'Sending';
await wait(700);
done();
await wait(500);
move(x + 40, Math.min(p.clientHeight - 30, y + 110));
show(false);
await wait(3200);
},
};
},
invite(panel) {
const btn = panel.querySelector('[data-target]'), label = btn.querySelector('[data-label]');
const typed = panel.querySelector('[data-type]'), win = panel.querySelector('[data-demo]');
const EMAIL = 'ana@northwind.co';
const reset = () => { typed.textContent = ''; win.classList.remove('is-added'); btn.classList.remove('is-done'); label.textContent = 'Invite'; };
const done = () => { typed.textContent = ''; win.classList.add('is-added'); btn.classList.add('is-done'); label.textContent = 'Invited'; };
return {
still: done,
async run({ wait, move, spot, show, click, panel: p }) {
reset();
const [x, y] = spot(btn);
show(false);
move(x - 160, y + 140, true);
await wait(700);
for (const ch of EMAIL) { typed.textContent += ch; await wait(55); }
await wait(300);
show(true);
move(x, y);
await wait(1300);
await click(btn);
await wait(250);
done();
await wait(500);
move(x + 30, Math.min(p.clientHeight - 30, y + 120));
show(false);
await wait(3400);
},
};
},
};
export default function init(root) {
const b = bag();
const page = palette(root);
root.querySelectorAll('.cx-feat__panel').forEach(panel => {
const id = panel.dataset.cxEffect || 'painterly-landscape';
const ground = groundPalette(page, id, panel.dataset.cxScene);
const params = { ...(GROUNDS[id] || {}), ...(paramsOf(panel) || {}), speed: 0, drift: 0 };
// the ground never moves: drawn once, kept as a picture, its context freed
b.add(still(panel, id, { palette: ground, seed: panel.dataset.cxSeed, params }));
const loop = SCRIPTS[panel.dataset.cxLoop];
if (loop) {
// the pause button holds the pointer demo (the ground is a picture and has nothing to pause)
if (!reduced()) b.add(pauser(panel, { label: 'the demo', corner: 'tr' }));
const s = loop(panel);
b.add(demo(panel, s.run, s.still));
}
});
return b;
}
export { groundPalette };
@@ -0,0 +1,24 @@
{
"id": "fx-feature-card",
"name": "Feature cards with inset panels",
"for": "The features grid of a product page, as on the best company sites: a centred headline and a grey line, then a 2x2 of cards, each a title and two grey lines, then an inset panel with its own margin holding a bright painterly or grain ground and one smaller UI window that runs out through the panel's bottom edge. Two panels run a short pointer demo; the cards never move.",
"effects": [
"painterly-landscape",
"grain-gradient",
"ruled-grounds"
],
"default": "painterly-landscape",
"how": "Each panel names its ground in data-cx-effect, its light in data-cx-scene (day, dusk or meadow, for painterly-landscape) and its composition in data-cx-params (horizon, side, slope), so no two panels look alike. The grounds are still: drawn once as they near the screen, kept as pictures and their WebGL context freed, one after another, so the cards hold no context and draw no frames at rest. A landscape takes a natural light near the page: a pale sky over graphite hills under a warm accent, blue-grey under a cool one, the accent itself when it is green or neutral, the page colour's own shades on a colour page; dusk is a deep blue evening sky lit in gold along the horizon, whatever the accent (a blue glow read as the day panel again); meadow is natural daylight (a blue sky over sunlit grass), as a photograph would show it. The sample grid mixes day, a grain, dusk and meadow. The window is plain HTML in the page's own ground colour, cut by the panel's bottom edge (classes --bleed, --narrow, --side vary its width and place); its content runs out through that edge. A card is a subgrid of two rows, so the text zones of a row share one height. Panels with data-cx-loop run a pointer demo (glide, click, state change, rest) only while on screen, each with a pause button in its top-right corner.",
"rules": [
"Text only in the top zone on the flat card fill; the panel never reaches under it.",
"Panel margin 8px on three sides, panel radius = card radius - margin.",
"Every window runs out through the panel's bottom edge; never a small dialog centred on an empty ground (that reads cheap).",
"No orb, sphere or other focal ground in these panels: the window is the subject.",
"One capability per card, one UI window per panel, real labels in it (never lorem), no UI text under 11px.",
"At most two looping panels in view (one per row); the others show a settled still.",
"The loop never follows scroll and the card never moves.",
"Every panel a different picture: a different scene or composition, or a grain; never four copies of one landscape, never a landscape painted in the accent hue only.",
"The window's content reaches the panel's bottom edge: no empty band under its last row."
],
"a11y": "Each card has a heading and a sentence that say what the feature does; the UI windows are illustrations (aria-hidden). Looping panels have a pause button; reduced motion shows the end state with no pointer and no button."
}
@@ -0,0 +1,29 @@
/* fx-globe: the dotted globe as a data visual, rising from the panel's floor, beside live region figures */
.cx-globe { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: var(--s-4); padding: var(--s-4); overflow: hidden; }
.cx-globe__copy { display: flex; flex-direction: column; gap: var(--s-3); padding: var(--s-5) var(--s-4) var(--s-4); min-width: 0; }
.cx-globe__title { font: 500 var(--t-h3)/1.2 var(--font-display); letter-spacing: -0.02em; margin: var(--s-1) 0 0; max-width: 18ch; text-wrap: balance; }
.cx-globe__live { width: 7px; height: 7px; border-radius: 9px; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
.cx-globe__table { width: 100%; border-collapse: collapse; margin-top: auto; font-size: var(--t-small); }
.cx-globe__table tr { border-top: 1px solid var(--cx-line); }
.cx-globe__table tr:last-child { border-bottom: 1px solid var(--cx-line); }
.cx-globe__table th, .cx-globe__table td { padding: 11px 0; text-align: left; font-weight: 400; }
.cx-globe__table th { color: var(--ink); display: flex; align-items: center; gap: 10px; }
.cx-globe__table td:nth-child(2) { color: var(--muted); font-family: var(--cx-mono); font-size: 12px; }
.cx-globe__table td:last-child { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
.cx-globe__dot { width: 6px; height: 6px; border-radius: 9px; background: color-mix(in oklab, var(--accent) 80%, var(--ink)); }
.cx-globe__panel {
position: relative; isolation: isolate; min-height: 440px; border-radius: var(--r-md); overflow: hidden;
background: var(--cx-panel); padding: var(--s-5);
}
.cx-globe__chip {
position: relative; z-index: 1; display: inline-grid; gap: 2px; padding: 12px 14px; border-radius: var(--r-md);
background: color-mix(in oklab, var(--bg) 82%, transparent); backdrop-filter: blur(10px);
box-shadow: var(--cx-shadow);
}
.cx-globe__chip-k { font-size: 12px; color: var(--muted); }
.cx-globe__chip-v { font: 500 22px/1.15 var(--font-display); letter-spacing: -0.02em; }
@media (max-width: 899px) {
.cx-globe { grid-template-columns: minmax(0, 1fr); }
.cx-globe__panel { min-height: 360px; order: -1; }
.cx-globe__copy { padding: var(--s-2) var(--s-2) var(--s-2); }
}
@@ -0,0 +1,24 @@
<article class="cx cx-card cx-globe">
<div class="cx-globe__copy">
<span class="cx-eyebrow"><span class="cx-globe__live" aria-hidden="true"></span>Network</span>
<h3 class="cx-globe__title">Your app runs in the region closest to each user</h3>
<p class="cx-text">Requests go to the nearest of 41 regions. If one is slow, traffic moves to the next one in seconds.</p>
<table class="cx-globe__table">
<caption class="cx-sr">Response time by region</caption>
<thead class="cx-sr"><tr><th scope="col">Region</th><th scope="col">Code</th><th scope="col">Response time</th></tr></thead>
<tbody>
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Frankfurt</th><td>fra1</td><td><span class="cx-num" data-ms="18">18</span> ms</td></tr>
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Virginia</th><td>iad1</td><td><span class="cx-num" data-ms="24">24</span> ms</td></tr>
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Singapore</th><td>sin1</td><td><span class="cx-num" data-ms="33">33</span> ms</td></tr>
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>São Paulo</th><td>gru1</td><td><span class="cx-num" data-ms="41">41</span> ms</td></tr>
</tbody>
</table>
</div>
<div class="cx-globe__panel" data-cx-seed="5">
<div class="cx-globe__chip">
<span class="cx-globe__chip-k">Requests now</span>
<span class="cx-globe__chip-v cx-num" data-count="48210">48,210</span>
<span class="cx-globe__chip-k">per second</span>
</div>
</div>
</article>
@@ -0,0 +1,31 @@
// fx-globe: dotted-globe in an inset panel, rising from its floor; the region times and the request
// counter change a little about once a second while the card is on screen (the card never moves).
// The panel's pause button holds the globe and the counters together.
import { mount, palette, tone, bag, whileVisible, pauser } from '../shared.js';
export default function init(root) {
const b = bag();
const panel = root.querySelector('.cx-globe__panel');
const page = palette(root);
const p = b.add(pauser(root, { label: 'the globe', at: panel }));
b.add(p.add(mount(panel, 'dotted-globe', {
palette: { ...page, bg: tone(page, 0.065) },
seed: panel.dataset.cxSeed,
params: { calmX: 0.5, calmY: 0.12, calm: 0, size: 1.05, speed: 3, arcs: 3.2, tilt: 18, glow: 0.6 },
})));
const cells = [...root.querySelectorAll('[data-ms]')];
const count = root.querySelector('[data-count]');
let n = +count.dataset.count;
const fmt = new Intl.NumberFormat('en-US');
b.add(whileVisible(root, {
every: 1200,
run() {
const c = cells[Math.floor(Math.random() * cells.length)];
const base = +c.dataset.ms;
c.textContent = String(base + Math.round((Math.random() - 0.5) * 4));
n += Math.round((Math.random() - 0.45) * 900);
count.textContent = fmt.format(n);
},
}));
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-globe",
"name": "Globe data card",
"for": "A feature card for a product whose reach is global (payments, hosting, logistics, networks): the dotted globe rises from the floor of an inset panel, with arcs between cities, beside live region figures.",
"effects": ["dotted-globe"],
"default": "dotted-globe",
"how": "dotted-globe mounted on the inset panel with the calm area at the top, so the globe is cut by the panel's floor; a small opaque card sits in the clear top corner.",
"rules": ["Only when the product really works across countries.", "Figures tick about once a second, by a few units; nothing counts up on scroll.", "One globe per page."],
"a11y": "Region figures are a real table with a caption; the globe is aria-hidden. Under reduced motion the figures and globe hold still."
}
@@ -0,0 +1,29 @@
/* fx-headline: a display headline whose key phrase (or every word) is filled with an effect */
.cx-headline {
display: grid; justify-items: center; text-align: center; gap: var(--s-5);
max-width: 980px; margin-inline: auto;
}
.cx-headline__pill {
display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 12px 0 4px;
border-radius: 999px; border: 1px solid var(--cx-line); background: var(--cx-card);
font-size: var(--t-label); color: var(--ink);
transition: border-color var(--dur) var(--ease), background-color var(--dur) var(--ease);
}
.cx-headline__pill:hover { border-color: var(--cx-line-2); background: var(--cx-panel); }
.cx-headline__pill svg { color: var(--muted); transition: transform var(--dur) var(--ease); }
.cx-headline__pill:hover svg { transform: translateX(2px); }
.cx-headline__tag {
display: inline-grid; place-items: center; height: 24px; padding: 0 10px; border-radius: 999px;
background: var(--action); color: var(--action-ink); font-weight: 500; font-size: 12px;
}
.cx-headline__title {
font-size: clamp(40px, 5.2vw + 12px, 88px); line-height: 1.02; letter-spacing: -0.035em;
max-width: 15ch; margin: 0;
}
.cx-headline__title [data-fill] { white-space: nowrap; }
.cx-headline__sub { font-size: var(--t-lead); line-height: 1.5; color: var(--muted); max-width: 46ch; margin: 0; }
.cx-headline__actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-3); margin-top: var(--s-2); }
@media (max-width: 639px) {
.cx-headline__actions { flex-direction: column; align-items: stretch; width: 100%; }
.cx-headline__title [data-fill] { white-space: normal; }
}
@@ -0,0 +1,13 @@
<div class="cx cx-headline">
<a class="cx-headline__pill" href="#">
<span class="cx-headline__tag">New</span>
<span>Instant payouts to debit cards</span>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
<h1 class="cx-headline__title" data-cx-fill="silk-folds" data-cx-seed="14">Accept payments in <span data-fill>135&nbsp;currencies</span></h1>
<p class="cx-headline__sub">One API for cards, wallets and bank transfers. The money lands in your bank account two days later.</p>
<div class="cx-headline__actions">
<a class="btn btn--primary" href="#">Start for free</a>
<a class="btn btn--secondary" href="#">Talk to sales</a>
</div>
</div>
@@ -0,0 +1,15 @@
// fx-headline: fills [data-cx-fill] headlines (their [data-fill] phrase, or all of their words) with an
// effect. The fill is still (speed 0, still grain): letters that keep moving would need a pause button
// next to the page's main line, and a still sheen reads as well. Being still, it is drawn once and kept
// as a picture: no WebGL context and no frames at rest.
import { fill, bag, paramsOf } from '../shared.js';
const STILL = { speed: 0, drift: 0, grainMotion: 0 };
export default function init(root) {
const b = bag();
root.querySelectorAll('[data-cx-fill]').forEach(el => {
b.add(fill(el, el.dataset.cxFill, { still: true, seed: el.dataset.cxSeed, params: { ...(paramsOf(el) || {}), ...STILL } }));
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-headline",
"name": "Effect headline",
"for": "The one display line on a page (hero or closing call to action) where a key phrase, or the whole line, is filled with a still effect while the rest stays in ink.",
"effects": ["silk-folds", "aurora-curtains", "grain-gradient"],
"default": "silk-folds",
"how": "fill(): one still canvas over the headline (speed 0), blended so only the glyphs of the [data-fill] phrase show it; the real text stays selectable. Still, so it needs no pause button.",
"rules": ["One filled headline per page.", "Fill a phrase of two or three words, or one short line; never body text.", "Display size only: 40px and up, weight 500 or more.", "Never fluted-glass in type: on a colour ground it paints thick accent bars through the letters."],
"a11y": "The text stays real and is read normally; the overlay is aria-hidden. Forced colours and no-script show the plain ink. Reduced motion shows one still frame."
}
@@ -0,0 +1,23 @@
/* fx-icon-tiles: a hairline grid of features; every icon chip is a window onto one shared effect */
.cx-tiles { position: relative; isolation: isolate; }
.cx-tiles__grid {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin: 0; padding: 0; list-style: none;
background: var(--cx-line); border: 1px solid var(--cx-line); border-radius: var(--r-lg); overflow: hidden;
}
.cx-tiles__cell { display: grid; align-content: start; gap: var(--s-2); padding: var(--s-6) var(--s-6) var(--s-7); background: var(--bg); min-width: 0; }
.cx-tiles__cell .cx-title { margin-top: var(--s-5); }
.cx-tiles__chip {
position: relative; z-index: 2; display: grid; place-items: center; width: 48px; height: 48px; border-radius: var(--r-md);
background: var(--accent); color: var(--accent-ink);
box-shadow: inset 0 1px 0 color-mix(in oklab, #fff 28%, transparent), inset 0 0 0 1px color-mix(in oklab, #000 10%, transparent), 0 6px 16px -6px color-mix(in oklab, var(--accent) 55%, transparent);
}
.cx-tiles.is-live .cx-tiles__chip { background: transparent; }
.cx-tiles__chip svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.cx-tiles__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
@media (max-width: 899px) { .cx-tiles__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 559px) {
.cx-tiles__grid { grid-template-columns: minmax(0, 1fr); }
.cx-tiles__cell { padding: var(--s-5); grid-template-columns: 48px 1fr; column-gap: var(--s-4); row-gap: 4px; }
.cx-tiles__chip { grid-row: span 2; }
.cx-tiles__cell .cx-title { margin-top: 2px; }
}
@@ -0,0 +1,35 @@
<div class="cx cx-tiles" data-cx-seed="22">
<ul class="cx-tiles__grid">
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M10 14.5V4.5M5.5 9 10 4.5 14.5 9M4 17h12"/></svg></span>
<h3 class="cx-title">Deploys</h3>
<p class="cx-text">Push to Git and every branch gets its own preview link.</p>
</li>
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><ellipse cx="10" cy="5.5" rx="6" ry="2.5"/><path d="M4 5.5v9c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-9M4 10c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5"/></svg></span>
<h3 class="cx-title">Databases</h3>
<p class="cx-text">Postgres with daily backups and restore to any minute.</p>
</li>
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M3 6.5 10 3l7 3.5v7L10 17l-7-3.5ZM3 6.5 10 10l7-3.5M10 10v7"/></svg></span>
<h3 class="cx-title">Storage</h3>
<p class="cx-text">Files and images served from 30 regions.</p>
</li>
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M4 5h12M4 10h12M4 15h7"/></svg></span>
<h3 class="cx-title">Logs</h3>
<p class="cx-text">Search every request from the last 30 days.</p>
</li>
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7"/><path d="M10 6v4l2.5 2.5"/></svg></span>
<h3 class="cx-title">Cron jobs</h3>
<p class="cx-text">Run a function on a schedule, down to the minute.</p>
</li>
<li class="cx-tiles__cell">
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7"/><path d="M3 10h14M10 3c1.9 2.1 2.8 4.5 2.8 7s-.9 4.9-2.8 7c-1.9-2.1-2.8-4.5-2.8-7S8.1 5.1 10 3Z"/></svg></span>
<h3 class="cx-title">Domains</h3>
<p class="cx-text">Add a domain and get HTTPS in under a minute.</p>
</li>
</ul>
<div class="cx-tiles__fx" aria-hidden="true"></div>
</div>
@@ -0,0 +1,29 @@
// fx-icon-tiles: one effect canvas over the grid, masked to the icon chips, so each chip shows its own
// part of the same field (related tones, never six copies) for the cost of one canvas. The field is
// still, so it is drawn once and kept as a picture: no WebGL context and no frames at rest.
import { still, palette, accentPalette, windows, bag, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'grain-gradient': { speed: 0, softness: 0.4, warp: 0.7, grain: 0.35, grainMotion: 0, tint: 0.9, contrast: 0.75, calm: 0 },
'gradient-mesh': { speed: 0, lines: 0, warp: 0.7, softness: 0.4, tint: 0.9, calm: 0 },
};
export default function init(root) {
const b = bag();
const host = root.querySelector('.cx-tiles__fx');
const chips = [...root.querySelectorAll('.cx-tiles__chip')];
const page = palette(root);
b.add(windows(host, chips));
const effect = root.dataset.cxEffect || 'grain-gradient';
const s = b.add(still(host, effect, {
palette: accentPalette(page, { lift: 0.16 }),
seed: root.dataset.cxSeed,
// still: six chips that drift would be six small loops on one screen
params: { ...tuned(PARAMS, effect), speed: 0, drift: 0 },
}));
// the chips keep their plain accent fill until the picture is in place
s.ready.then(() => root.classList.add('is-live'));
b.add(() => root.classList.remove('is-live'));
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-icon-tiles",
"name": "Icon tiles",
"for": "A grid of features where each icon chip is filled with the effect, like an app icon: one canvas seen through every chip, so the chips are related but each a little different.",
"effects": ["grain-gradient", "gradient-mesh"],
"default": "grain-gradient",
"how": "windows(): one still effect host over the grid, masked to the chips' boxes (12px radius, the --r-md token); the glyphs sit above it in the accent ink. Before the canvas draws, each chip is plain accent.",
"rules": ["Glyphs in the accent ink, drawn by hand or from one icon set; never brand logos here.", "Still: no drift and no flicker in chips this small.", "Chip radius is the --r-md token (12px), as for every chip in the kit.", "One tile group with filled chips per page."],
"a11y": "Chips are decorative (aria-hidden); every tile has a heading and a sentence. The effect is still."
}
@@ -0,0 +1,30 @@
/* fx-logos: a still row of brand marks; every few seconds a band of light passes through them */
@property --cx-sweep { syntax: "<percentage>"; inherits: true; initial-value: -30%; }
.cx-logos { position: relative; display: grid; justify-items: center; gap: var(--s-6); text-align: center; padding-inline: 44px; }
.cx-logos__title { margin: 0; font-size: var(--t-small); color: var(--muted); }
.cx-logos__row {
display: flex; flex-wrap: wrap; justify-content: center; align-items: center; column-gap: clamp(28px, 4.5vw, 64px); row-gap: var(--s-5);
margin: 0; padding: 4px 8px; list-style: none;
}
.cx-logos__row li {
display: inline-flex; align-items: center; gap: 10px; color: color-mix(in oklab, var(--ink) 62%, var(--bg));
font: 600 20px/1 var(--font-display); letter-spacing: -0.02em;
}
.cx-logos__row .cx-mark { width: 24px; height: 24px; flex: none; fill: currentColor; }
/* the light: the fill overlay (the row's next sibling) is shown only inside a soft band that crosses the row, then rests */
.cx-logos__row + .cx-fill {
-webkit-mask: linear-gradient(100deg, transparent calc(var(--cx-sweep) - 16%), #000 var(--cx-sweep), transparent calc(var(--cx-sweep) + 16%));
mask: linear-gradient(100deg, transparent calc(var(--cx-sweep) - 16%), #000 var(--cx-sweep), transparent calc(var(--cx-sweep) + 16%));
animation: cx-sweep 9s cubic-bezier(.45, .05, .55, .95) infinite;
animation-delay: .8s;
}
@keyframes cx-sweep { 0% { --cx-sweep: -30%; } 34% { --cx-sweep: 130%; } 100% { --cx-sweep: 130%; } }
/* the pause sits at the row's right end, on the row's middle line */
.cx-logos > .cx-pause { right: 0; bottom: 0; }
@media (prefers-reduced-motion: reduce) { .cx-logos__row + .cx-fill { display: none; } }
@media (max-width: 639px) {
.cx-logos { padding-inline: 0; padding-bottom: 48px; }
.cx-logos > .cx-pause { right: 50%; transform: translateX(50%); bottom: 0; }
.cx-logos__row { display: grid; grid-template-columns: repeat(2, auto); justify-content: center; column-gap: 40px; }
.cx-logos__row li { font-size: 18px; }
}
@@ -0,0 +1,11 @@
<section class="cx cx-logos" aria-labelledby="cx-logos-title">
<p class="cx-logos__title" id="cx-logos-title">Works with the tools your team already uses</p>
<ul class="cx-logos__row" data-cx-fill="grain-gradient" data-cx-seed="40">
<li data-fill><svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 250"><g> <path d="M128.00106,0 C57.3172926,0 0,57.3066942 0,128.00106 C0,184.555281 36.6761997,232.535542 87.534937,249.460899 C93.9320223,250.645779 96.280588,246.684165 96.280588,243.303333 C96.280588,240.251045 96.1618878,230.167899 96.106777,219.472176 C60.4967585,227.215235 52.9826207,204.369712 52.9826207,204.369712 C47.1599584,189.574598 38.770408,185.640538 38.770408,185.640538 C27.1568785,177.696113 39.6458206,177.859325 39.6458206,177.859325 C52.4993419,178.762293 59.267365,191.04987 59.267365,191.04987 C70.6837675,210.618423 89.2115753,204.961093 96.5158685,201.690482 C97.6647155,193.417512 100.981959,187.77078 104.642583,184.574357 C76.211799,181.33766 46.324819,170.362144 46.324819,121.315702 C46.324819,107.340889 51.3250588,95.9223682 59.5132437,86.9583937 C58.1842268,83.7344152 53.8029229,70.715562 60.7532354,53.0843636 C60.7532354,53.0843636 71.5019501,49.6441813 95.9626412,66.2049595 C106.172967,63.368876 117.123047,61.9465949 128.00106,61.8978432 C138.879073,61.9465949 149.837632,63.368876 160.067033,66.2049595 C184.49805,49.6441813 195.231926,53.0843636 195.231926,53.0843636 C202.199197,70.715562 197.815773,83.7344152 196.486756,86.9583937 C204.694018,95.9223682 209.660343,107.340889 209.660343,121.315702 C209.660343,170.478725 179.716133,181.303747 151.213281,184.472614 C155.80443,188.444828 159.895342,196.234518 159.895342,208.176593 C159.895342,225.303317 159.746968,239.087361 159.746968,243.303333 C159.746968,246.709601 162.05102,250.70089 168.53925,249.443941 C219.370432,232.499507 256,184.536204 256,128.00106 C256,57.3066942 198.691187,0 128.00106,0 Z M47.9405593,182.340212 C47.6586465,182.976105 46.6581745,183.166873 45.7467277,182.730227 C44.8183235,182.312656 44.2968914,181.445722 44.5978808,180.80771 C44.8734344,180.152739 45.876026,179.97045 46.8023103,180.409216 C47.7328342,180.826786 48.2627451,181.702199 47.9405593,182.340212 Z M54.2367892,187.958254 C53.6263318,188.524199 52.4329723,188.261363 51.6232682,187.366874 C50.7860088,186.474504 50.6291553,185.281144 51.2480912,184.70672 C51.8776254,184.140775 53.0349512,184.405731 53.8743302,185.298101 C54.7115892,186.201069 54.8748019,187.38595 54.2367892,187.958254 Z M58.5562413,195.146347 C57.7719732,195.691096 56.4895886,195.180261 55.6968417,194.042013 C54.9125733,192.903764 54.9125733,191.538713 55.713799,190.991845 C56.5086651,190.444977 57.7719732,190.936735 58.5753181,192.066505 C59.3574669,193.22383 59.3574669,194.58888 58.5562413,195.146347 Z M65.8613592,203.471174 C65.1597571,204.244846 63.6654083,204.03712 62.5716717,202.981538 C61.4524999,201.94927 61.1409122,200.484596 61.8446341,199.710926 C62.5547146,198.935137 64.0575422,199.15346 65.1597571,200.200564 C66.2704506,201.230712 66.6095936,202.705984 65.8613592,203.471174 Z M75.3025151,206.281542 C74.9930474,207.284134 73.553809,207.739857 72.1039724,207.313809 C70.6562556,206.875043 69.7087748,205.700761 70.0012857,204.687571 C70.302275,203.678621 71.7478721,203.20382 73.2083069,203.659543 C74.6539041,204.09619 75.6035048,205.261994 75.3025151,206.281542 Z M86.046947,207.473627 C86.0829806,208.529209 84.8535871,209.404622 83.3316829,209.4237 C81.8013,209.457614 80.563428,208.603398 80.5464708,207.564772 C80.5464708,206.498591 81.7483088,205.631657 83.2786917,205.606221 C84.8005962,205.576546 86.046947,206.424403 86.046947,207.473627 Z M96.6021471,207.069023 C96.7844366,208.099171 95.7267341,209.156872 94.215428,209.438785 C92.7295577,209.710099 91.3539086,209.074206 91.1652603,208.052538 C90.9808515,206.996955 92.0576306,205.939253 93.5413813,205.66582 C95.054807,205.402984 96.4092596,206.021919 96.6021471,207.069023 Z" fill="currentColor"></path> </g></svg>GitHub</li>
<li data-fill><svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 247"><g> <path d="M251.845416,97.6423982 L251.516654,96.6561113 L216.667852,5.75333996 C216.010327,3.9451474 214.695278,2.46571713 213.051466,1.47943028 C211.407655,0.493143425 209.599463,0 207.626889,0 C205.654315,0 203.846123,0.821905709 202.366692,1.9725737 C200.887262,3.12324169 199.736594,4.76705311 199.243451,6.73962681 L175.736947,78.7385669 L80.5602662,78.7385669 L57.053763,6.73962681 C56.5606196,4.93143425 55.4099516,3.28762283 53.9305213,1.9725737 C52.451091,0.821905709 50.6428985,0 48.6703248,0 C46.6977511,0 44.8895585,0.328762283 43.2457471,1.47943028 C41.6019357,2.46571713 40.2868865,3.9451474 39.629362,5.75333996 L4.78055991,96.6561113 L4.45179763,97.6423982 C-5.9042143,124.600905 2.97236735,155.011417 25.9857272,172.435818 L26.3144895,172.435818 C26.3144895,172.600199 79.4095983,212.380435 79.4095983,212.380435 L105.710581,232.270553 L121.655552,244.270377 C125.436318,247.229237 130.860896,247.229237 134.641662,244.270377 L150.586633,232.270553 L176.887615,212.380435 L230.311486,172.435818 L230.311486,172.435818 C253.324846,155.011417 262.037047,124.600905 251.681035,97.6423982 L251.845416,97.6423982 Z" fill="currentColor"></path> <path d="M251.845416,97.6423982 L251.516654,96.6561113 C234.256634,100.272496 218.311663,107.505267 204.668028,117.696897 C204.503647,117.696897 163.243981,149.093696 128.066416,175.559059 C154.038637,195.284796 176.723234,212.380435 176.723234,212.380435 L230.147105,172.435818 L230.147105,172.435818 C253.160465,155.011417 261.872666,124.600905 251.516654,97.6423982 L251.845416,97.6423982 Z" fill="currentColor"></path> <path d="M79.2452171,212.380435 L105.5462,232.270553 L121.491171,244.270377 C125.271937,247.229237 130.696514,247.229237 134.477281,244.270377 L150.422251,232.270553 L176.723234,212.380435 C176.723234,212.380435 154.038637,195.284796 128.066416,175.559059 C101.929815,195.284796 79.2452171,212.380435 79.2452171,212.380435 L79.2452171,212.380435 L79.2452171,212.380435 Z" fill="currentColor"></path> <path d="M51.4648042,117.696897 C37.8211694,107.505267 21.8761987,100.108115 4.61617877,96.6561113 L4.28741649,97.6423982 C-6.06859544,124.600905 2.80798621,155.011417 25.8213461,172.435818 L26.1501083,172.435818 C26.1501083,172.600199 79.2452171,212.380435 79.2452171,212.380435 C79.2452171,212.380435 101.929815,195.284796 128.066416,175.559059 C93.053233,149.093696 51.7935664,117.861279 51.4648042,117.696897 L51.4648042,117.696897 L51.4648042,117.696897 Z" fill="currentColor"></path> </g></svg>GitLab</li>
<li data-fill><svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 384"><g> <path d="M64,384 C99.328,384 128,355.328 128,320 L128,256 L64,256 C28.672,256 0,284.672 0,320 C0,355.328 28.672,384 64,384 Z" fill="currentColor"></path> <path d="M0,192 C0,156.672 28.672,128 64,128 L128,128 L128,256 L64,256 C28.672,256 0,227.328 0,192 Z" fill="currentColor"></path> <path d="M0,64 C0,28.672 28.672,0 64,0 L128,0 L128,128 L64,128 C28.672,128 0,99.328 0,64 Z" fill="currentColor"></path> <path d="M128,0 L192,0 C227.328,0 256,28.672 256,64 C256,99.328 227.328,128 192,128 L128,128 L128,0 Z" fill="currentColor"></path> <path d="M256,192 C256,227.328 227.328,256 192,256 C156.672,256 128,227.328 128,192 C128,156.672 156.672,128 192,128 C227.328,128 256,156.672 256,192 Z" fill="currentColor"></path> </g></svg>Figma</li>
<li data-fill><svg class="cx-mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 268"><defs><mask id="k-notionicon" maskUnits="userSpaceOnUse" x="0" y="0" width="256" height="268"><g fill="#fff"><g> <path d="M16.0924984,11.5384656 L164.089991,0.608048392 C182.268719,-0.952166138 186.940447,0.0998642306 198.370133,8.40912104 L245.613429,41.6907258 C253.405586,47.4144843 256,48.9746988 256,55.2066414 L256,237.73391 C256,249.172512 251.845372,255.939385 237.304172,256.973584 L65.4398551,267.377986 C54.5272689,267.895086 49.3295257,266.334872 43.6146827,259.050899 L8.82635648,213.813593 C2.58549836,205.486505 0,199.254562 0,191.970589 L0,29.7261093 C0,20.3737376 4.1546284,12.572665 16.0924984,11.5384656 Z" fill="#000"></path> <path d="M164.089991,0.608048392 L16.0924984,11.5384656 C4.1546284,12.572665 0,20.3737376 0,29.7261093 L0,191.970589 C0,199.254562 2.58549836,205.486505 8.82635648,213.813593 L43.6146827,259.050899 C49.3295257,266.334872 54.5272689,267.895086 65.4398551,267.377986 L237.304172,256.973584 C251.836456,255.939385 256,249.172512 256,237.73391 L256,55.2066414 C256,49.2956572 253.664136,47.5927945 246.790277,42.5466149 C246.394749,42.2616979 245.999494,41.9764014 245.604513,41.6907258 L198.370133,8.40912104 C186.940447,0.0998642306 182.268719,-0.952166138 164.089991,0.608048392 Z M69.3270182,52.219945 C55.2940029,53.1649893 52.1111653,53.3789615 44.1406979,46.8973846 L23.8757401,30.7781396 C21.8162569,28.6919099 22.8504562,26.0885805 28.039284,25.5714809 L170.313018,15.1759943 C182.259804,14.1328795 188.482831,18.2964234 193.154559,21.9339521 L217.556314,39.6134116 C218.599429,40.1394268 221.193843,43.2509404 218.073414,43.2509404 L71.1457825,52.0951279 L69.3270182,52.219945 Z M52.9670544,236.173696 L52.9670544,81.2221043 C52.9670544,74.455231 55.0443686,71.3348019 61.2673957,70.8087867 L230.020199,60.9303999 C235.743958,60.4133002 238.329456,64.0508289 238.329456,70.8087867 L238.329456,224.726179 C238.329456,231.493052 237.286341,237.216811 227.942885,237.73391 L66.4562234,247.095198 C57.1127673,247.612297 52.9670544,244.500784 52.9670544,236.173696 Z M212.376402,89.5313611 C213.410601,94.2120046 212.376402,98.8926482 207.695758,99.4275789 L199.912517,100.969962 L199.912517,215.373807 C193.154559,219.011336 186.931532,221.08865 181.733788,221.08865 C173.424532,221.08865 171.347217,218.485321 165.12419,210.693164 L114.225535,130.614039 L114.225535,208.089834 L130.326949,211.736279 C130.326949,211.736279 130.326949,221.097566 117.337048,221.097566 L81.523438,223.17488 C80.4803232,221.08865 81.523438,215.890907 85.1520513,214.856708 L94.5044229,212.262294 L94.5044229,109.823065 L81.523438,108.771035 C80.4803232,104.090391 83.0747371,97.3324337 90.3497945,96.8064185 L128.77565,94.2209202 L181.733788,175.334245 L181.733788,103.573292 L168.235704,102.021993 C167.192589,96.2893189 171.347217,92.1257749 176.536045,91.6175908 L212.376402,89.5313611 L212.376402,89.5313611 Z" fill="#fff"></path> </g></g></mask></defs><rect x="0" y="0" width="256" height="268" fill="currentColor" mask="url(#k-notionicon)"/></svg>Notion</li>
<li data-fill><svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 255.999966"><g> <path d="M8.17351507,102.61269 L153.38731,247.826434 C155.506492,249.945871 154.484163,253.546047 151.537796,254.096394 C146.624112,255.014493 141.611832,255.651912 136.517857,255.991749 C135.430224,256.064224 134.367944,255.654216 133.5971,254.883372 L1.11657404,122.4029 C0.345783548,121.632056 -0.0643290124,120.569776 0.0082341699,119.482143 C0.348078154,114.388168 0.985591977,109.375888 1.9035369,104.462204 C2.45395044,101.515837 6.05412868,100.493508 8.17351507,102.61269 Z M4.08163806,161.409157 C3.11270474,157.795152 7.38226007,155.515399 10.0279718,158.161111 L97.8388892,245.972054 C100.484601,248.617766 98.2048481,252.887372 94.5908433,251.918311 C50.5602697,240.113381 15.8867212,205.43973 4.08163806,161.409157 Z M16.8085843,64.1644626 C18.0417047,62.028635 20.9559826,61.7013463 22.6999089,63.4453494 L192.554651,233.299989 C194.298654,235.043992 193.971621,237.958347 191.835537,239.191441 C188.24202,241.266062 184.538382,243.171405 180.73538,244.896457 C179.304068,245.545401 177.625881,245.218112 176.514687,244.106918 L11.8930308,79.4853127 C10.7818114,78.3741189 10.4544971,76.695932 11.1036452,75.2646202 C12.828595,71.4616177 14.7338864,67.7579799 16.8085843,64.1644626 Z M127.860072,0 C198.629979,0 256,57.3702771 256,128.139928 C256,165.709238 239.831733,199.502437 214.07401,222.940712 C212.587126,224.293659 210.305837,224.204026 208.884257,222.782702 L33.2172979,47.1157434 C31.7959738,45.6941631 31.7063407,43.4128738 33.0592877,41.9259895 C56.4975633,16.1681897 90.2907616,0 127.860072,0 Z" fill="currentColor"></path> </g></svg>Linear</li>
<li data-fill><svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 222"><g> <polygon fill="currentColor" points="127.999778 0 256 221.704527 0 221.704527"></polygon> </g></svg>Vercel</li>
</ul>
</section>
@@ -0,0 +1,21 @@
// fx-logos: fill() lays the effect over the marks and names (as a sibling overlay, so the list stays a
// clean <ul>); CSS shows it only in a band that crosses the row once every 9 s, and a pause button at
// the row's end holds it. Under reduced motion there is no band and the row stays plain.
import { fill, bag, paramsOf, reduced, pauser, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'grain-gradient': { speed: 0.6, grainMotion: 0, grain: 0.3, contrast: 0.8, tint: 0.9, calm: 0 },
'aurora-curtains': { speed: 0.15, bands: 3, height: 0.8, intensity: 1.6, stars: 0, calm: 0 },
'soft-light-bars': { speed: 0.5, softness: 0.6, glow: 1.1, haze: 0.3, stipple: 0.2, grain: 0.3, calm: 0 },
};
export default function init(root) {
const b = bag();
if (reduced()) return b;
const p = b.add(pauser(root, { label: 'the light on the logos' }));
root.querySelectorAll('[data-cx-fill]').forEach(el => {
b.add(p.add(fill(el, el.dataset.cxFill, { keep: true, seed: el.dataset.cxSeed, params: paramsOf(el) || tuned(PARAMS, el.dataset.cxFill, { speed: 0.5, calm: 0 }) })));
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-logos",
"name": "Logo row with a light sweep",
"for": "A row of the brands a product works with (or real customers): the marks stay still and quiet, and every 9 s a soft band of light passes through them once.",
"effects": ["grain-gradient", "aurora-curtains", "soft-light-bars"],
"default": "grain-gradient",
"how": "fill() over the marks (one-colour inline SVG marks from scripts/icons.mjs fetch --mono, names as text), with the overlay beside the list (never inside the <ul>) and masked to a band whose position is an animated custom property: 3 s across, 6 s rest. A pause button sits at the row's right end.",
"rules": ["Real brands only, and only ones the product really works with or real customers who agreed.", "Marks in one muted colour, never their brand colours here.", "One sweep per page; it never runs faster than one pass in 8 s, and its pause button holds it."],
"a11y": "A labelled list of names in text; the marks are aria-hidden and the list holds only <li>. Reduced motion shows the plain row with no band and no button."
}
@@ -0,0 +1,485 @@
#!/usr/bin/env node
// fx-map/bake.mjs: the real streets, water, parks and rails around one place, taken from OpenStreetMap
// once at build time and written as a small JSON that fx-map draws in the page's own colours, with the
// walk from a station (only with --from). One request to an Overpass server; the page itself loads nothing from a
// map service and needs no key.
//
// node bake.mjs --lat 52.4879 --lon 13.4301 --out map.json
// node bake.mjs --lat 52.4879 --lon 13.4301 --from 52.4862,13.4246 --from-name "U Hermannplatz" --out map.json
//
// --from none (default): no walk, only the streets around the pin; --from auto: the nearest named rail,
// metro or tram station 150 m to 1.6 km away, drawn as a walk (a client whose brief names a station). --w / --h: the area in metres (default 3000 x 2000). --in <file>: a saved Overpass answer
// (no request). --save <file>: keep the answer. --endpoint: another Overpass server.
// The map's data is (c) OpenStreetMap contributors (ODbL): the credit goes in the page's one footer credit line,
// never on the map.
import fs from 'node:fs';
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
const lat = +opt('lat'), lon = +opt('lon');
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
console.error('usage: node bake.mjs --lat <deg> --lon <deg> [--from none|auto|lat,lon] [--from-name "Name"] [--w 3000] [--h 2000] [--out map.json] [--in saved.json] [--save saved.json]');
process.exit(2);
}
const W = +opt('w', 3000), H = +opt('h', 2000);
const out = opt('out', 'map.json');
const from = opt('from', 'none');
const endpoint = opt('endpoint', 'https://overpass-api.de/api/interpreter');
// metres east (x) and south (y) of the place, y down as on a screen
const KX = 111320 * Math.cos(lat * Math.PI / 180), KY = 110574;
const P = (la, lo) => [(lo - lon) * KX, (lat - la) * KY];
const BOX = { x0: -W / 2, y0: -H / 2, x1: W / 2, y1: H / 2 };
const PAD = 1.3; // lines run on past the frame
const bb = [lat - H / 2 * PAD / KY, lon - W / 2 * PAD / KX, lat + H / 2 * PAD / KY, lon + W / 2 * PAD / KX].map(v => v.toFixed(6)).join(',');
const QUERY = `[out:json][timeout:120];
(
way["highway"](${bb});
way["railway"~"^(rail|light_rail|tram|narrow_gauge)$"](${bb});
way["natural"~"^(water|wood|scrub|grassland|heath|wetland|beach|sand)$"](${bb});
relation["natural"~"^(water|wood|wetland)$"](${bb});
way["waterway"~"^(riverbank|dock|canal|river|stream)$"](${bb});
way["landuse"~"^(basin|reservoir|grass|recreation_ground|cemetery|forest|meadow|allotments|village_green)$"](${bb});
relation["landuse"~"^(basin|reservoir|forest|grass|recreation_ground|cemetery|meadow)$"](${bb});
way["leisure"~"^(park|garden|playground|pitch|common|nature_reserve|golf_course)$"](${bb});
relation["leisure"~"^(park|garden|nature_reserve|golf_course)$"](${bb});
way["natural"="coastline"](${bb});
node["railway"~"^(station|halt|tram_stop)$"](${bb});
node["public_transport"="station"](${bb});
);
out geom qt;`;
async function overpass() {
const inFile = opt('in', '');
if (inFile) return JSON.parse(fs.readFileSync(inFile, 'utf8'));
for (let attempt = 0; attempt < 3; attempt++) {
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'User-Agent': 'taste-skill fx-map bake (one static map per site)' },
body: 'data=' + encodeURIComponent(QUERY),
});
if (r.ok) {
const j = await r.json();
const save = opt('save', '');
if (save) fs.writeFileSync(save, JSON.stringify(j));
return j;
}
console.error(`overpass: ${r.status}, trying again`);
await new Promise(res => setTimeout(res, 4000 * (attempt + 1)));
}
throw new Error('the Overpass server did not answer: try --endpoint https://overpass.kumi.systems/api/interpreter');
}
/* ------------------------------------------------------------------ geometry */
const lerp = (p, q, t) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
const dist = (p, q) => Math.hypot(q[0] - p[0], q[1] - p[1]);
const inside = (p, b) => p[0] >= b.x0 && p[0] <= b.x1 && p[1] >= b.y0 && p[1] <= b.y1;
// Liang-Barsky: the part of segment p-q inside box b, as [t0, t1], or null
function lb(p, q, b) {
let t0 = 0, t1 = 1;
const dx = q[0] - p[0], dy = q[1] - p[1];
for (const [pp, qq] of [[-dx, p[0] - b.x0], [dx, b.x1 - p[0]], [-dy, p[1] - b.y0], [dy, b.y1 - p[1]]]) {
if (pp === 0) { if (qq < 0) return null; continue; }
const r = qq / pp;
if (pp < 0) { if (r > t1) return null; if (r > t0) t0 = r; } else { if (r < t0) return null; if (r < t1) t1 = r; }
}
return [t0, t1];
}
// a polyline cut to the box: pieces, each marked by whether it starts or ends on the box's edge
function clipLine(pts, b) {
const pieces = [];
let cur = null;
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1], r = lb(p, q, b);
if (!r) { if (cur) { cur.end = false; pieces.push(cur); cur = null; } continue; }
if (!cur) cur = { pts: [lerp(p, q, r[0])], start: r[0] > 0 || !inside(p, b) };
cur.pts.push(lerp(p, q, r[1]));
if (r[1] < 1) { cur.end = true; pieces.push(cur); cur = null; }
}
if (cur) { cur.end = false; pieces.push(cur); }
return pieces.filter(x => x.pts.length > 1);
}
// Sutherland-Hodgman: a ring cut to the box
function clipRing(ring, b) {
let pts = ring;
const edges = [
[p => p[0] >= b.x0, (p, q) => lerp(p, q, (b.x0 - p[0]) / (q[0] - p[0]))],
[p => p[0] <= b.x1, (p, q) => lerp(p, q, (b.x1 - p[0]) / (q[0] - p[0]))],
[p => p[1] >= b.y0, (p, q) => lerp(p, q, (b.y0 - p[1]) / (q[1] - p[1]))],
[p => p[1] <= b.y1, (p, q) => lerp(p, q, (b.y1 - p[1]) / (q[1] - p[1]))],
];
for (const [isIn, cut] of edges) {
if (!pts.length) break;
const next = [];
for (let i = 0; i < pts.length; i++) {
const p = pts[i], q = pts[(i + 1) % pts.length];
if (isIn(p)) { next.push(p); if (!isIn(q)) next.push(cut(p, q)); } else if (isIn(q)) next.push(cut(p, q));
}
pts = next;
}
return pts.length > 2 ? pts : null;
}
// Ramer-Douglas-Peucker
function simplify(pts, tol) {
if (pts.length < 3) return pts;
const keep = new Uint8Array(pts.length); keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [a, z] = stack.pop();
const [ax, ay] = pts[a], [zx, zy] = pts[z];
const len = Math.hypot(zx - ax, zy - ay) || 1e-9;
let best = -1, bi = -1;
for (let i = a + 1; i < z; i++) {
const d = Math.abs((zx - ax) * (ay - pts[i][1]) - (ax - pts[i][0]) * (zy - ay)) / len;
if (d > best) { best = d; bi = i; }
}
if (best > tol) { keep[bi] = 1; stack.push([a, bi], [bi, z]); }
}
return pts.filter((_, i) => keep[i]);
}
// a closed ring: split at the point farthest from its start, so both halves have a chord to measure from
function simplifyRing(pts, tol) {
let k = 1, best = -1;
for (let i = 1; i < pts.length - 1; i++) { const d = dist(pts[0], pts[i]); if (d > best) { best = d; k = i; } }
return simplify(pts.slice(0, k + 1), tol).concat(simplify(pts.slice(k), tol).slice(1));
}
const lengthOf = pts => pts.reduce((s, p, i) => (i ? s + dist(pts[i - 1], p) : 0), 0);
const area = ring => ring.reduce((s, p, i) => { const q = ring[(i + 1) % ring.length]; return s + p[0] * q[1] - q[0] * p[1]; }, 0) / 2;
// SVG path data: absolute M, then relative steps on whole metres
function pathOf(lines, closed = false) {
let s = '';
for (const pts of lines) {
const r = [];
for (const [x, y] of pts) { const q = [Math.round(x), Math.round(y)]; const l = r[r.length - 1]; if (!l || l[0] !== q[0] || l[1] !== q[1]) r.push(q); }
if (r.length < (closed ? 3 : 2)) continue;
s += `M${r[0][0]} ${r[0][1]}l` + r.slice(1).map((q, i) => `${q[0] - r[i][0]} ${q[1] - r[i][1]}`).join(' ') + (closed ? 'z' : '');
}
return s;
}
// ways joined into chains by their end nodes (same direction only, as coastlines are drawn)
function chains(ways, { either = false } = {}) {
const left = new Set(ways.map((_, i) => i));
const byFirst = new Map(), byLast = new Map();
ways.forEach((w, i) => {
(byFirst.get(w.ids[0]) || byFirst.set(w.ids[0], []).get(w.ids[0])).push(i);
(byLast.get(w.ids.at(-1)) || byLast.set(w.ids.at(-1), []).get(w.ids.at(-1))).push(i);
});
const take = (list, rev) => { for (const i of list || []) if (left.has(i)) { left.delete(i); return rev ? { ids: [...ways[i].ids].reverse(), pts: [...ways[i].pts].reverse() } : ways[i]; } return null; };
const out = [];
for (const i0 of [...left]) {
if (!left.has(i0)) continue;
left.delete(i0);
let ids = [...ways[i0].ids], pts = [...ways[i0].pts];
for (let grew = true; grew && ids[0] !== ids.at(-1);) {
grew = false;
let n = take(byFirst.get(ids.at(-1))) || (either && take(byLast.get(ids.at(-1)), true));
if (n) { ids = ids.concat(n.ids.slice(1)); pts = pts.concat(n.pts.slice(1)); grew = true; continue; }
n = take(byLast.get(ids[0])) || (either && take(byFirst.get(ids[0]), true));
if (n) { ids = n.ids.slice(0, -1).concat(ids); pts = n.pts.slice(0, -1).concat(pts); grew = true; }
}
out.push({ ids, pts, closed: ids.length > 2 && ids[0] === ids.at(-1) });
}
return out;
}
/* ------------------------------------------------------------------ the sea, from coastlines */
// OSM coastlines run with the land on the left and the sea on the right. Each piece that crosses the
// frame is joined to the next piece's entry by walking the frame's edge clockwise, which keeps the sea
// on the right. Islands (closed rings inside) are land drawn over it.
function sea(coastWays, b) {
const per = 2 * (b.x1 - b.x0) + 2 * (b.y1 - b.y0), w = b.x1 - b.x0, h = b.y1 - b.y0;
const tOf = ([x, y]) => {
const e = 0.01;
if (Math.abs(y - b.y0) < e) return x - b.x0;
if (Math.abs(x - b.x1) < e) return w + (y - b.y0);
if (Math.abs(y - b.y1) < e) return w + h + (b.x1 - x);
return 2 * w + h + (b.y1 - y);
};
const corners = [[w, [b.x1, b.y0]], [w + h, [b.x1, b.y1]], [2 * w + h, [b.x0, b.y1]], [per, [b.x0, b.y0]]];
const pieces = [], islands = [];
for (const c of chains(coastWays)) {
if (c.closed && c.pts.every(p => inside(p, b))) { islands.push(c.pts); continue; }
for (const pc of clipLine(c.pts, b)) if (pc.start && pc.end) pieces.push({ pts: pc.pts, a: tOf(pc.pts[0]), z: tOf(pc.pts.at(-1)) });
}
const water = [];
if (!pieces.length) {
// the whole frame is sea only around an island that holds the place itself
if (islands.some(r => pointIn([0, 0], r))) water.push([[b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]]);
return { water, islands };
}
const used = new Set();
for (const first of pieces) {
if (used.has(first)) continue;
const ring = [];
let cur = first;
for (let guard = 0; guard < pieces.length + 1; guard++) {
used.add(cur);
ring.push(...cur.pts);
// the next entry clockwise from this exit
let best = null, bd = Infinity;
for (const p of pieces) { const d = ((p.a - cur.z) % per + per) % per; if (d < bd && (!used.has(p) || p === first)) { bd = d; best = p; } }
if (!best) break;
for (const [t, pt] of corners.concat(corners.map(([t, pt]) => [t + per, pt]))) {
const d = t - cur.z;
if (d > 0 && d < bd) ring.push(pt);
}
if (best === first) break;
cur = best;
}
water.push(ring);
}
return { water, islands };
}
function pointIn([x, y], ring) {
let c = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
}
return c;
}
/* ------------------------------------------------------------------ main */
const ROAD = {
major: /^(motorway|trunk|primary|secondary)(_link)?$/,
street: /^(tertiary|tertiary_link|unclassified|residential|living_street|road)$/,
minor: /^(service|pedestrian|busway)$/,
path: /^(footway|path|cycleway|steps|track)$/,
};
const classOf = t => {
const h = t.highway;
if (!h || /^(construction|proposed|platform|corridor|elevator|raceway|bus_guideway|escape|abandoned|disused|bridleway|via_ferrata|rest_area|services)$/.test(h)) return null;
for (const [k, re] of Object.entries(ROAD)) if (re.test(h)) return k;
return null;
};
const walkable = t => {
if (!t.highway || /^(motorway|motorway_link|construction|proposed|raceway|bus_guideway|busway)$/.test(t.highway)) return false;
if (/^(no|private)$/.test(t.foot || '')) return false;
if (/^(no|private)$/.test(t.access || '') && !/^(yes|designated|permissive)$/.test(t.foot || '')) return false;
return true;
};
// what the card calls the stop: metro, train, tram, ferry or bus
const kindOf = t => (t.station === 'subway' || t.subway === 'yes' || t.railway === 'subway_entrance') ? 'metro'
: t.railway === 'tram_stop' || t.tram === 'yes' ? 'tram'
: /^(station|halt)$/.test(t.railway || '') ? (t.station === 'light_rail' ? 'light rail' : 'train')
: t.ferry === 'yes' || t.amenity === 'ferry_terminal' ? 'ferry' : t.bus === 'yes' ? 'bus' : 'station';
const json = await overpass();
const els = json.elements || [];
const wayPts = el => (el.geometry || []).map(g => (g ? P(g.lat, g.lon) : null)).filter(Boolean);
const lines = { major: [], street: [], minor: [], path: [], rail: [], tram: [] };
const rivers = { river: [], canal: [], stream: [] };
const polys = { water: [], green: [], plaza: [] };
const coast = [];
const named = []; // ways with a street name, for labels
const graph = new Map(); // node id -> { p, e: [[id, metres]] }
const stations = [];
// small rings (verges, back lawns) are left out: they only speckle the map
const addRing = (bucket, pts, min = 60) => { const r = clipRing(simplifyRing(pts, 1.6), BOX); if (r && Math.abs(area(r)) > min) bucket.push(r); };
const relRings = el => chains((el.members || []).filter(m => m.type === 'way' && m.geometry && m.geometry.length > 1)
.map(m => ({ ids: m.geometry.map(g => `${g.lat.toFixed(7)},${g.lon.toFixed(7)}`), pts: m.geometry.map(g => P(g.lat, g.lon)) })), { either: true })
.filter(c => c.closed).map(c => c.pts);
for (const el of els) {
const t = el.tags || {};
if (el.type === 'node') {
if (t.name && (/^(station|halt|tram_stop)$/.test(t.railway || '') || t.public_transport === 'station')) stations.push({ name: t.name, p: P(el.lat, el.lon), kind: kindOf(t) });
continue;
}
const isWater = t.natural === 'water' || /^(riverbank|dock)$/.test(t.waterway || '') || /^(basin|reservoir)$/.test(t.landuse || '');
const isGreen = /^(wood|scrub|grassland|heath|wetland)$/.test(t.natural || '') || /^(grass|recreation_ground|cemetery|forest|meadow|allotments|village_green)$/.test(t.landuse || '') || /^(park|playground|pitch|common|nature_reserve|golf_course)$/.test(t.leisure || '')
|| (t.leisure === 'garden' && !!t.name && !/^(private|no)$/.test(t.access || '')); // never the back gardens
if (el.type === 'relation') {
if (isWater) for (const r of relRings(el)) addRing(polys.water, r);
else if (isGreen) for (const r of relRings(el)) addRing(polys.green, r, 900);
continue;
}
const pts = wayPts(el);
if (pts.length < 2) continue;
const closed = el.nodes && el.nodes.length > 3 && el.nodes[0] === el.nodes.at(-1);
if (t.natural === 'coastline') { coast.push({ ids: el.nodes, pts }); continue; }
if (isWater && closed) { addRing(polys.water, pts); continue; }
if (isGreen && closed) { addRing(polys.green, pts, t.name ? 200 : 900); continue; }
if (/^(river|canal|stream)$/.test(t.waterway || '') && t.tunnel !== 'culvert' && !t.tunnel) {
for (const pc of clipLine(simplify(pts, 1.5), BOX)) rivers[t.waterway].push(pc.pts);
continue;
}
if (t.railway) {
if (t.tunnel && t.tunnel !== 'no') continue;
if (/^(platform)$/.test(t.railway)) continue;
for (const pc of clipLine(simplify(pts, 1.5), BOX)) (t.railway === 'tram' ? lines.tram : lines.rail).push(pc.pts);
continue;
}
if (!t.highway) continue;
// the walking graph takes every walkable way, tunnels and sidewalks too
if (walkable(t) && el.nodes && el.nodes.length === pts.length) {
for (let i = 0; i < pts.length; i++) {
const id = el.nodes[i];
if (!graph.has(id)) graph.set(id, { p: pts[i], e: [] });
if (i) { const m = dist(pts[i - 1], pts[i]); graph.get(id).e.push([el.nodes[i - 1], m]); graph.get(el.nodes[i - 1]).e.push([id, m]); }
}
}
if (t.highway === 'pedestrian' && closed && t.area === 'yes') { addRing(polys.plaza, pts); continue; }
if (t.tunnel && t.tunnel !== 'no') continue;
if (t.area === 'yes') continue;
const cls = classOf(t);
if (!cls) continue;
if (cls === 'minor' && /^(parking_aisle|driveway|drive-through)$/.test(t.service || '')) continue;
if (cls === 'path' && /^(sidewalk|crossing|traffic_island)$/.test(t.footway || '')) continue;
for (const pc of clipLine(simplify(pts, cls === 'path' ? 2 : 1.2), BOX)) {
// short service stubs and path bits only add noise (and weight)
if ((cls === 'minor' && t.highway === 'service' && lengthOf(pc.pts) < 25) || (cls === 'path' && lengthOf(pc.pts) < 30)) continue;
lines[cls].push(pc.pts);
}
if (t.name && (cls === 'major' || cls === 'street' || t.highway === 'pedestrian')) named.push({ name: t.name, cls, ids: el.nodes, pts });
}
/* the sea */
const coastal = sea(coast, BOX);
/* street names: the straightest long run of each named street near the place */
const labels = [];
{
const inner = { x0: BOX.x0 * 0.92, y0: BOX.y0 * 0.92, x1: BOX.x1 * 0.92, y1: BOX.y1 * 0.92 };
const byName = new Map();
for (const w of named) (byName.get(w.name) || byName.set(w.name, []).get(w.name)).push(w);
const cand = [];
for (const [name, ws] of byName) {
const cls = ws.some(w => w.cls === 'major') ? 'major' : ws.some(w => w.cls === 'street') ? 'street' : 'minor';
for (const c of chains(ws.map(w => ({ ids: w.ids, pts: w.pts })), { either: true })) {
for (const pc of clipLine(simplify(c.pts, 2), inner)) {
const pts = pc.pts;
// the longest run with less than about 22 degrees of turning in all
let best = null;
for (let i = 0; i < pts.length - 1; i++) {
let turn = 0, len = 0, j = i + 1, h0 = Math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0]), hp = h0;
len += dist(pts[i], pts[i + 1]);
for (; j < pts.length - 1; j++) {
const h = Math.atan2(pts[j + 1][1] - pts[j][1], pts[j + 1][0] - pts[j][0]);
let d = Math.abs(h - hp); if (d > Math.PI) d = 2 * Math.PI - d;
if (turn + d > 0.38) break;
turn += d; hp = h; len += dist(pts[j], pts[j + 1]);
}
if (!best || len > best.len) best = { i, j, len };
}
if (!best || best.len < 90) continue;
const whole = pts.slice(best.i, best.j + 1), total = lengthOf(whole);
// a window of up to 300 m: the middle of the run, and on a long run two more toward its ends,
// so a small frame that shows only part of the street still finds its name in view
const span = Math.min(300, total);
const at = total > 520 ? [0.5, 0.2, 0.8] : [0.5];
for (const f of at) {
const s0 = Math.max(0, Math.min(total - span, total * f - span / 2)), keep = [];
let acc = 0;
for (let k = 0; k < whole.length - 1; k++) {
const a = whole[k], b2 = whole[k + 1], l = dist(a, b2);
const u0 = Math.max(0, s0 - acc), u1 = Math.min(l, s0 + span - acc);
if (u1 > u0) { if (!keep.length) keep.push(lerp(a, b2, u0 / l)); keep.push(lerp(a, b2, u1 / l)); }
acc += l;
}
if (keep.length < 2) continue;
// read left to right (or top to bottom when the street runs straight up)
const dx = keep.at(-1)[0] - keep[0][0], dy = keep.at(-1)[1] - keep[0][1];
if (dx < 0 || (Math.abs(dx) < 1 && dy < 0)) keep.reverse();
const mid = keep[Math.floor(keep.length / 2)];
const near = 1 / (1 + Math.hypot(mid[0], mid[1]) / 500);
cand.push({ name, cls, run: keep, len: lengthOf(keep), score: lengthOf(keep) * (cls === 'major' ? 1.7 : cls === 'street' ? 1 : 0.7) * near });
}
}
}
}
cand.sort((a, b2) => b2.score - a.score);
// the 14 best streets, up to three windows each (the part shows one name per street)
const names = [...new Set(cand.map(c => c.name))].slice(0, 14);
for (const n of names) {
for (const c of cand.filter(x => x.name === n).slice(0, 3)) labels.push({ name: c.name, d: pathOf([c.run]), len: Math.round(c.len), cls: c.cls });
}
}
/* the walk from the nearest station */
let route = null;
{
let st = null;
if (from !== 'none') {
if (from !== 'auto') {
const [fl, fo] = from.split(',').map(Number);
if (Number.isFinite(fl) && Number.isFinite(fo)) st = { name: opt('from-name', 'Station'), p: P(fl, fo) };
} else {
// one point per station name, the nearest within the frame, 150 m to 1.6 km away
const best = new Map();
for (const s of stations) {
const d = Math.hypot(...s.p);
if (!inside(s.p, BOX) || d < 150 || d > 1600) continue;
const k = s.name;
if (!best.has(k) || d < best.get(k).d) best.set(k, { ...s, d });
}
// a metro, train or tram stop over a ferry or bus stop up to 1.6 times as far
const w = x => x.d * (/^(ferry|bus|station)$/.test(x.kind) ? 1.6 : 1);
st = [...best.values()].sort((a, b2) => w(a) - w(b2))[0] || null;
if (st && opt('from-name', '')) st.name = opt('from-name', '');
}
}
if (st && graph.size) {
// the walk joins the street network at the closest point on a street (not its nearest corner,
// which made the line run past the door and back)
const onStreet = p => {
let best = null;
for (const [id, n] of graph) for (const [nb] of n.e) {
if (nb < id) continue;
const a2 = n.p, b2 = graph.get(nb).p, dx = b2[0] - a2[0], dy = b2[1] - a2[1];
const t = Math.max(0, Math.min(1, ((p[0] - a2[0]) * dx + (p[1] - a2[1]) * dy) / (dx * dx + dy * dy || 1)));
const q = [a2[0] + dx * t, a2[1] + dy * t], d = dist(q, p);
if (!best || d < best.d) best = { d, q, a: id, b: nb };
}
return best;
};
const S = onStreet(st.p), Z = onStreet([0, 0]);
// Dijkstra with a binary heap, from both ends of the station's street piece
const D = new Map(), prev = new Map(), heap = [];
const push = x => { heap.push(x); let i = heap.length - 1; while (i) { const p = (i - 1) >> 1; if (heap[p][0] <= heap[i][0]) break; [heap[p], heap[i]] = [heap[i], heap[p]]; i = p; } };
const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === i) break; [heap[m], heap[i]] = [heap[i], heap[m]]; i = m; } } return top; };
for (const id of [S.a, S.b]) { const d0 = dist(S.q, graph.get(id).p); if (d0 < (D.get(id) ?? Infinity)) { D.set(id, d0); push([d0, id]); } }
while (heap.length) {
const [d, id] = pop();
if (d > (D.get(id) ?? Infinity)) continue;
if (d > 4000) break;
for (const [nb, m] of graph.get(id).e) { const nd = d + m; if (nd < (D.get(nb) ?? Infinity)) { D.set(nb, nd); prev.set(nb, id); push([nd, nb]); } }
}
// the door's street piece, entered from whichever end is nearer by the walk
const ends = [Z.a, Z.b].filter(id => D.has(id)).map(id => [D.get(id) + dist(graph.get(id).p, Z.q), id]).sort((x, y) => x[0] - y[0]);
const same = (S.a === Z.a && S.b === Z.b);
if (same || ends.length) {
const ids = [];
if (!same) { ids.push(ends[0][1]); while (prev.has(ids.at(-1))) ids.push(prev.get(ids.at(-1))); ids.reverse(); }
const pts = [st.p, S.q, ...ids.map(id => graph.get(id).p), Z.q, [0, 0]];
const m = lengthOf(pts);
route = { d: pathOf([simplify(pts, 1)]), m: Math.round(m), min: Math.max(1, Math.round(m / 80)), from: { name: st.name, kind: st.kind || 'station', x: Math.round(st.p[0]), y: Math.round(st.p[1]) } };
}
}
}
const data = {
v: 1,
place: { lat, lon },
box: [BOX.x0, BOX.y0, W, H],
credit: '© OpenStreetMap contributors',
sea: pathOf(coastal.water, true),
land: pathOf(coastal.islands.map(r => clipRing(r, BOX)).filter(Boolean), true),
water: pathOf(polys.water, true),
green: pathOf(polys.green, true),
plaza: pathOf(polys.plaza, true),
river: pathOf(rivers.river), canal: pathOf(rivers.canal), stream: pathOf(rivers.stream),
rail: pathOf(lines.rail), tram: pathOf(lines.tram),
path: pathOf(lines.path), minor: pathOf(lines.minor), street: pathOf(lines.street), major: pathOf(lines.major),
labels,
route,
};
fs.writeFileSync(out, JSON.stringify(data));
const kb = (fs.statSync(out).size / 1024).toFixed(1);
console.log(`saved ${out} (${kb} KB): ${lines.major.length + lines.street.length + lines.minor.length} street pieces, ${labels.length} names, ` +
(route ? `${route.min} min walk (${route.m} m) from ${route.from.name} (${route.from.kind})` : 'no route') + (coast.length ? `, coast ${coastal.water.length} sea / ${coastal.islands.length} islands` : ''));
const nearestName = [...new Set(labels.map(l => l.name))].slice(0, 4).join(', ');
if (nearestName) console.log(`streets near the place: ${nearestName}`);
@@ -0,0 +1,168 @@
/* fx-map: the real streets around the place in the page's colours, a pin, the walk from the station and
an address card. Light pages: white streets on a land a step darker than the page; dark pages: lighter
streets on a land a step lighter. Water and parks keep their natural blue and green, mixed into the land. */
.cx-map {
position: relative; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--cx-line); container-type: inline-size;
--map-land: color-mix(in oklab, var(--ink) 6%, var(--bg));
--map-road: var(--bg);
--map-casing: color-mix(in oklab, var(--ink) 15%, var(--bg));
--map-path: color-mix(in oklab, var(--ink) 20%, var(--bg));
--map-rail: color-mix(in oklab, var(--ink) 30%, var(--bg));
--map-water: color-mix(in oklab, #86b4d8 46%, var(--map-land));
--map-green: color-mix(in oklab, #a4cf8f 40%, var(--map-land));
--map-plaza: color-mix(in oklab, var(--ink) 2.5%, var(--bg));
--map-name: color-mix(in oklab, var(--ink) 66%, var(--bg));
}
.cx-map.is-dark {
--map-land: color-mix(in oklab, var(--ink) 7%, var(--bg));
--map-road: color-mix(in oklab, var(--ink) 19%, var(--bg));
--map-casing: color-mix(in oklab, var(--ink) 11%, var(--bg));
--map-path: color-mix(in oklab, var(--ink) 16%, var(--bg));
--map-rail: color-mix(in oklab, var(--ink) 26%, var(--bg));
--map-water: color-mix(in oklab, #3d7bab 34%, var(--map-land));
--map-green: color-mix(in oklab, #4f8f4f 26%, var(--map-land));
--map-plaza: color-mix(in oklab, var(--ink) 12%, var(--bg));
--map-name: color-mix(in oklab, var(--ink) 62%, var(--bg));
}
/* the height from the width (16:7), never under 380px; a height, not aspect-ratio with a min-height, which
widened the ground past the card at 820 */
.cx-map__ground { position: relative; isolation: isolate; height: max(380px, 43.75cqw); background: var(--map-land); overflow: hidden; }
.cx-map.has-ground .cx-map__ground { background: var(--cx-panel); }
/* the fallback ground is no map data: no OpenStreetMap credit on it */
.cx-map.has-ground .cx-map__credit { display: none; }
/* nor a scale: the fallback is a picture, not a map at any scale */
.cx-map.has-ground .cx-map__scale { display: none; }
.cx-map__svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --m: 1; --k: 1; }
/* the layers; widths are screen pixels (--m is metres per pixel, --k thins them when zoomed out) */
.cx-map__land { fill: var(--map-land); }
.cx-map__svg .is-area { stroke: none; fill-rule: evenodd; }
.cx-map__sea, .cx-map__water { fill: var(--map-water); }
.cx-map__land, .cx-map__svg path.cx-map__land { fill: var(--map-land); }
.cx-map__green { fill: var(--map-green); }
.cx-map__plaza { fill: var(--map-plaza); }
.cx-map__svg .is-line, .cx-map__svg .is-casing { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.cx-map__river { stroke: var(--map-water); stroke-width: calc(7px * var(--m)); }
.cx-map__canal { stroke: var(--map-water); stroke-width: calc(4px * var(--m)); }
.cx-map__stream { stroke: var(--map-water); stroke-width: calc(1.6px * var(--m)); }
.cx-map__path.is-line { stroke: var(--map-path); stroke-width: calc(1px * var(--m)); stroke-dasharray: calc(3px * var(--m)) calc(2px * var(--m)); }
.cx-map__rail { stroke: var(--map-rail); stroke-width: calc(1.6px * var(--m)); }
.cx-map__tram { stroke: var(--map-rail); stroke-width: calc(1px * var(--m)); opacity: .8; }
.cx-map__minor.is-line { stroke: var(--map-road); stroke-width: calc(2.6px * var(--m) * var(--k)); }
.cx-map__street.is-casing { stroke: var(--map-casing); stroke-width: calc(6.4px * var(--m) * var(--k)); }
.cx-map__street.is-line { stroke: var(--map-road); stroke-width: calc(4.6px * var(--m) * var(--k)); }
.cx-map__major.is-casing { stroke: var(--map-casing); stroke-width: calc(10px * var(--m) * var(--k)); }
.cx-map__major.is-line { stroke: var(--map-road); stroke-width: calc(8px * var(--m) * var(--k)); }
.cx-map.is-dark .cx-map__street.is-casing, .cx-map.is-dark .cx-map__major.is-casing { stroke: transparent; }
.cx-map.is-dark .cx-map__major.is-line { stroke: color-mix(in oklab, var(--ink) 24%, var(--bg)); }
/* the walk from the station: dots at rest; pointed at, a solid line runs from the station to the door */
.cx-map__route-casing { fill: none; stroke: var(--bg); stroke-width: calc(7px * var(--m)); stroke-linecap: round; stroke-linejoin: round; opacity: .7; }
.cx-map__route { fill: none; stroke: var(--accent); stroke-width: calc(5px * var(--m)); stroke-linecap: round; stroke-linejoin: round; }
.cx-map__pass {
fill: none; stroke: var(--accent); stroke-width: calc(4.5px * var(--m)); stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100 100; stroke-dashoffset: 100; opacity: 0;
transition: opacity .25s var(--cx-ease);
}
.cx-map.is-walking .cx-map__pass { opacity: 1; animation: cx-map-walk 1.1s var(--cx-ease) forwards; }
@keyframes cx-map-walk { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
/* street names, set along their streets with a halo of the land */
.cx-map__name {
font: 500 calc(11.5px * var(--m)) / 1 var(--font-text); letter-spacing: .01em;
fill: var(--map-name); stroke: var(--map-land); stroke-width: calc(3px * var(--m)); stroke-linejoin: round; paint-order: stroke;
pointer-events: none; user-select: none;
}
.cx-map__name.is-major { font-weight: 600; fill: color-mix(in oklab, var(--ink) 74%, var(--bg)); }
/* the pin, the station and the walk time */
.cx-map__pin { position: absolute; left: 64%; top: 42%; z-index: 2; width: 0; height: 0; }
.cx-map__dot {
position: absolute; left: -10px; top: -10px; width: 20px; height: 20px; border-radius: 99px;
background: var(--accent); border: 3px solid var(--bg);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink) 18%, transparent), 0 6px 16px -4px rgb(0 0 0 / .35);
}
.cx-map__halo {
position: absolute; left: -36px; top: -36px; width: 72px; height: 72px; border-radius: 99px;
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent) 30%, transparent), transparent);
transform: scale(.8); opacity: .8;
}
/* the halo answers once when the walk is drawn, then rests */
.cx-map.is-walking .cx-map__halo { animation: cx-map-breathe 1.2s var(--cx-ease) .9s 1; }
@keyframes cx-map-breathe { 0%, 100% { transform: scale(.8); opacity: .8; } 45% { transform: scale(1.25); opacity: .35; } }
.cx-map__tag {
position: absolute; left: 18px; top: -15px; white-space: nowrap; height: 30px; display: inline-flex; align-items: center;
padding: 0 11px; border-radius: var(--r-md); background: var(--bg); color: var(--ink); font-size: 14px; font-weight: 600;
box-shadow: var(--cx-shadow);
}
.cx-map__pin[data-side="l"] .cx-map__tag { left: auto; right: 18px; }
.cx-map__pin[data-side="t"] .cx-map__tag { left: 0; top: -50px; transform: translateX(-50%); }
.cx-map__pin[data-side="b"] .cx-map__tag { left: 0; top: 20px; transform: translateX(-50%); }
.cx-map__stop { position: absolute; z-index: 1; width: 0; height: 0; pointer-events: none; }
.cx-map__stop i {
position: absolute; left: -7px; top: -7px; width: 14px; height: 14px; border-radius: 4px;
background: var(--ink); border: 2px solid var(--bg); box-shadow: 0 1px 4px rgb(0 0 0 / .25);
}
.cx-map__stop b {
position: absolute; left: 13px; top: -11px; white-space: nowrap; height: 22px; display: inline-flex; align-items: center;
padding: 0 7px; border-radius: 6px; background: color-mix(in oklab, var(--bg) 88%, transparent);
font: 500 12.5px/1 var(--font-text); color: var(--ink);
}
.cx-map__stop[data-side="l"] b { left: auto; right: 13px; }
.cx-map__stop[data-side="t"] b { left: 0; top: -36px; transform: translateX(-50%); }
.cx-map__stop[data-side="b"] b { left: 0; top: 13px; transform: translateX(-50%); }
.cx-map__walk {
position: absolute; z-index: 1; transform: translate(-50%, -50%); display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 10px 0 8px; border-radius: 99px; white-space: nowrap; pointer-events: none;
background: var(--accent); color: var(--accent-ink, #fff); font: 600 12.5px/1 var(--font-text);
box-shadow: 0 2px 8px -2px rgb(0 0 0 / .3);
}
.cx-map__walk svg circle { fill: currentColor; }
.cx-map__walk[hidden] { display: none; }
.cx-map__scale {
position: absolute; right: var(--s-5); bottom: var(--s-5); z-index: 2; display: grid; justify-items: end; gap: 5px;
font: 500 11px/1 var(--font-text); color: var(--map-name);
}
.cx-map__scale span { width: 72px; height: 6px; border: 1.5px solid var(--map-name); border-top: 0; }
.cx-map__credit {
position: absolute; right: 0; bottom: 0; z-index: 2; padding: 3px 8px; border-top-left-radius: 6px;
font: 400 10.5px/1.3 var(--font-text); color: var(--map-name); text-decoration: none;
background: color-mix(in oklab, var(--map-land) 80%, transparent);
}
.cx-map__credit:hover { color: var(--ink); text-decoration: underline; }
.cx-map__scale { bottom: calc(var(--s-5) + 8px); }
.cx-map__card {
position: absolute; left: var(--s-5); bottom: var(--s-5); z-index: 3; width: min(340px, calc(100% - 48px));
display: grid; gap: var(--s-2); padding: var(--s-5); border-radius: var(--r-md);
background: var(--bg); box-shadow: var(--cx-shadow);
}
.cx-map__addr { font-style: normal; font-size: var(--t-small); line-height: 1.55; color: var(--ink); }
.cx-map__line { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--t-label); color: var(--muted); }
.cx-map__hours { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--t-label); color: var(--muted); }
.cx-map__open { width: 7px; height: 7px; border-radius: 9px; background: color-mix(in oklab, var(--accent) 85%, var(--ink)); }
.cx-map__actions { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-3); }
.cx-map__coords { display: none; }
@media (prefers-reduced-motion: reduce) {
.cx-map.is-walking .cx-map__halo { animation: none; }
.cx-map.is-walking .cx-map__pass { animation: none; opacity: 0; }
}
/* under 900px (a tablet, a phone, a half column) the card sits below the map, so it never covers the
pin, the walk or the scale bar, and the map keeps the whole width */
@container (max-width: 899px) {
.cx-map__ground { height: max(340px, 50cqw); }
.cx-map__card {
position: relative; left: auto; bottom: auto; width: auto; border-radius: 0; box-shadow: none; border-top: 1px solid var(--cx-line);
grid-template-columns: 1fr auto; column-gap: var(--s-6); align-items: end;
}
.cx-map__card > * { grid-column: 1; }
.cx-map__actions { grid-column: 2; grid-row: 1 / span 5; align-self: end; margin-top: 0; }
.cx-map__scale { bottom: calc(var(--s-4) + 8px); right: var(--s-4); }
}
@container (max-width: 559px) {
.cx-map__ground { height: 340px; }
.cx-map__card { grid-template-columns: 1fr; }
.cx-map__actions { grid-column: 1; grid-row: auto; margin-top: var(--s-3); }
.cx-map__actions .btn { flex: 1; }
}
@@ -0,0 +1,21 @@
<section class="cx cx-map" aria-label="Where to find us" data-cx-map="fx-map/sample-map.json">
<div class="cx-map__ground">
<svg class="cx-map__svg" role="img" aria-label="Street map of Leith: Harbour Works on Sandport Street"></svg>
<div class="cx-map__pin" aria-hidden="true">
<span class="cx-map__halo"></span>
<span class="cx-map__dot"></span>
<span class="cx-map__tag">Harbour Works</span>
</div>
<div class="cx-map__scale" aria-hidden="true"><span></span>200 m</div>
</div>
<div class="cx-map__card">
<span class="cx-eyebrow">Studio</span>
<h3 class="cx-title">Harbour Works</h3>
<address class="cx-map__addr">Sandport Street, Leith<br>Edinburgh</address>
<p class="cx-map__line"><span class="cx-map__open" aria-hidden="true"></span>Open today, 9:00 to 18:00</p>
<div class="cx-map__actions">
<a class="btn btn--primary btn--sm" href="https://www.openstreetmap.org/search?query=Sandport%20Street%20Leith" target="_blank" rel="noopener">Get directions<span class="cx-sr"> (opens OpenStreetMap)</span></a>
<button class="btn btn--secondary btn--sm" type="button" data-copy="Sandport Street, Leith, Edinburgh">Copy address</button>
</div>
</div>
</section>
@@ -0,0 +1,263 @@
// fx-map: a street map of the real place, in the page's own colours. The streets, water, parks, rails,
// street names and the walk from the nearest station come from OpenStreetMap, baked once at build time
// by bake.mjs into a small JSON (inline in <script type="application/json" class="cx-map__data">, or a
// file named by data-cx-map). Plain SVG: no map service, no key, no WebGL, nothing moves at rest.
// Pointed at, the walk lights up from the station to the door and the pin's halo answers once.
// Without baked data (no network at build time) it falls back to a still effect ground with the pin.
import { still, palette, tone, bag, color, isDark, reduced } from '../shared.js';
const NS = 'http://www.w3.org/2000/svg';
let uid = 0;
// the fallback grounds, each with its own params only (one effect's numbers mean something else to another)
const PARAMS = {
'contour-lines': narrow => ({ levels: 30, scale: narrow ? 1.6 : 1.25, lineWidth: 1, indexEvery: 5, opacity: 0.32, speed: 0, tint: 0.45, shade: 0.35, edge: 0.4, calm: narrow ? 0 : 0.55, calmX: 0.17, calmY: 0.78 }),
'dot-terrain': narrow => ({ spacing: narrow ? 11 : 13, dot: 2.4, amplitude: 0.2, waveScale: 1.1, speed: 0, opacity: 0.9, shade: 0.8, crest: 0.7, horizon: 0.16, fog: 0.35, haze: 0.25, calm: narrow ? 0 : 0.55, calmX: 0.17, calmY: 0.8 }),
'ruled-grounds': narrow => ({ cell: narrow ? 14 : 16, major: 4, ink: 0.85, focus: 0.5, speed: 0, reach: 0.85, grain: 0.35, calm: narrow ? 0 : 0.45, calmX: 0.17, calmY: 0.78 }),
};
// bottom to top; lines are stroked, areas filled
const LAYERS = [
['sea', 'area'], ['land', 'area'], ['green', 'area'], ['plaza', 'area'], ['water', 'area'],
['stream', 'line'], ['canal', 'line'], ['river', 'line'],
['path', 'line'], ['rail', 'line'], ['tram', 'line'], ['minor', 'line'],
['street', 'casing'], ['major', 'casing'], ['street', 'line'], ['major', 'line'],
];
const NICE = [50, 100, 200, 250, 500, 1000, 2000];
async function readData(root) {
const inline = root.querySelector('script.cx-map__data[type="application/json"]');
if (inline) { try { return JSON.parse(inline.textContent); } catch (e) { console.warn('fx-map: bad inline data', e); } }
const src = root.dataset.cxMap;
if (!src) return null;
try { const r = await fetch(new URL(src, document.baseURI)); return r.ok ? await r.json() : null; } catch (e) { console.warn('fx-map: no map data', e); return null; }
}
const el = (name, attrs = {}) => { const n = document.createElementNS(NS, name); for (const k in attrs) n.setAttribute(k, attrs[k]); return n; };
export default function init(root) {
const b = bag();
let dead = false;
const ground = root.querySelector('.cx-map__ground');
const svg = root.querySelector('.cx-map__svg');
const pin = root.querySelector('.cx-map__pin');
const tag = root.querySelector('.cx-map__tag');
const scale = root.querySelector('.cx-map__scale');
const card = root.querySelector('.cx-map__card');
b.add(() => { dead = true; });
root.classList.toggle('is-dark', isDark(palette(root)));
readData(root).then(data => {
if (dead) return;
if (data && data.box && svg) draw(data); else fallback();
});
/* ------------------------------------------------------------------ the street map */
function draw(data) {
root.classList.add('has-streets');
const id = `cxm${++uid}`;
const [bx, by, bw, bh] = data.box;
svg.setAttribute('preserveAspectRatio', 'none');
svg.replaceChildren();
const g = el('g');
g.append(el('rect', { class: 'cx-map__land', x: bx, y: by, width: bw, height: bh }));
for (const [name, kind] of LAYERS) {
if (!data[name]) continue;
g.append(el('path', { class: `cx-map__${name} is-${kind}`, d: data[name] }));
}
// the walk: a dotted line at rest, a solid pass drawn over it when pointed at
let walk = null, pass = null, stop = null, chip = null;
if (data.route && data.route.d) {
// the bake writes the walk in rounded relative steps, so its end drifts a few metres off the door:
// close it on the pin (0 0) so the walk reaches the dot
const rd = `${data.route.d} L0 0`;
g.append(el('path', { class: 'cx-map__route-casing', d: rd }));
walk = el('path', { class: 'cx-map__route', d: rd });
pass = el('path', { class: 'cx-map__pass', d: rd, pathLength: 100 });
g.append(walk, pass);
}
// street names along their streets
const defs = el('defs');
const names = (data.labels || []).map((l, i) => {
const pid = `${id}-s${i}`;
defs.append(el('path', { id: pid, d: l.d }));
const t = el('text', { class: `cx-map__name is-${l.cls}` });
const tp = el('textPath', { href: `#${pid}`, startOffset: '50%', 'text-anchor': 'middle' });
tp.textContent = l.name;
t.append(tp);
return { t, len: l.len, name: l.name };
});
g.prepend(defs);
const nameLayer = el('g', { class: 'cx-map__names' });
for (const n of names) nameLayer.append(n.t);
g.append(nameLayer);
svg.append(g);
if (data.route && data.route.from) {
stop = document.createElement('span');
stop.className = 'cx-map__stop';
stop.setAttribute('aria-hidden', 'true');
stop.innerHTML = '<i></i><b></b>';
stop.querySelector('b').textContent = data.route.from.name;
chip = document.createElement('span');
chip.className = 'cx-map__walk';
chip.setAttribute('aria-hidden', 'true');
chip.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="9" cy="2.6" r="1.6"/><path d="M8.2 5.2 6 6.6 5 9.4m3.2-4.2 1.1 4 2.4 1.6M9.3 9.2 7.6 12.4 6.2 14.6m3.1-5.4 1 3.2 1.2 2.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span></span>';
chip.querySelector('span').textContent = `${data.route.min} min walk`;
ground.append(stop, chip);
b.add(() => { stop.remove(); chip.remove(); });
}
// fit: the place and the walk in the free part of the frame (right of the card when it sits on
// the map), at a zoom where streets still read as streets
const layout = () => {
if (dead) return;
const W = ground.clientWidth, H = ground.clientHeight;
if (!W || !H) return;
const narrow = root.clientWidth < 560;
const R = ground.getBoundingClientRect();
let fx0 = narrow ? 20 : 48, fy0 = 44, fx1 = W - 40, fy1 = H - 56;
// the card covers the map only from 900px of width (the CSS container query); then the free part is right of it
if (root.clientWidth >= 900 && card) { const c = card.getBoundingClientRect(); if (c.right - R.left < W * 0.6) fx0 = c.right - R.left + 48; }
const from = data.route && data.route.from;
// the pin's name and the station's name sit on the side the walk does not leave by; on a narrow
// frame above or below, so the walk keeps the width
const sideAway = (vx0, vy0) => {
if (Math.abs(vx0) >= Math.abs(vy0) && !narrow) return vx0 > 0 ? 'l' : 'r';
return vy0 > 0 ? 't' : 'b';
};
let tagSide = narrow ? 't' : 'r', stopSide = 'r';
if (walk) {
const L = walk.getTotalLength();
const a = walk.getPointAtLength(Math.max(0, L - 70)), z = walk.getPointAtLength(Math.min(L, 70));
tagSide = sideAway(a.x, a.y);
stopSide = sideAway(z.x - from.x, z.y - from.y);
}
// each mark with its name as a box in pixels around its point
const boxOf = (side, w, h, r) => side === 'r' ? [-r, -h / 2, r + 8 + w, h / 2] : side === 'l' ? [-(r + 8 + w), -h / 2, r, h / 2]
: side === 't' ? [-w / 2, -(h + r + 8), w / 2, r] : [-w / 2, -r, w / 2, h + r + 8];
const marks = [[0, 0, boxOf(tagSide, tag ? tag.offsetWidth : 120, 30, 12)]];
if (from) marks.push([from.x, from.y, boxOf(stopSide, stop.querySelector('b').offsetWidth, 22, 8)]);
const fw = Math.max(80, fx1 - fx0), fh = Math.max(80, fy1 - fy0);
const extent = mm => {
const xs = marks.flatMap(([x, , bx4]) => [x / mm + bx4[0], x / mm + bx4[2]]), ys = marks.flatMap(([, y, bx4]) => [y / mm + bx4[1], y / mm + bx4[3]]);
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
};
const fits = mm => { const e = extent(mm); return e[2] - e[0] <= fw && e[3] - e[1] <= fh; };
// the closest zoom (streets still read as streets) at which the marks and their names fit
const minM = narrow ? 1.1 : 1.05, maxM = Math.min(3, bw / W, bh / H);
let m = minM;
if (!fits(minM)) {
let lo = minM, hi = maxM;
if (!fits(hi)) m = hi; else { for (let i = 0; i < 18; i++) { const mid = (lo + hi) / 2; if (fits(mid)) hi = mid; else lo = mid; } m = hi; }
}
const e = extent(m);
let vx = -(((fx0 + fx1) / 2) - (e[0] + e[2]) / 2) * m, vy = -(((fy0 + fy1) / 2) - (e[1] + e[3]) / 2) * m;
vx = Math.min(Math.max(vx, bx), bx + bw - W * m);
vy = Math.min(Math.max(vy, by), by + bh - H * m);
svg.setAttribute('viewBox', `${vx} ${vy} ${W * m} ${H * m}`);
// strokes and type in screen pixels: --m is metres per pixel; --k thins the lines when zoomed out
svg.style.setProperty('--m', m.toFixed(4));
svg.style.setProperty('--k', Math.min(1.15, Math.max(0.62, 1.25 / m)).toFixed(3));
const at = ([x, y]) => [(x - vx) / m, (y - vy) / m];
const [px, py] = at([0, 0]);
pin.style.left = `${px}px`; pin.style.top = `${py}px`;
pin.dataset.side = tagSide;
if (walk) walk.style.strokeDasharray = `0 ${(9 * m).toFixed(2)}`;
if (stop) {
const [sx, sy] = at([from.x, from.y]);
stop.style.left = `${sx}px`; stop.style.top = `${sy}px`;
stop.dataset.side = stopSide;
}
if (chip && walk) {
const L = walk.getTotalLength(), mid = walk.getPointAtLength(L * 0.5);
const [hx, hy] = at([mid.x, mid.y]);
chip.style.left = `${hx}px`; chip.style.top = `${hy}px`;
}
// the scale bar: a round distance 60 to 130 px long
if (scale) {
const d = NICE.find(n => n / m >= 60) || NICE[NICE.length - 1];
scale.querySelector('span').style.width = `${Math.round(d / m)}px`;
scale.lastChild.nodeValue = d >= 1000 ? `${d / 1000} km` : `${d} m`;
}
// names: only those that fit their street, stay inside the frame and clear of every label
const marksEls = [tag, root.querySelector('.cx-map__dot'), stop && stop.querySelector('b'), stop && stop.querySelector('i')].filter(Boolean);
const block = [...marksEls, chip, scale, root.querySelector('.cx-map__credit'), root.clientWidth >= 900 ? card : null]
.filter(Boolean).map(n => n.getBoundingClientRect()).filter(r => r.width);
const hit = (a, r, pad) => a.left < r.right + pad && a.right > r.left - pad && a.top < r.bottom + pad && a.bottom > r.top - pad;
const kept = [], shown = new Set();
const most = narrow ? 3 : 6;
for (const n of names) {
n.t.style.display = '';
let ok = kept.length < most && !shown.has(n.name);
if (ok) { try { ok = n.t.getComputedTextLength() < n.len * 0.94; } catch { ok = false; } }
if (ok) {
const r = n.t.getBoundingClientRect();
ok = r.width > 0 && r.left > R.left + 10 && r.right < R.right - 10 && r.top > R.top + 10 && r.bottom < R.bottom - 10 &&
!block.some(x => hit(r, x, 8)) && !kept.some(x => hit(r, x, 14));
if (ok) { kept.push(r); shown.add(n.name); }
}
if (!ok) n.t.style.display = 'none';
}
if (chip) {
const c = chip.getBoundingClientRect();
chip.hidden = false;
chip.hidden = marksEls.some(n => hit(c, n.getBoundingClientRect(), 4));
}
};
layout();
let raf = 0;
const ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(layout); });
ro.observe(ground);
if (card) ro.observe(card);
b.add(() => { ro.disconnect(); cancelAnimationFrame(raf); svg.replaceChildren(); root.classList.remove('has-streets', 'is-walking'); });
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { if (!dead) layout(); });
// pointed at: the walk is drawn from the station to the door, the halo answers once
if (pass && !reduced()) {
let t = 0;
const on = () => { clearTimeout(t); root.classList.remove('is-walking'); void pass.getBoundingClientRect(); root.classList.add('is-walking'); };
const off = () => { clearTimeout(t); t = setTimeout(() => root.classList.remove('is-walking'), 260); };
ground.addEventListener('pointerenter', on);
ground.addEventListener('pointerleave', off);
const btns = [...root.querySelectorAll('.cx-map__actions a')];
for (const a of btns) { a.addEventListener('pointerenter', on); a.addEventListener('focus', on); a.addEventListener('blur', off); a.addEventListener('pointerleave', off); }
b.add(() => {
clearTimeout(t);
ground.removeEventListener('pointerenter', on); ground.removeEventListener('pointerleave', off);
for (const a of btns) { a.removeEventListener('pointerenter', on); a.removeEventListener('focus', on); a.removeEventListener('blur', off); a.removeEventListener('pointerleave', off); }
});
}
}
/* ------------------------------------------------------------------ no data: a still ground */
function fallback() {
root.classList.add('has-ground');
if (svg) svg.remove();
const page = palette(root);
const effect = PARAMS[root.dataset.cxEffect] ? root.dataset.cxEffect : 'contour-lines';
const narrow = root.clientWidth < 560;
const bg = tone(page, 0.065);
b.add(still(ground, effect, {
palette: { ...page, bg, ...(effect === 'dot-terrain' ? { ink: color.mix(page.ink, bg, 0.25) } : {}) },
seed: root.dataset.cxSeed,
params: PARAMS[effect](narrow),
}));
pin.style.left = 'var(--x, 64%)';
pin.style.top = 'var(--y, 42%)';
}
/* ------------------------------------------------------------------ copy address */
const btn = root.querySelector('[data-copy]');
if (btn) {
const label = btn.textContent;
let t = 0;
const copy = async () => {
try { await navigator.clipboard.writeText(btn.dataset.copy || (root.querySelector('address') || {}).innerText || ''); btn.textContent = 'Copied'; }
catch (_) { btn.textContent = 'Select and copy'; }
clearTimeout(t);
t = setTimeout(() => { btn.textContent = label; }, 1600);
};
btn.addEventListener('click', copy);
b.add(() => { clearTimeout(t); btn.textContent = label; btn.removeEventListener('click', copy); });
}
return b;
}
@@ -0,0 +1,20 @@
{
"id": "fx-map",
"name": "Street map of the place",
"for": "A contact or visit section: the real streets, water, parks and rails around the place from OpenStreetMap, drawn in the page's colours, with the pin, a scale bar and an address card with working actions. No map service at run time, no key, no tracking.",
"effects": [
"contour-lines",
"ruled-grounds",
"dot-terrain"
],
"default": "none",
"how": "Bake the place once: node assets/components/fx-map/bake.mjs --lat <lat> --lon <lon> --out map.json (one request to an Overpass server; no walk by default: --from auto or --from lat,lon --from-name \"Name\" draws a walk only when the brief names a station). Paste the JSON inline as <script type=\"application/json\" class=\"cx-map__data\">...</script> inside the section, or name the file with data-cx-map. With a walk, the bake prints it in minutes and the station: write the same in the card and in the svg's aria-label. The card gives the street and district only, never a house number, postcode or coordinates. The part fits the pin, the walk and both names into the free part of the frame (right of the card from 900px of width, the whole map below it), sets street names only where they fit, and keeps every mark inside the frame. Light pages get white streets on a land a step darker than the page, dark pages lighter streets on a land a step lighter; water and parks keep a natural blue and green mixed into the land. The effects listed are only the fallback when there is no baked data (the bake could not reach the network): report it.",
"rules": [
"The pin is the real place: the bake's lat and lon are the address, and the address is a real street near it.",
"Get directions opens a search for the street and district in a real map service (openstreetmap.org/search?query=Street%20District), in a new tab: never coordinates and never a route.",
"The OpenStreetMap credit (ODbL) goes in the page's one footer credit line, never on the map and never as a caption.",
"Nothing moves at rest. Pointed at (or its directions link focused), the walk is drawn from the station to the door and the halo answers once; nothing on scroll, so no pause button.",
"An address the client has not given yet is a placeholder listed in the report, never a 'sample' tag on the card."
],
"a11y": "A labelled region with a real address element and buttons; the svg has role img and an aria-label that names the place, the street and the walk. The pin, names and walk chip are decorative. Copy address announces by its own label change. Reduced motion: no drawn walk, no halo."
}
@@ -0,0 +1 @@
{"v":1,"place":{"lat":55.9765,"lon":-3.1717},"box":[-1500,-1000,3000,2000],"sea":"M1500 179l-33 -20 -15 -8 -16 -10 -74 -47 -77 -50 2 -7 -7 -34 -2 -15 2 -12 -6 -1 -8 -9 -8 -5 -11 0 -74 -98 -57 -66 -51 -42 -44 -45 -25 -30 -9 -32 -41 -190 -24 -40 -35 -31 -17 -9 -22 -8 -89 -70 -48 -21 -56 -48 -28 -29 -10 -14 -12 -15 -16 -13 -35 -16 -35 -4 -39 -5 -84 -50 -91 -57 -47 -28 1242 0zM-271 -1000l19 21 20 10 -6 9 29 15 -2 5 146 76 1 2 1 1 0 2 0 2 -3 6 1 1 -7 11 -1 -1 -6 9 0 1 0 2 1 2 2 1 9 6 3 -1 5 2 5 4 -1 2 80 50 6 1 5 4 0 1 6 3 2 0 2 -1 14 -22 0 -2 0 -1 0 -2 -8 -8 -1 1 -5 -4 -3 -4 0 -2 -10 -10 -2 2 -105 -105 21 -20 10 10 -3 3 5 5 3 -2 90 91 -2 2 9 9 1 0 4 4 4 4 -1 1 5 4 1 1 1 0 1 -1 1 -1 5 -7 1 -1 2 0 1 0 480 302 2 9 -42 63 -1 1 -1 0 -2 0 -197 -127 -2 0 -2 0 -2 1 -36 57 -1 2 -1 2 0 3 1 10 -18 1 -1 -13 0 -2 -1 -2 -1 -2 -266 -167 0 -1 -1 -1 1 -1 1 1 14 -22 0 -1 0 -1 0 -1 -1 -1 -174 -90 -2 -1 -3 0 -2 1 -1 1 -3 4 -1 2 0 2 0 2 157 257 141 48 2 1 0 1 0 2 -13 32 1 2 0 2 1 2 2 1 125 51 3 0 2 -1 1 -2 27 -41 0 -2 1 -1 0 -2 -2 -14 18 0 1 10 2 2 1 1 2 2 267 166 0 2 0 1 -1 1 -7 12 -1 1 1 2 1 1 63 40 2 0 2 0 2 0 1 -2 38 -61 2 -1 1 0 2 0 1 1 379 237 0 2 1 1 -1 1 -29 46 -1 1 -1 0 -1 0 -251 -158 -2 0 -2 0 -2 1 -13 22 0 1 0 2 1 1 1 1 1 -1 6 4 7 5 1 -3 63 39 -1 1 2 2 -2 3 1 1 1 2 0 1 0 2 -1 1 -1 1 -1 0 -2 0 -1 0 -3 3 -2 -2 -2 3 -62 -39 2 -3 -6 -4 -7 -4 1 -1 -1 -1 -1 -1 -2 1 -1 0 -1 2 -14 21 -35 53 -125 -81 48 -78 0 -2 0 -1 0 -2 -1 -1 -64 -40 -1 -1 -1 0 -1 0 -1 1 -51 80 -79 -50 -118 -74 -30 -19 12 -27 1 -2 1 -2 1 -2 -1 -2 0 -2 -2 -2 -22 -14 -21 -13 -1 -1 0 -1 -1 -1 1 -1 0 -1 0 -1 0 -2 0 -1 -1 -1 -32 -13 -90 -37 -5 0 -70 -28 -1 -1 -1 0 -1 1 -12 8 0 1 0 1 0 1 0 1 24 40 2 1 2 1 82 34 112 31 4 2 2 3 1 3 0 4 -1 3 -2 3 -3 3 -3 1 -5 0 -12 -4 -77 -22 -1 -1 0 -1 -5 -2 -1 1 -1 1 -20 -6 -6 12 0 1 0 1 6 2 0 -1 7 2 8 3 1 -3 76 22 0 -1 8 9 1 2 1 1 -1 2 -1 2 -1 1 -1 0 -13 4 0 -1 -40 -12 -34 -9 1 -3 -9 -3 -15 -4 -1 0 -1 0 0 1 -1 1 -33 -10 -34 -9 -6 2 -1 -2 -1 -1 -1 -1 -1 0 -2 -1 -1 0 -1 0 -1 1 0 2 -1 2 -3 -8 -1 -1 -1 -2 -2 0 -1 -1 -13 -3 -1 -1 -1 -1 -2 0 -1 1 -1 0 0 1 -3 9 -3 10 0 1 1 1 1 1 1 0 1 0 1 0 4 1 4 2 3 3 2 3 1 3 3 80 -1 2 -2 2 -3 1 -3 0 -213 -65 -1 -1 -1 -1 0 -1 0 -1 14 -42 11 -39 1 -1 0 -1 1 0 0 1 27 8 125 36 0 1 1 1 0 1 1 0 1 0 1 0 1 0 1 -1 3 -10 3 -10 0 -1 -1 -1 -1 0 -1 -1 -1 1 -1 0 -1 1 -6 -3 8 -18 -56 -94 -58 -18 -16 -59 -3 -13 -7 -27 -11 -42 -53 -95 -5 -1 -281 269 2 2 -28 21 -6 -5 -21 -18 -89 -77 -90 -78 -76 -64 -1 -1 -1 0 -1 0 -1 0 -2 0 0 1 -2 2 -26 30 -77 87 -27 -24 -19 -18 66 -74 4 -5 -1 -15 9 -12 -8 -22 13 -20 16 0 159 -264zM-1399 -1000l-45 32 -41 30 -12 13 -3 4 0 -79zM-1500 -635l5 0 0 -3 0 -3 30 0 0 24 -2 66 -4 0 -4 0 -2 47 -23 7z","land":"","water":"M-1246 994l-9 -11 62 -36 38 -9 38 -19 11 13 -21 12 -57 14 -62 36zM-1241 658l9 -19 35 -41 21 -16 19 -23 12 -22 18 -9 31 -8 58 -5 62 -1 -1 16 -1 -2 -23 -4 -6 2 -10 -2 -95 8 -15 6 -7 6 -30 41 -25 22 -33 34 -10 17 -9 0zM-1483 1000l37 -15 62 -8 26 1 80 14 23 -9 9 11 -15 6 -29 0 -13 -5 -19 -4 -62 -4 -34 2 -40 11zM-885 540l-39 -12 -53 2 1 -16 70 7 13 5 8 14zM-842 560l-3 -1 1 -4 -4 -3 -14 -2 -23 -10 -8 -14 36 14 11 1 9 -1 45 -22 4 12 -40 16 -14 14zM-240 -715l-281 269 2 2 -28 21 -283 -243 -5 1 -105 119 -46 -42 70 -79 -1 -15 9 -12 -8 -22 13 -20 16 0 159 -264 328 0 24 76 136 209zM-612 505l-1 -3 -19 3 -3 -14 51 -8 14 -5 39 -22 24 -29 5 -10 1 -17 -4 -9 15 -3 3 15 -10 27 -15 25 -13 10 -41 23 -46 17zM-788 530l-4 -12 19 -6 50 -8 11 -6 69 -2 8 -5 3 14 -20 6 -21 2 -17 -4 -26 2 -72 19zM-92 -95l55 17 -2 4 -54 -17 1 -4zM33 -325l67 19 -2 5 5 1 7 16 -1 13 12 37 9 -3 14 46 -1 5 2 0 5 28 0 16 -14 104 -3 -1 -10 53 -6 17 -36 53 2 1 -24 32 -43 35 -16 3 -13 -4 -13 7 -81 13 -1 4 -4 -1 -20 -26 4 -2 0 -2 -48 -1 -9 -3 -2 -7 -93 -16 -24 1 -11 4 -62 46 -34 20 -64 29 -6 6 1 3 -18 17 -5 -4 -34 23 -44 35 -2 12 5 8 25 25 -1 6 16 14 34 14 14 12 -15 3 -12 -9 -47 -14 -6 -10 -6 0 -7 -7 -8 1 -13 -30 1 -22 6 -15 8 -9 68 -49 84 -54 10 -10 5 0 39 -27 2 1 26 -21 29 -13 14 -17 50 2 79 23 18 11 57 4 2 -2 15 0 4 -2 45 -4 11 -5 3 -4 53 -21 17 -11 21 -27 3 2 14 -37 0 -16 16 -41 16 -62 -9 -1 -10 -11 -57 -5 -59 1 0 -11 58 0 8 -2 6 -5 16 -35 7 -9 7 -2 1 -21 -9 -29 7 -2 2 2 2 -3 -5 -18 -44 -70zM-33 -324l-4 1 -2 -3 -152 -45 -27 84 2 3 216 65 6 -5 -3 -80 -6 -9 -30 -11zM50 -822l9 9 -4 10 -12 5 -10 -10 -2 2 -105 -105 21 -20 10 10 -3 3 5 5 3 -2 90 91 -2 2zM617 -201l-9 16 1 6 -48 78 125 81 51 -76 16 10 -2 3 62 39 12 -5 0 -13 -63 -39 -1 3 -15 -9 -1 -4 13 -22 4 -1 253 158 3 -1 29 -46 0 -4 -380 -238 -5 1 -38 61 -7 2zM238 -379l-9 15 1 9 44 28 2 4 0 4 -14 31 227 143 51 -80 4 0 10 -16 -1 -4 8 -16 -269 -168 -3 -3 -1 -10 -18 0 1 19 -27 41 -6 3zM55 -803l5 -10 13 14 8 -9 4 -1 480 302 2 9 -42 63 -4 1 -199 -127 -4 1 -36 57 -1 17 -18 1 -3 -19 -266 -167 0 -3 36 -55 -3 -6 -14 -9 10 -7 2 -11 17 9 4 -1 14 -22 0 -5 -17 -15 12 -7zM118 -304l-1 -8 5 -6 8 3 1 -3 76 21 8 9 1 5 -3 3 -13 4 -74 -22 1 -3 -9 -3zM910 -364l-5 4 3 3 -1 7 7 7 7 -1 -1 6 4 1 7 -8 2 -7 -6 -3 -9 1 -7 -4 -1 -6zM929 -368l0 8 8 3 -3 -12 -5 1zM936 -339l-4 5 8 0 0 4 -4 2 4 2 -1 11 15 -8 -10 -9 1 -6 -2 -Line truncated
@@ -0,0 +1,30 @@
/* fx-photo: the section's own photograph in natural colour, the copy in a solid card inset in its corner
(never a gradient scrim over the picture). .is-graded (data-cx-grade="duotone"): the picture graded into
the page tones. */
.cx-photo__frame {
position: relative; isolation: isolate; overflow: hidden; display: flex; align-items: flex-end;
aspect-ratio: 16 / 8; min-height: 380px; border-radius: var(--r-lg); background: var(--cx-panel);
}
.cx-photo__frame > img {
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 60% 62%; z-index: -2;
transition: transform 1.4s var(--cx-ease);
}
/* no scrim and no word straight on the picture: the copy stands in a solid card inset in the corner */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.cx-photo:not(.is-graded) .cx-photo__frame:hover > img { transform: scale(1.03); }
}
.cx-photo__copy { position: relative; display: grid; justify-items: start; gap: var(--s-3); max-width: 460px; margin: var(--s-6); padding: var(--s-6); border-radius: var(--r-md, var(--r-lg)); background: var(--bg); }
.cx-photo__eyebrow { font-size: var(--t-label); font-weight: 500; color: var(--ink); opacity: .8; }
.cx-photo__title { font: 500 clamp(24px, 1.2vw + 16px, 32px)/1.15 var(--font-display); letter-spacing: -0.02em; margin: 0; color: var(--ink); text-wrap: balance; }
.cx-photo__link {
display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; margin-top: var(--s-2);
border-radius: var(--r-btn); background: var(--bg); color: var(--ink); font-size: var(--t-small); font-weight: 500;
border: 1px solid var(--cx-line-2);
}
.cx-photo__link::after { content: "\2192"; transition: transform var(--dur) var(--ease); }
.cx-photo__link:hover::after { transform: translateX(3px); }
@media (max-width: 639px) {
.cx-photo__frame { aspect-ratio: 4 / 5; min-height: 0; }
.cx-photo__copy { margin: var(--s-4); padding: var(--s-5); max-width: none; }
.cx-photo__frame > img { object-position: 62% 50%; }
}
@@ -0,0 +1,10 @@
<article class="cx cx-photo">
<div class="cx-photo__frame" data-cx-seed="2">
<img data-fx-photo src="fx-photo/meeting-room.jpg" alt="A meeting room in a coworking space: a wooden table with white chairs by tall windows" width="1920" height="1280">
<div class="cx-photo__copy">
<span class="cx-photo__eyebrow">Customer story</span>
<h3 class="cx-photo__title">How a coworking space in Leeds bills 300 members on the same day each month</h3>
<a class="cx-photo__link" href="#">Read the story</a>
</div>
</div>
</article>
@@ -0,0 +1,30 @@
// fx-photo: the section's own photograph in its natural colour, as figma.com and x.ai show theirs: the
// copy sits on a soft dark scrim in its corner (white type, 4.5:1 over any picture), and pointed at, the
// picture eases in a little. No filter, no WebGL, nothing at rest.
// data-cx-grade="duotone" (an editorial page that prints every picture in its tones): duotone-grade reads
// the frame's <img data-fx-photo> and grades it into three page tones, drawn once and kept as a picture
// (still() in shared.js); a cool accent gets the tones without their colour, so it reads as a neutral print.
import { still, palette, color, bag } from '../shared.js';
export default function init(root) {
const b = bag();
const frame = root.querySelector('.cx-photo__frame');
// only the hand-set grade: a dealt effect (data-cx-effect) never filters a photograph
if (root.dataset.cxGrade !== 'duotone') return b;
// is-graded (page ink, no scrim) only once the graded picture is in place (below): until then the scrim and white type stay
b.add(() => root.classList.remove('is-graded'));
const page = palette(root);
const narrow = frame.clientWidth < 640;
const [, C, h] = color.lch(page.accent);
const neutral = C > 0.04 && h > 180 && h < 300;
const de = c => color.saturate(c, 0.12);
const pal = neutral ? { ...page, bg: de(page.bg), surface: de(page.surface), ink: de(page.ink), muted: de(page.muted), accent: de(page.accent) } : page;
b.add(still(frame, 'duotone-grade', {
// the graded picture replaces the photograph at once (a fade would show the ungraded photo under it)
fade: false,
palette: pal,
seed: frame.dataset.cxSeed,
params: { tones: 3, contrast: 0.42, exposure: 0.08, grain: 0.3, ink: neutral && color.isDark(page.bg) ? 1 : 0, scrim: 0.5, calm: 0.72, calmX: narrow ? 0.3 : 0.2, calmY: narrow ? 0.82 : 0.74, drift: 0 },
})).ready.then(() => root.classList.add('is-graded'));
return b;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

@@ -0,0 +1,16 @@
{
"id": "fx-photo",
"name": "Photograph with copy",
"for": "A story, team or place card built on the client's own photograph of the subject the copy names, in its natural colour, with the copy in a solid card inset in one corner, as figma.com and x.ai show their pictures.",
"effects": [],
"default": "none",
"how": "Put the photograph in the frame as <img data-fx-photo> with real alt text. Nothing is drawn over it, no scrim and no gradient: the copy stands in a solid card in the page's own ground and ink (never a word straight on the picture); pointed at, the picture eases in 3%. No dealt effect ever applies to a photograph (effects is empty on purpose, data-cx-effect is ignored). data-cx-grade=\"duotone\" on the article, set by hand, is only for an editorial page that prints every picture in its own tones: duotone-grade then grades it into three page tones, drawn once and kept as a picture (no WebGL context at rest).",
"rules": [
"Only a photograph of what the copy is about: the client's product, people or place, or a vetted photograph of the same kind of thing. Never a blank notebook, a laptop on a desk, a coffee cup or a handshake.",
"Never halftone, dither, dissolve or print filters over a photograph: the picture is shown as it is.",
"One photograph card per screen.",
"The photographer's credit goes in the page's one footer credit line, never under the picture."
],
"a11y": "The photograph keeps real alt text; the copy is a heading and a link. Nothing moves at rest; the ease-in only answers the pointer and is off under reduced motion.",
"photo": "meeting-room.jpg: Breather, Unsplash licence (https://unsplash.com/photos/DEdM9Vs6s8w), from the skill's vetted photo kit (assets/objects/photo-kit). A stand-in: replace with the client's own photograph."
}
@@ -0,0 +1,55 @@
/* fx-pricing: three plans; the recommended one has a border of the effect and a faint wash of it at the top */
.cx-pricing { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-4); align-items: stretch; }
.cx-plan {
position: relative; isolation: isolate; display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-6);
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line); box-shadow: inset 0 1px 0 var(--cx-hi);
}
.cx-plan > :not(.cx-plan__fx) { position: relative; z-index: 1; }
.cx-plan.is-featured { border-color: var(--action); box-shadow: 0 24px 64px -32px color-mix(in oklab, var(--accent) 45%, transparent); }
.cx-plan.is-featured.is-live { border-color: transparent; }
.cx-plan__fx {
position: absolute; inset: -1px; z-index: 0; padding: 1.5px; border-radius: calc(var(--r-lg) + 1px); pointer-events: none;
/* the ring (content box cut out of the border box) plus a soft wash down from the top */
-webkit-mask-image: linear-gradient(to bottom, rgb(0 0 0 / .2), transparent 150px), linear-gradient(#000 0 0), linear-gradient(#000 0 0);
-webkit-mask-clip: border-box, content-box, border-box;
-webkit-mask-origin: border-box, content-box, border-box;
-webkit-mask-composite: source-over, xor;
mask-image: linear-gradient(to bottom, rgb(0 0 0 / .2), transparent 150px), linear-gradient(#000 0 0), linear-gradient(#000 0 0);
mask-clip: border-box, content-box, border-box;
mask-origin: border-box, content-box, border-box;
mask-composite: add, exclude;
}
/* the badge drops under the name when the column is narrow, never squeezed onto two lines over it */
.cx-plan__top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s-2) var(--s-3); }
.cx-plan__name { font: 500 var(--t-card)/1.3 var(--font-text); letter-spacing: -0.01em; margin: 0; }
.cx-plan__badge {
display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 999px;
background: var(--action); color: var(--action-ink); font-size: 12px; font-weight: 500; white-space: nowrap;
}
.cx-plan__for { font-size: var(--t-small); line-height: 1.5; color: var(--muted); margin: 0; min-height: 3em; }
.cx-plan__price { display: flex; align-items: baseline; gap: 8px; margin: var(--s-4) 0 var(--s-2); flex-wrap: wrap; }
.cx-plan__amount { font: 560 44px/1 var(--font-display); letter-spacing: -0.035em; }
.cx-plan__per { font-size: var(--t-label); color: var(--muted); }
.cx-plan__cta { width: 100%; margin: var(--s-3) 0 var(--s-4); }
.cx-plan__list { display: grid; gap: 10px; align-content: start; margin: 0; padding: var(--s-5) 0 0; list-style: none; border-top: 1px solid var(--cx-line); }
.cx-plan__list li { position: relative; padding-left: 26px; font-size: var(--t-small); line-height: 1.45; }
.cx-plan__list li::before {
content: ""; position: absolute; left: 0; top: 2px; width: 16px; height: 16px; border-radius: 99px;
background: color-mix(in oklab, var(--accent) 20%, var(--cx-card));
}
.cx-plan__list li::after {
content: ""; position: absolute; left: 5px; top: 6px; width: 6px; height: 4px; border-left: 1.5px solid var(--ink); border-bottom: 1.5px solid var(--ink);
transform: rotate(-45deg);
}
@media (max-width: 899px) {
.cx-pricing { grid-template-columns: minmax(0, 1fr); max-width: 480px; margin-inline: auto; }
.cx-plan__for { min-height: 0; }
}
/* side by side, the plans share their rows (subgrid): a badge that drops under the name makes that row
taller in every plan, so the three prices, buttons and lists stay on one line */
@media (min-width: 900px) {
@supports (grid-template-rows: subgrid) {
.cx-plan { display: grid; grid-row: span 5; grid-template-rows: subgrid; row-gap: var(--s-2); align-content: start; }
.cx-plan__top, .cx-plan > .cx-plan__name { align-self: center; }
}
}
@@ -0,0 +1,40 @@
<div class="cx cx-pricing" data-cx-seed="35">
<article class="cx-plan">
<h3 class="cx-plan__name">Starter</h3>
<p class="cx-plan__for">For one person trying it on a side project.</p>
<p class="cx-plan__price"><span class="cx-plan__amount">$0</span><span class="cx-plan__per">per month</span></p>
<a class="btn btn--secondary cx-plan__cta" href="#">Start for free</a>
<ul class="cx-plan__list">
<li>1 project</li>
<li>10,000 requests a day</li>
<li>Community support</li>
</ul>
</article>
<article class="cx-plan is-featured" aria-describedby="cx-plan-badge">
<div class="cx-plan__fx" aria-hidden="true"></div>
<div class="cx-plan__top">
<h3 class="cx-plan__name">Team</h3>
<span class="cx-plan__badge" id="cx-plan-badge">Most teams pick this</span>
</div>
<p class="cx-plan__for">For a team shipping one product together.</p>
<p class="cx-plan__price"><span class="cx-plan__amount">$24</span><span class="cx-plan__per">per person, per month</span></p>
<a class="btn btn--primary cx-plan__cta" href="#">Start a 14-day trial</a>
<ul class="cx-plan__list">
<li>Unlimited projects</li>
<li>1 million requests a day</li>
<li>Roles and audit log</li>
<li>Email support, next working day</li>
</ul>
</article>
<article class="cx-plan">
<h3 class="cx-plan__name">Business</h3>
<p class="cx-plan__for">For companies with security and billing needs.</p>
<p class="cx-plan__price"><span class="cx-plan__amount">Custom</span><span class="cx-plan__per">billed yearly</span></p>
<a class="btn btn--secondary cx-plan__cta" href="#">Talk to sales</a>
<ul class="cx-plan__list">
<li>Single sign-on</li>
<li>99.99% uptime agreement</li>
<li>A named support engineer</li>
</ul>
</article>
</div>
@@ -0,0 +1,30 @@
// fx-pricing: one effect host behind the recommended plan, masked to a 1.5px ring and a faint wash at
// the top; the other plans stay plain, so the eye lands on the one that is recommended. The ring is
// still, so it is drawn once and kept as a picture (no WebGL context at rest).
import { still, palette, accentPalette, color, bag, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'gradient-mesh': { speed: 0, lines: 0, warp: 0.7, softness: 0.45, tint: 1, calm: 0 },
'aurora-curtains': { speed: 0, bands: 3, height: 0.8, intensity: 1.5, stars: 0, calm: 0 },
'grain-gradient': { speed: 0, grainMotion: 0, softness: 0.5, warp: 0.7, grain: 0.35, tint: 1, contrast: 0.8, calm: 0 },
};
export default function init(root) {
const b = bag();
const plan = root.querySelector('.cx-plan.is-featured');
const host = plan?.querySelector('.cx-plan__fx');
if (!host) return b;
const page = palette(root);
const ring = accentPalette(page, { min: 1, lift: 0.22 });
const effect = root.dataset.cxEffect || 'gradient-mesh';
const s = b.add(still(host, effect, {
palette: { ...ring, bg: color.mix(page.accent, page.bg, 0.2) },
seed: root.dataset.cxSeed,
// still: a ring that keeps drifting would need a pause button on a pricing card
params: { ...tuned(PARAMS, effect), speed: 0, drift: 0 },
}));
s.ready.then(() => plan.classList.add('is-live'));
b.add(() => plan.classList.remove('is-live'));
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-pricing",
"name": "Pricing with an effect border",
"for": "A pricing section of two to four plans: the recommended plan gets a thin border of the effect and a faint wash of it behind its name and price; the others stay plain.",
"effects": ["gradient-mesh", "aurora-curtains", "grain-gradient"],
"default": "gradient-mesh",
"how": "One still effect host behind the plan, 1px outside it, masked to a 1.5px ring (content box cut out of the border box) plus a 20% wash fading down over 150px.",
"rules": ["Only the one recommended plan carries the effect.", "Its call to action is the only primary button in the row.", "The wash stays under 20% where text sits.", "Still (speed 0): a pricing card carries no pause button, so nothing in it loops."],
"a11y": "Plans are articles with headings, prices in text and real links; the badge describes the featured plan. Without WebGL the featured plan keeps a solid accent border. The effect is still."
}
@@ -0,0 +1,33 @@
/* fx-progress: the file being imported fills with a soft light of the effect as it goes; a 2px line gives the exact share */
.cx-upload { display: grid; gap: var(--s-4); width: min(100%, 560px); padding: var(--s-5); isolation: isolate; }
.cx-upload__head { display: flex; align-items: center; gap: var(--s-3); padding: 0 var(--s-2); min-height: 28px; }
.cx-upload__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
.cx-upload__count { margin-left: auto; font-size: var(--t-label); color: var(--muted); font-variant-numeric: tabular-nums; }
.cx-upload__head .cx-pause--inline { margin-left: 0; }
.cx-upload__list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.cx-upload__row {
position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr) 24px; align-items: center; gap: 12px;
height: 56px; padding: 0 12px; border-radius: var(--r-sm); background: var(--bg); border: 1px solid var(--cx-line);
}
.cx-upload__row > * { position: relative; z-index: 2; }
.cx-upload__icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--r-md); background: var(--cx-panel); color: var(--muted); }
.cx-upload__icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linejoin: round; stroke-linecap: round; }
.cx-upload__text { display: grid; gap: 2px; min-width: 0; }
.cx-upload__name { font-size: var(--t-small); line-height: 1.3; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cx-upload__meta { font-size: 12px; line-height: 1.3; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.cx-upload__state { width: 20px; height: 20px; border-radius: 99px; border: 1.5px solid var(--cx-line-2); justify-self: end; }
.cx-upload__row.is-busy .cx-upload__state { border-color: var(--cx-line-2); border-top-color: var(--accent); animation: cx-upload-spin .9s linear infinite; }
.cx-upload__row.is-done .cx-upload__state { border: 0; background: var(--action); position: relative; }
.cx-upload__row.is-done .cx-upload__state::after {
content: ""; position: absolute; left: 6px; top: 6px; width: 8px; height: 4.5px;
border-left: 1.75px solid var(--action-ink); border-bottom: 1.75px solid var(--action-ink); transform: rotate(-45deg);
}
@keyframes cx-upload-spin { to { transform: rotate(360deg); } }
.cx-upload__line {
position: absolute !important; left: 12px; right: 12px; bottom: -1px; height: 2px; border-radius: 2px; z-index: 3 !important;
background: var(--accent); transform-origin: left; transform: scaleX(var(--p, 0)); opacity: 0;
}
.cx-upload__row.is-busy .cx-upload__line { opacity: 1; }
.cx-upload__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; opacity: .9; }
@media (prefers-reduced-motion: reduce) { .cx-upload__row.is-busy .cx-upload__state { animation: none; } }
@media (max-width: 479px) { .cx-upload { padding: var(--s-3); } .cx-upload__head { padding: var(--s-2) var(--s-2) 0; } }
@@ -0,0 +1,30 @@
<article class="cx cx-card cx-upload" data-cx-seed="16" aria-label="Uploads">
<header class="cx-upload__head">
<h3 class="cx-upload__title">Importing your files</h3>
<span class="cx-upload__count" data-count>1 of 3 done</span>
</header>
<ul class="cx-upload__list">
<li class="cx-upload__row" data-size="4.2">
<span class="cx-upload__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M5 2.5h6.5L15 6v11.5H5Z"/><path d="M11.5 2.5V6H15M7.5 10h5M7.5 13h5"/></svg></span>
<span class="cx-upload__text"><span class="cx-upload__name">customers-2026.csv</span><span class="cx-upload__meta">4.2 MB</span></span>
<span class="cx-upload__state" aria-hidden="true"></span>
<progress class="cx-sr" max="100" value="0">0%</progress>
<span class="cx-upload__line" aria-hidden="true"></span>
</li>
<li class="cx-upload__row" data-size="12.8">
<span class="cx-upload__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M5 2.5h6.5L15 6v11.5H5Z"/><path d="M11.5 2.5V6H15M7.5 10h5M7.5 13h5"/></svg></span>
<span class="cx-upload__text"><span class="cx-upload__name">invoices-q3.xlsx</span><span class="cx-upload__meta">12.8 MB</span></span>
<span class="cx-upload__state" aria-hidden="true"></span>
<progress class="cx-sr" max="100" value="0">0%</progress>
<span class="cx-upload__line" aria-hidden="true"></span>
</li>
<li class="cx-upload__row" data-size="0.9">
<span class="cx-upload__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M5 2.5h6.5L15 6v11.5H5Z"/><path d="M11.5 2.5V6H15M7.5 10h5M7.5 13h5"/></svg></span>
<span class="cx-upload__text"><span class="cx-upload__name">tax-rates.json</span><span class="cx-upload__meta">0.9 MB</span></span>
<span class="cx-upload__state" aria-hidden="true"></span>
<progress class="cx-sr" max="100" value="0">0%</progress>
<span class="cx-upload__line" aria-hidden="true"></span>
</li>
</ul>
<div class="cx-upload__fx" aria-hidden="true"></div>
</article>
@@ -0,0 +1,85 @@
// fx-progress: one effect host over the card, clipped to the busy row up to its progress, so the row
// fills with light as the file imports. The loop runs only on screen; reduced motion shows one still state.
import { mount, palette, color, reduced, bag, pauser, held, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'grain-gradient': { speed: 1.2, softness: 0.5, warp: 0.7, grain: 0.4, grainMotion: 0, scatter: 0.6, tint: 1, contrast: 1, calm: 0 },
'soft-light-bars': { speed: 1.2, bars: 3, softness: 0.6, glow: 1, breathe: 0, haze: 0.4, stipple: 0.3, grain: 0.4, calm: 0 },
'aurora-curtains': { speed: 0.3, bands: 3, height: 0.8, intensity: 1.6, stars: 0, calm: 0 },
'gradient-mesh': { speed: 0.4, lines: 0, warp: 0.7, softness: 0.5, tint: 1, calm: 0 },
};
const ease = t => 1 - Math.pow(1 - t, 2.2);
export default function init(root) {
const b = bag();
const host = root.querySelector('.cx-upload__fx');
const rows = [...root.querySelectorAll('.cx-upload__row')];
const count = root.querySelector('[data-count]');
const page = palette(root);
// the loop runs on its own, so the header carries a pause button
const p = reduced() ? null : b.add(pauser(root, { label: 'the import preview', into: root.querySelector('.cx-upload__head') }));
const effect = root.dataset.cxEffect || 'grain-gradient';
const fxCtrl = mount(host, effect, {
palette: { ...page, bg: color.mix(page.bg, page.accent, color.isDark(page.bg) ? 0.24 : 0.16), surface: color.mix(page.bg, page.accent, 0.4), accent: color.isDark(page.bg) ? page.accent : color.saturate(page.accent, 1.15) },
seed: root.dataset.cxSeed,
params: tuned(PARAMS, effect),
});
b.add(p ? p.add(fxCtrl) : fxCtrl);
const clip = (row, p) => {
const hr = host.getBoundingClientRect(), r = row.getBoundingClientRect();
const top = r.top - hr.top, left = r.left - hr.left;
const right = hr.width - (left + r.width * p), bottom = hr.height - (top + r.height);
host.style.clipPath = `inset(${top.toFixed(1)}px ${right.toFixed(1)}px ${bottom.toFixed(1)}px ${left.toFixed(1)}px round 8px)`;
};
const setState = (doneN, busy, p) => {
rows.forEach((row, i) => {
row.classList.toggle('is-done', i < doneN);
row.classList.toggle('is-busy', i === busy);
const v = i < doneN ? 100 : i === busy ? Math.round(p * 100) : 0;
const prog = row.querySelector('progress');
prog.value = v;
prog.textContent = `${v}%`;
row.querySelector('.cx-upload__line').style.setProperty('--p', i === busy ? p : 0);
});
count.textContent = `${doneN} of ${rows.length} done`;
if (busy >= 0) clip(rows[busy], p); else host.style.clipPath = 'inset(50%)';
};
if (reduced()) { setState(1, 1, 0.64); return b; }
// the loop: each file takes a time by its size, then a pause, then all reset
let raf = 0, t0 = 0, idx = 0, onScreen = false;
const dur = row => 1600 + Math.min(4200, +row.dataset.size * 300);
// a pause holds the frame where it is: time spent paused does not count
let pausedAt = 0;
const tick = ts => {
raf = 0;
if (!onScreen || document.hidden) return;
if (held(root)) { pausedAt = pausedAt || ts; return; }
if (pausedAt) { t0 += ts - pausedAt; pausedAt = 0; }
if (!t0) t0 = ts;
const el = ts - t0;
if (idx < rows.length) {
const d = dur(rows[idx]);
const p = Math.min(1, el / d);
setState(idx, idx, ease(p));
if (p >= 1) { idx++; t0 = ts; if (idx === rows.length) setState(rows.length, -1, 0); }
} else if (el > 2600) { idx = 0; t0 = ts; }
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(e => {
onScreen = e[e.length - 1].isIntersecting;
if (onScreen && !raf) { t0 = 0; raf = requestAnimationFrame(tick); }
});
io.observe(root);
const vis = () => { if (!document.hidden && onScreen && !raf) { t0 = 0; raf = requestAnimationFrame(tick); } };
const resume = () => { if (!held(root) && onScreen && !document.hidden && !raf) raf = requestAnimationFrame(tick); };
document.addEventListener('visibilitychange', vis);
root.addEventListener('cx-pause', resume);
setState(0, 0, 0.42);
b.add(() => { cancelAnimationFrame(raf); io.disconnect(); document.removeEventListener('visibilitychange', vis); root.removeEventListener('cx-pause', resume); });
return b;
}
@@ -0,0 +1,20 @@
{
"id": "fx-progress",
"name": "Import progress with a light fill",
"for": "An import, upload or setup step shown in a product card: the file being processed fills with a soft light of the effect from left to right, a 2px line under it gives the exact share.",
"effects": [
"grain-gradient",
"soft-light-bars",
"aurora-curtains",
"gradient-mesh"
],
"default": "grain-gradient",
"how": "One effect host over the card, clipped (clip-path inset with a radius) to the busy row up to its progress; the row's text sits above it. A loop inside a still card: files finish one by one, then reset.",
"rules": [
"The exact progress is the 2px line and a real progress element; the light is only the feel.",
"The card never moves; only the fill, the spinner and the count change.",
"The light stays under 75% so the row text reads."
],
"a11y": "Each row holds a real progress element (visually hidden) with its value. Reduced motion shows one still state: one done, one at 64%, one waiting.",
"never": "Not a loader and not a page or scroll progress bar (the house rules have no drag rails or scroll-progress bars; consistency.mjs WARNs progress-bar on any progress element). Use it only when the card's own subject is a file or step the product really processes (an import, an upload, a setup), and never in a hero, a nav or a strip."
}
@@ -0,0 +1,50 @@
/* fx-prompt: a prompt field whose border carries a slow travelling light (edge-glow), blooming outward */
.cx-prompt { --inset: 20px; width: min(100%, 600px); margin-inline: auto; display: grid; }
.cx-prompt__box {
position: relative; isolation: isolate;
padding: calc(var(--inset) + 14px) calc(var(--inset) + 14px) calc(var(--inset) + 12px);
margin: calc(-1 * var(--inset)); border-radius: calc(var(--r-lg) + var(--inset));
}
/* before the effect is drawn (and in forced colours), a plain card */
.cx-prompt__box::before {
content: ""; position: absolute; inset: var(--inset); z-index: -2; border-radius: var(--r-lg);
background: var(--cx-card); border: 1px solid var(--cx-line-2);
}
/* the plain card goes only while a canvas has actually drawn (fx sets opacity:1 on its first frame) */
.cx-prompt__box::before { transition: background-color .5s, border-color .5s; }
.cx-prompt__box:has(> .fx-canvas[style*="opacity: 1"])::before { background: transparent; border-color: transparent; }
.cx-prompt__input {
display: block; width: 100%; min-height: 52px; resize: none; border: 0; outline: 0; background: transparent;
font: 400 var(--t-body)/1.5 var(--font-text); color: var(--ink); padding: 0; caret-color: var(--accent);
}
.cx-prompt__input::placeholder { color: var(--muted); }
.cx-prompt__box:has(.cx-prompt__input:focus-visible) { outline: 2px solid var(--action); outline-offset: calc(-1 * var(--inset) + 3px); }
.cx-prompt__bar { display: flex; align-items: center; gap: var(--s-2); margin-top: var(--s-3); }
.cx-prompt__tool {
display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--r-sm); border: 1px solid var(--cx-line-2);
background: transparent; color: var(--muted); cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.cx-prompt__tool:hover { color: var(--ink); }
.cx-prompt__chip {
display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: var(--r-sm);
border: 1px solid var(--cx-line-2); font-size: var(--t-label); color: var(--ink);
}
.cx-prompt__dot { width: 6px; height: 6px; border-radius: 9px; background: var(--accent); }
.cx-prompt__send {
margin-left: auto; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 999px; border: 0;
background: var(--action); color: var(--action-ink); cursor: pointer;
}
.cx-prompt__hints {
display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-2); padding: 0 40px; list-style: none;
margin: calc(var(--inset) + var(--s-4)) 0 0; position: relative; z-index: 1;
}
/* the pause sits at the right end of the examples row */
.cx-prompt > .cx-pause { right: 0; bottom: 0; width: 30px; height: 30px; }
.cx-prompt__hints button {
height: 30px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--cx-line); background: transparent;
font-size: var(--t-label); color: var(--muted); cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.cx-prompt__hints button:hover { color: var(--ink); border-color: var(--cx-line-2); }
@media (max-width: 639px) { .cx-prompt { --inset: 12px; } }
@@ -0,0 +1,19 @@
<form class="cx cx-prompt" data-cx-seed="3" onsubmit="return false">
<div class="cx-prompt__box">
<label class="cx-sr" for="cx-prompt-input">Ask about your account</label>
<textarea id="cx-prompt-input" class="cx-prompt__input" rows="2" placeholder="Ask about an invoice, a payout or a customer"></textarea>
<div class="cx-prompt__bar">
<button type="button" class="cx-prompt__tool" aria-label="Attach a file">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M10.5 4.5 5.6 9.4a1.4 1.4 0 0 0 2 2l5-5a2.8 2.8 0 0 0-4-4l-5 5a4.2 4.2 0 0 0 6 6l4.4-4.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
<span class="cx-prompt__chip"><span class="cx-prompt__dot" aria-hidden="true"></span>Ledger data</span>
<button type="submit" class="cx-prompt__send" aria-label="Send">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 13V3.5M3.5 8 8 3.5 12.5 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<ul class="cx-prompt__hints" aria-label="Examples">
<li><button type="button">Unpaid invoices over 30 days</button></li>
<li><button type="button">Refunds this week</button></li>
</ul>
</form>
@@ -0,0 +1,24 @@
// fx-prompt: edge-glow draws the prompt card itself, with one light lapping its border about every 12 s;
// a pause button at the end of the examples row holds it
import { mount, palette, bag, tone, pauser } from '../shared.js';
export default function init(root) {
const b = bag();
const box = root.querySelector('.cx-prompt__box');
const input = root.querySelector('.cx-prompt__input');
const page = palette(root);
const inset = parseFloat(getComputedStyle(root).getPropertyValue('--inset')) || 20;
const radius = parseFloat(getComputedStyle(root).getPropertyValue('--r-lg')) || 16;
const p = b.add(pauser(root, { label: 'the light around the field' }));
b.add(p.add(mount(box, 'edge-glow', {
palette: { ...page, surface: tone(page, 0.035) },
seed: root.dataset.cxSeed,
params: { inset, radius, width: 1.2, glow: 22, bloom: 0.55, segment: 0.38, speed: 110, idle: 0.32, wash: 0.4, fill: 1 },
})));
root.querySelectorAll('.cx-prompt__hints button').forEach(h => {
const f = () => { input.value = h.textContent; input.focus(); };
h.addEventListener('click', f);
b.add(() => h.removeEventListener('click', f));
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-prompt",
"name": "Prompt field with edge glow",
"for": "The hero prompt box of an AI or developer product: one light runs slowly around its border and blooms outward, so the one input on the page is found first.",
"effects": ["edge-glow"],
"default": "edge-glow",
"how": "edge-glow mounted on the box with an inset: it draws the card and its border, and the glow spreads into the margin around it. One lap takes about 12 s.",
"rules": ["One lit border per screen; other boxes stay static.", "A lap takes 6 to 18 s, never faster.", "The text area and buttons sit on the opaque card fill."],
"a11y": "A real form with a labelled textarea and labelled buttons; the canvas is aria-hidden. Reduced motion shows the border still, with its faint idle light."
}
@@ -0,0 +1,38 @@
/* fx-search: a search field that gathers a soft ambient glow of the effect around it while focused */
.cx-search { position: relative; isolation: isolate; width: min(100%, 520px); margin-inline: auto; }
.cx-search__glow {
position: absolute; inset: -22px -30px; z-index: -1; border-radius: 40px; pointer-events: none;
filter: blur(22px); opacity: 0; transform: scale(.96);
-webkit-mask: radial-gradient(closest-side, #000 55%, transparent); mask: radial-gradient(closest-side, #000 55%, transparent);
transition: opacity .6s var(--cx-ease), transform .6s var(--cx-ease);
}
.cx-search.is-focus .cx-search__glow { opacity: .6; transform: none; }
.cx-search__field {
position: relative; display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 12px 0 16px;
border-radius: var(--r-md); background: var(--bg); border: 1px solid var(--cx-line-2);
box-shadow: 0 1px 2px rgb(0 0 0 / .06);
transition: border-color .3s var(--cx-ease), box-shadow .3s var(--cx-ease);
}
.cx-search.is-focus .cx-search__field { border-color: color-mix(in oklab, var(--accent) 55%, var(--cx-line-2)); box-shadow: 0 8px 28px -10px rgb(0 0 0 / .4); }
.cx-search__icon { color: var(--muted); flex: none; }
.cx-search__input {
flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; padding: 0;
font: 400 var(--t-body)/1 var(--font-text); color: var(--ink);
}
.cx-search__input::placeholder { color: var(--muted); }
.cx-search__input::-webkit-search-cancel-button { display: none; }
.cx-search__list {
position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 5; margin: 0; padding: 4px; list-style: none;
border-radius: var(--r-md); background: var(--bg); box-shadow: var(--cx-shadow);
}
.cx-search__list li {
display: flex; align-items: center; gap: 12px; height: 40px; padding: 0 12px; border-radius: var(--r-sm);
font-size: var(--t-small); color: var(--ink); cursor: pointer;
}
.cx-search__list li[aria-selected="true"], .cx-search__list li:hover { background: var(--cx-panel); }
/* the rule above sets display, so the hidden attribute needs its own rule or filtered rows stay on screen */
.cx-search__list[hidden], .cx-search__list li[hidden], .cx-search__empty[hidden] { display: none; }
.cx-search__kind { width: 56px; font: 400 11px/1 var(--cx-mono); color: var(--muted); }
.cx-search__empty { position: absolute; top: calc(100% + 8px); left: 0; right: 0; margin: 0; padding: 14px 16px; border-radius: var(--r-md); background: var(--bg); box-shadow: var(--cx-shadow); font-size: var(--t-small); color: var(--muted); }
/* the stage leaves room for the open list */
.cx-search { margin-bottom: 0; }
@@ -0,0 +1,17 @@
<div class="cx cx-search" data-cx-seed="19">
<div class="cx-search__glow" aria-hidden="true"></div>
<div class="cx-search__field">
<svg class="cx-search__icon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><circle cx="9" cy="9" r="5.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="m13.2 13.2 3.3 3.3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<input class="cx-search__input" type="search" role="combobox" aria-expanded="false" aria-controls="cx-search-list" aria-autocomplete="list" autocomplete="off" placeholder="Search docs and settings" aria-label="Search">
<span class="cx-kbd cx-search__kbd" aria-hidden="true">/</span>
</div>
<ul class="cx-search__list" id="cx-search-list" role="listbox" aria-label="Suggestions" hidden>
<li role="option" id="cx-s-1"><span class="cx-search__kind">Page</span>Payouts</li>
<li role="option" id="cx-s-2"><span class="cx-search__kind">Page</span>Invoices</li>
<li role="option" id="cx-s-3"><span class="cx-search__kind">Setting</span>API keys</li>
<li role="option" id="cx-s-4"><span class="cx-search__kind">Setting</span>Webhooks</li>
<li role="option" id="cx-s-5"><span class="cx-search__kind">Doc</span>Refund a payment</li>
<li role="option" id="cx-s-6"><span class="cx-search__kind">Doc</span>Test card numbers</li>
</ul>
<p class="cx-search__empty" hidden>No results</p>
</div>
@@ -0,0 +1,104 @@
// fx-search: the glow is an effect host behind the field, blurred into ambient light; it fades in and
// runs only while the field has focus. At rest it holds no WebGL context: the effect is mounted on the
// first focus and removed (its context freed) a few seconds after the field loses focus. The
// suggestions filter as you type and work with the keyboard.
import { mount, palette, color, bag, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'aurora-curtains': { speed: 0.12, bands: 3, height: 0.8, intensity: 1.6, stars: 0, calm: 0 },
'grain-gradient': { speed: 0.12, calm: 0 },
'gradient-mesh': { speed: 0.12, lines: 0, warp: 0.7, softness: 0.55, tint: 1, calm: 0 },
'soft-light-bars': { speed: 0.4, softness: 0.7, glow: 1, haze: 0.5, stipple: 0.2, grain: 0.3, calm: 0 },
};
export default function init(root) {
const b = bag();
const glow = root.querySelector('.cx-search__glow');
const input = root.querySelector('.cx-search__input');
const list = root.querySelector('.cx-search__list');
const empty = root.querySelector('.cx-search__empty');
const items = [...list.querySelectorAll('[role="option"]')];
const page = palette(root);
let ctrl = null, pending = null, dead = false, timer = 0, freeT = 0, active = -1;
const ensure = () => {
if (ctrl || pending) return;
const effect = root.dataset.cxEffect || (color.isDark(page.bg) ? 'aurora-curtains' : 'grain-gradient');
pending = mount(glow, effect, {
// the effect's own ground is already an accent tint, so the blurred light reaches all round the field
palette: { ...page, bg: color.mix(page.bg, page.accent, color.isDark(page.bg) ? 0.38 : 0.3), accent: color.isDark(page.bg) ? page.accent : color.saturate(page.accent, 1.1) },
seed: root.dataset.cxSeed,
params: tuned(PARAMS, effect, { speed: 0.12, calm: 0 }),
}).then(c => {
pending = null;
if (dead) { c.destroy(); return; }
ctrl = c;
if (!root.classList.contains('is-focus')) c.pause();
}, err => { pending = null; console.warn('fx-search:', err); });
};
const free = () => { ctrl?.destroy(); ctrl = null; };
b.add(() => { dead = true; clearTimeout(freeT); free(); });
const visible = () => items.filter(li => !li.hidden);
const select = n => {
const v = visible();
items.forEach(li => li.setAttribute('aria-selected', 'false'));
if (!v.length) { active = -1; input.removeAttribute('aria-activedescendant'); return; }
active = (n + v.length) % v.length;
v[active].setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', v[active].id);
};
const open = yes => {
const v = visible();
list.hidden = !yes || !v.length;
empty.hidden = !yes || !!v.length;
input.setAttribute('aria-expanded', String(yes && !!v.length));
};
const filter = () => {
const q = input.value.trim().toLowerCase();
items.forEach(li => { li.hidden = !!q && !li.textContent.toLowerCase().includes(q); });
select(0);
open(true);
};
const focus = () => { clearTimeout(timer); clearTimeout(freeT); root.classList.add('is-focus'); ensure(); ctrl?.resume(); filter(); };
const blur = () => {
root.classList.remove('is-focus');
open(false);
timer = setTimeout(() => ctrl?.pause(), 700);
freeT = setTimeout(free, 4000);
};
const key = e => {
if (e.key === 'ArrowDown') { e.preventDefault(); select(active + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); select(active - 1); }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); input.value = visible()[active].lastChild.textContent.trim(); filter(); }
else if (e.key === 'Escape') { input.value = ''; input.blur(); }
};
const pick = e => {
const li = e.target.closest('[role="option"]');
if (!li) return;
e.preventDefault();
input.value = li.lastChild.textContent.trim();
filter();
};
// "/" focuses the field from anywhere on the page (the kbd hint shows it)
const slash = e => {
if (e.key !== '/' || e.defaultPrevented || /input|textarea|select/i.test(document.activeElement?.tagName || '') || document.activeElement?.isContentEditable) return;
const r = root.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight) return;
e.preventDefault();
input.focus();
};
input.addEventListener('focus', focus);
input.addEventListener('blur', blur);
input.addEventListener('input', filter);
input.addEventListener('keydown', key);
list.addEventListener('mousedown', pick);
document.addEventListener('keydown', slash);
b.add(() => {
clearTimeout(timer);
input.removeEventListener('focus', focus); input.removeEventListener('blur', blur);
input.removeEventListener('input', filter); input.removeEventListener('keydown', key);
list.removeEventListener('mousedown', pick); document.removeEventListener('keydown', slash);
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-search",
"name": "Search with ambient glow",
"for": "A docs or command search field that gathers a soft light of the effect around it while it has focus, so the place you are typing is obvious without a loud ring.",
"effects": ["aurora-curtains", "grain-gradient", "gradient-mesh", "soft-light-bars"],
"default": "aurora-curtains",
"how": "aurora-curtains on a dark ground, grain-gradient on a light one, in a host behind the field, 22-30px larger on every side, blurred into ambient light; it fades in on focus and runs only then. A real combobox with a filtered list.",
"rules": ["The glow tints at most a few percent where it meets the field; text never sits on it.", "Glow only on focus, never at rest.", "One glowing field per page."],
"a11y": "An input with role combobox, a labelled listbox, aria-activedescendant for arrow keys, Enter to choose, Escape to clear; / focuses it. Reduced motion keeps the glow still."
}
@@ -0,0 +1,31 @@
/* fx-spotlight: plain cards with a faint dot ruling in their empty corner; a small soft light under the pointer brings it up */
@property --cx-spot-r { syntax: "<length>"; inherits: true; initial-value: 0px; }
.cx-spot {
--cx-spot-r: 0px; --mx: 50%; --my: 50%;
position: relative; isolation: isolate; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-4);
transition: --cx-spot-r .45s var(--cx-ease);
}
.cx-spot.is-on { --cx-spot-r: 150px; }
.cx-spot__card {
position: relative; display: flex; flex-direction: column; gap: var(--s-6); min-height: 300px; padding: var(--s-6);
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line); box-shadow: inset 0 1px 0 var(--cx-hi);
}
.cx-spot__card > * { position: relative; z-index: 2; }
.cx-spot__icon {
display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--r-md);
background: var(--bg); border: 1px solid var(--cx-line-2); color: var(--ink);
}
.cx-spot__icon svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.cx-spot__body { display: grid; gap: var(--s-2); margin-top: auto; }
.cx-spot__link {
font-size: var(--t-small); font-weight: 500; color: var(--ink); display: inline-flex; gap: 6px; align-items: center; width: fit-content;
}
.cx-spot__link::after { content: "\2192"; transition: transform var(--dur) var(--ease); }
.cx-spot__link:hover::after { transform: translateX(2px); }
.cx-spot__card { cursor: pointer; }
.cx-spot__card:has(.cx-spot__link:focus-visible) { outline: 2px solid var(--action); outline-offset: 2px; }
.cx-spot__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
@media (max-width: 899px) {
.cx-spot { grid-template-columns: minmax(0, 1fr); }
.cx-spot__card { min-height: 220px; }
}
@@ -0,0 +1,27 @@
<div class="cx cx-spot" data-cx-seed="27">
<article class="cx-spot__card">
<span class="cx-spot__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><rect x="4" y="9" width="12" height="8" rx="2"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/></svg></span>
<div class="cx-spot__body">
<h3 class="cx-title">Single sign-on</h3>
<p class="cx-text">Connect Okta, Entra ID or Google Workspace. People sign in with the account they already have.</p>
</div>
<a class="cx-spot__link" href="#">Set up SSO<span class="cx-sr"> for your team</span></a>
</article>
<article class="cx-spot__card">
<span class="cx-spot__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M5 3.5h7l3 3v10H5Z"/><path d="M8 9.5h4M8 12.5h4"/></svg></span>
<div class="cx-spot__body">
<h3 class="cx-title">Audit log</h3>
<p class="cx-text">Every change is recorded with who made it and when. Export the log at any time.</p>
</div>
<a class="cx-spot__link" href="#">Read about the log</a>
</article>
<article class="cx-spot__card">
<span class="cx-spot__icon" aria-hidden="true"><svg viewBox="0 0 20 20"><circle cx="8" cy="7" r="3"/><path d="M2.5 16.5c.7-2.8 2.8-4.5 5.5-4.5s4.8 1.7 5.5 4.5M13.5 4.2a3 3 0 0 1 0 5.6M15.5 12.4c1 .8 1.7 2.2 2 4.1"/></svg></span>
<div class="cx-spot__body">
<h3 class="cx-title">Roles</h3>
<p class="cx-text">Give each person exactly the access they need, project by project.</p>
</div>
<a class="cx-spot__link" href="#">See the roles</a>
</article>
<div class="cx-spot__fx" aria-hidden="true"></div>
</div>
@@ -0,0 +1,80 @@
// fx-spotlight: one effect canvas over the card row, a fine dot ruling at a few percent of the ink. Each
// card shows it only in its empty top-right corner (a mask that fades out long before the icon and the
// text), faintly at rest; a small soft light under the pointer brings that corner up. Without a fine
// pointer (touch) the corner simply rests at its faint level. The ruling is still: drawn once and kept
// as a picture (no WebGL context at rest); only the CSS mask follows the pointer.
import { still, palette, tone, color, windows, roundRectFade, smoother, hoverable, reduced, bag } from '../shared.js';
export default function init(root) {
const b = bag();
const host = root.querySelector('.cx-spot__fx');
const cards = [...root.querySelectorAll('.cx-spot__card')];
const page = palette(root);
const live = hoverable();
// each card: the ruling only in its far corner, gone by about two thirds of the way to the text
const shape = (w, h, r) => roundRectFade(w, h, r, { cx: 1, cy: 0, reach: 0.62, peak: 1 });
// the pointer light: small and soft; at rest the corner keeps a third of its strength
const spot = 'radial-gradient(circle max(1px, var(--cx-spot-r)) at var(--mx) var(--my), #000 0%, rgb(0 0 0 / .6) 45%, rgb(0 0 0 / var(--rest, .34)) 100%)';
const rest = 'linear-gradient(rgb(0 0 0 / .34), rgb(0 0 0 / .34))';
b.add(windows(host, cards, { shape, extra: () => [{ image: live ? spot : rest }] }));
// the whole card opens its link, unless the visitor is selecting text
const click = e => {
const card = e.target.closest('.cx-spot__card');
if (!card || e.target.closest('a') || String(getSelection())) return;
card.querySelector('.cx-spot__link')?.click();
};
root.addEventListener('click', click);
b.add(() => root.removeEventListener('click', click));
const card = tone(page, 0.035);
const effect = root.dataset.cxEffect || 'ruled-grounds';
const PARAMS = {
'ruled-grounds': { cell: 14, major: 4, ink: 0.5, focus: 0, speed: 0, reach: 1, grain: 0, calm: 0 },
'contour-lines': { levels: 18, scale: 1.4, lineWidth: 1, indexEvery: 0, opacity: 0.4, speed: 0, tint: 0, shade: 0, edge: 0, calm: 0 },
};
b.add(still(host, effect, {
// the ground is the card itself; the marks are a quiet step of the ink
palette: { ...page, bg: card, surface: card, ink: color.mix(card, page.ink, 0.42), accent: color.mix(card, page.accent, 0.5) },
seed: root.dataset.cxSeed,
params: PARAMS[effect] || { speed: 0, calm: 0 },
}));
if (!live) return b;
// the light trails the pointer a little (or jumps there under reduced motion)
const cur = [0, 0], target = [0, 0];
let first = true;
const sm = smoother(dt => {
const k = reduced() ? 1 : 1 - Math.exp(-dt * 10);
cur[0] += (target[0] - cur[0]) * k;
cur[1] += (target[1] - cur[1]) * k;
root.style.setProperty('--mx', `${cur[0].toFixed(1)}px`);
root.style.setProperty('--my', `${cur[1].toFixed(1)}px`);
return Math.abs(target[0] - cur[0]) + Math.abs(target[1] - cur[1]) > 0.3;
});
const at = (x, y) => {
const r = root.getBoundingClientRect();
target[0] = x - r.left; target[1] = y - r.top;
if (first) { cur[0] = target[0]; cur[1] = target[1]; first = false; }
sm.kick();
};
const on = () => root.classList.add('is-on');
const off = () => { root.classList.remove('is-on'); first = true; };
const move = e => { if (e.target.closest('.cx-spot__card')) { on(); at(e.clientX, e.clientY); } else off(); };
const focus = e => {
const c = e.target.closest('.cx-spot__card');
if (!c) return;
const r = c.getBoundingClientRect();
on(); first = true; at(r.right - r.width * 0.18, r.top + r.height * 0.2);
};
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', off);
root.addEventListener('focusin', focus);
root.addEventListener('focusout', off);
b.add(() => {
sm.stop();
root.removeEventListener('pointermove', move); root.removeEventListener('pointerleave', off);
root.removeEventListener('focusin', focus); root.removeEventListener('focusout', off);
});
return b;
}
@@ -0,0 +1,10 @@
{
"id": "fx-spotlight",
"name": "Spotlight cards",
"for": "A row of plain feature cards (security, admin, settings): a fine ruling sits faintly in each card's empty top-right corner, and a small soft light under the pointer brings that corner up; the cards themselves never move.",
"effects": ["ruled-grounds", "contour-lines"],
"default": "ruled-grounds",
"how": "One still effect host over the row, masked per card by a fade from the top-right corner (roundRectFade) that is gone well before the icon and the text, and by a 150px radial light at the pointer. The marks are a quiet step of the ink on the card colour.",
"rules": ["The only pointer-following element on the page.", "The effect never reaches the icon, the title or the body text.", "Pattern effects only (rulings, contours) at a few percent of the ink: never colour blobs, never a mesh."],
"a11y": "Each card is an article with a heading and one real link that covers the card. Keyboard focus lights the focused card's corner. The effect is still; reduced motion moves the light without easing."
}
@@ -0,0 +1,22 @@
/* fx-stat: big numbers on hairline rules; the digits carry a still effect on dark grounds, an ink fade on light ones */
/* sized by its own width (a container), not the window: in a narrow column at 1440 three numbers sized
to the window ran into each other */
.cx-stat { display: grid; gap: var(--s-4); container-type: inline-size; }
.cx-stat__row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-6); margin: 0; }
.cx-stat__item { display: flex; flex-direction: column-reverse; justify-content: flex-end; gap: var(--s-3); border-top: 1px solid var(--cx-line-2); padding-top: var(--s-5); min-width: 0; }
.cx-stat__num {
margin: 0; font-family: var(--font-display); font-weight: 600; letter-spacing: -0.045em; line-height: .95;
font-size: clamp(48px, 7.4cqi, 104px); font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* light grounds: ink at the top fading to about 60% ink at the foot of the digits */
.cx-stat--fade .cx-stat__num {
background: linear-gradient(180deg, var(--ink) 20%, color-mix(in oklab, var(--ink) 60%, var(--bg)) 95%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.cx-stat__label { font-size: var(--t-small); line-height: 1.5; color: var(--muted); max-width: 26ch; }
.cx-stat__note { font-size: var(--t-label); color: var(--muted); margin: 0; }
@media (forced-colors: active) { .cx-stat--fade .cx-stat__num { color: CanvasText; background: none; } }
@container (max-width: 699px) {
.cx-stat__row { grid-template-columns: minmax(0, 1fr); gap: var(--s-5); }
.cx-stat__num { font-size: clamp(48px, 15cqi, 72px); }
}
@@ -0,0 +1,17 @@
<div class="cx cx-stat">
<dl class="cx-stat__row" data-cx-fill="aurora-curtains" data-cx-seed="9">
<div class="cx-stat__item">
<dt class="cx-stat__label">Uptime over the last 12 months</dt>
<dd class="cx-stat__num" data-fill>99.99%</dd>
</div>
<div class="cx-stat__item">
<dt class="cx-stat__label">Median response time, worldwide</dt>
<dd class="cx-stat__num" data-fill>38 ms</dd>
</div>
<div class="cx-stat__item">
<dt class="cx-stat__label">Requests served each day</dt>
<dd class="cx-stat__num" data-fill>1.2B</dd>
</div>
</dl>
<p class="cx-stat__note">From the public status page, November 2025 to October 2026.</p>
</div>
@@ -0,0 +1,24 @@
// fx-stat: on a dark or colour ground, one still effect canvas over the row of numbers, so only the
// digits show it (soft curtains of light, no motion), drawn once and kept as a picture (no WebGL context
// and no frames at rest). On a light ground an effect inside digits reads as scratched metal, so the
// digits get a plain ink fade instead: full ink at the top to about 60% ink.
import { fill, bag, paramsOf, palette, isDark, tuned } from '../shared.js';
// each effect this part takes, tuned on its own: one effect's params mean something else to another
const PARAMS = {
'aurora-curtains': { bands: 4, height: 0.85, intensity: 1.3, calm: 0 },
'grain-gradient': { grainMotion: 0, calm: 0 },
};
export default function init(root) {
const b = bag();
const light = !isDark(palette(root));
root.classList.toggle('cx-stat--fade', light);
b.add(() => root.classList.remove('cx-stat--fade'));
if (light) return b;
root.querySelectorAll('[data-cx-fill]').forEach(el => {
const params = { ...tuned(PARAMS, el.dataset.cxFill), ...(paramsOf(el) || {}), speed: 0 };
b.add(fill(el, el.dataset.cxFill, { still: true, seed: el.dataset.cxSeed, params }));
});
return b;
}
Loaded 100 of 1765 files, more files were not shown because too many files have changed in this diff. Show more