42 Commits
Author SHA1 Message Date
Leon Lin 12af471f62 tasteskill v2.1.5: community fixes (#172)
tasteskill v2.1.5: community fixes
2026-10-25 01:42:19 +02:00
af8a897996 v2.1.5: community fixes (#156, #161, #163, #171) and an encoding check
- probe.mjs: text boxes are clipped to the window their element's overflow
  allows, so ellipsised lines no longer fail edge-text (#156).
- type.json: Boska, Erode and Sentient request their italic weight (401)
  (#161; the fix from #166, redone without the encoding change).
- SKILL.md: the redraw rule names its third reason (#163); the Gate says to
  inspect the production build (#171).
- inspect.mjs: dev-server WARN for Vite, webpack and Next dev pages (#171).
- tests/text-encoding.mjs + CI: catches UTF-8 saved as Latin-1; CI also
  runs the inspector file-name tests and parses the scene scripts.

Co-Authored-By: ketpatil77 <243740572+ketpatil77@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:40:02 +02:00
Leon Lin f99d059146 docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves (#170)
docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves
2026-10-25 01:32:53 +02:00
Leon Lin fd25565e10 fix(inspect): keep screenshots for widths sharing a viewport class (#165)
fix(inspect): keep screenshots for widths sharing a viewport class
2026-10-25 01:32:43 +02:00
Leon Lin 5fac2066df Escape text passed to innerHTML in charts.js and the Stage.split fallback (#169)
Escape text passed to innerHTML in charts.js and the Stage.split fallback
2026-10-25 01:32:27 +02:00
MrBinnacle 643565c72d docs(taste-skill): keep the help text that states what a field accepts 2026-10-24 18:02:09 -04:00
Matthew Gruber 114deccee7 docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves 2026-10-24 14:59:36 -04:00
PablolakotekandClaude Opus 5.5 1dcd3a2644 Escape text passed to innerHTML in charts.js and the Stage.split fallback
Fixes #160. Labels, series names, format()/note() results and ring
sub-labels in the line tooltip, ring, plan and bullet now go through a
small esc() helper before reaching innerHTML; the markup and styles are
unchanged. bullet() had the same pattern and was not listed in the
issue. The Stage.split fallback builds its word spans with
createElement/textContent instead of re-parsing the heading's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:56:47 +02:00
ketpatil77 aa6d99659b fix(inspect): preserve screenshots for widths sharing a viewport class 2026-10-24 15:33:54 +05:30
Leon Lin 6010f91a99 Merge pull request #159 from Leonxlnx/fix/profile-sweep-owner-pid
Profile sweep with an owner file: remove only profiles whose run and Chrome are gone
2026-10-20 20:38:06 +02:00
LeonxlnxandClaude Opus 5.5 99c1877abe Profile sweep with an owner file: remove only profiles whose run and Chrome are gone
#158 removed the sweep because it could delete a profile another run was
still using (macOS and Linux have no Chrome lockfile). Leftovers from
killed runs then pile up again. Each profile now carries
taste-skill-owner.json (host, run pid, Chrome pid), written before Chrome
starts; a later run removes a profile only when that file names this
machine and neither process is alive. Anything else is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:29:28 +02:00
Leon Lin 588d11adca Merge pull request #158 from t4sh/feature/preserve-active-browser-profiles
fix(browser): 🐞 preserve profiles owned by other runs
2026-10-20 20:22:06 +02:00
tAsh b39320c06f fix(browser): 🐞 preserve profiles owned by other runs 2026-10-20 23:17:04 +05:30
Leon Lin 63aba5f43b Merge pull request #155 from Leonxlnx/chore/cleanup
Clean up the repo: one skill, a cleaner README
2026-10-20 00:16:44 +02:00
LeonxlnxandClaude Opus 5.5 825e306497 Clean up the repo: one skill, a cleaner README (#153)
- The classic skills move to Leonxlnx/taste-skill-classics (same install
  names); the pointer folders for the image skills and the retired
  redesign skill are removed. The README lists where every old install
  name lives now.
- Removed research/laziness, skills/llms.txt, skill.sh and its test, and
  unused images.
- README: a light/dark wordmark instead of the banner, a dark-mode
  TasteCode banner that stands out on GitHub's dark background, four
  badges, Sidrune AI and the nav line removed, community sponsors always
  shown.
- Manifests 2.1.3; the Claude Code marketplace drops taste-skill-classics.

Nothing in skills/taste-skill changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:54:20 +02:00
Leon Lin b9ca2fd804 Fix design-taste-frontend not loading in Claude Code (#152)
Fix design-taste-frontend not loading in Claude Code (#151)
2026-10-19 07:46:43 +02:00
LeonxlnxandClaude Opus 5.5 9919bcb117 Fix design-taste-frontend not loading: quote the description (#151); check every frontmatter in CI
The description added in 2.1.0 had an unquoted ': ', so strict YAML parsers dropped the skill. It is now a folded block scalar. tests/frontmatter.mjs parses every SKILL.md and agents/openai.yaml strictly and checks names, lengths and manifests; check-skills.yml runs it with the registry test and a script syntax check on every push and PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:40:09 +02:00
Leon Lin a9c73c3d5d tasteskill v2.1.1 (#150)
tasteskill v2.1.1: one skill to reach for
2026-10-19 01:12:11 +02:00
LeonxlnxandClaude Opus 5.5 f6831c5df8 v2.1.1: one skill to reach for
- Classic skills (soft, minimalist, brutalist, gpt-taste, Stitch, v1,
  v2-experimental) are opt-in: disable-model-invocation for Claude Code,
  allow_implicit_invocation: false for Codex, and descriptions that say so.
- SKILL.md gains a full redesign / restyle / review route; the redesign skill
  is retired and points there.
- Image-generation skills moved to Leonxlnx/imagegen-skills; their folders
  here are pointers with the same install names.
- full-output-enforcement removed.
- Claude Code marketplace: taste-skill (default skill only) and
  taste-skill-classics; the root plugin.json is gone so each entry lists its
  own skills.
- README: one install command, classics collapsed and marked opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:09:32 +02:00
Leon Lin a206bbf75a tasteskill v2.1 (#148)
tasteskill v2.1: dashboards and app screens, the frame, the Awwwards study, the scroll-scene engine
2026-10-19 00:12:36 +02:00
LeonxlnxandClaude Opus 5.5 a0b2a4fc66 lint: skip lockfiles by name, not every path that contains 'lock'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:10:31 +02:00
LeonxlnxandClaude Opus 5.5 24b0743db3 v2.1.0: changelog dated, scenes library moved to 2.2, README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:07:35 +02:00
LeonxlnxandClaude Opus 5.5 7d84c08f01 Product pages build their interface fragments by the app rules; Chrome profiles removed reliably
SKILL.md and the interface sheet: a software page deals its app style once (draw --app --keep palette,type --out .taste-skill/app) and builds every fragment with app.css and charts.js. cdp.mjs: stop Chrome before removing its profile on exit and on signals; the launch sweep removes any profile no Chrome holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:57:42 +02:00
LeonxlnxandClaude Opus 5.5 1ff6d4f4a6 cdp: cap the throwaway Chrome profile's cache, sweep profiles left by killed runs, remove on exit
Killed or crashed runs left their profiles in Temp, each holding Chrome's cache of the pages it opened; on a long test session they filled tens of GB. Cache is now capped at 16 MB (each run starts cold anyway), profiles older than six hours are swept at launch, and TASTE_SKILL_TMP can move them off the system drive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:09:23 +02:00
LeonxlnxandClaude Opus 5.5 13d5e8749f CHANGELOG: scroll scenes and app round 4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:00:44 +02:00
LeonxlnxandClaude Opus 5.5 d6d281ee9c Scroll scenes: the run, the draw and the reference
- SKILL.md: step 6 builds the dealt scroll scenes with the section they carry;
  step 4 says the scenes carry the idea; the gate adds scripts/scenes.mjs;
  step 9 counts the scenes as built; the finish line asks for the scene
  sheets; Files lists references/scenes.md, scripts/scenes.mjs, assets/scenes.
- scripts/scenes.mjs: every scene at 0-100% of its hold on desktop and phone,
  plus the reduced-motion page, as contact sheets; fails a scene that never
  started or rests on desktop.
- draw.mjs: deals two or three scenes from different families (three for
  expressive sectors and tones), places each on a section it can carry,
  remembers recent scenes, --redraw scenes, --list scenes; sheet section 9.
  Inert until library/scenes.json exists.
- references/scenes.md: what a scene is, putting one on a page, filling,
  fitting, phone and reduced motion, checking, rules, building a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:59:56 +02:00
LeonxlnxandClaude Opus 5.5 e37f888c81 Scroll scenes: inspector checks, motion rules loosened for scenes
- inspect.mjs: scene-not-started, scene-not-live (FAIL), many-scenes and
  scene-heavy (WARN); --film switches on when the sheet dealt scenes.
- probe.mjs frame(): live scenes are exempt from long-pin and
  pinned-over-picture (a scene holds its title and moves pictures past it by
  design).
- motion.md: the budget gains "two or three scroll scenes"; smoothing allowed
  through Stage.smooth() (Lenis, off for touch and reduced motion); inside a
  scene the scene's own numbers apply; outside them the quiet limits stay;
  no preloader, gate, custom cursor or wheel capture as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:31:18 +02:00
LeonxlnxandClaude Opus 5.5 3dba5acc82 v2.1 (wip): app screens round-4 fixes (a face of its own, data truth, charts)
From the round-4 blind verdicts (Greenhouse 7.35, Wattson 7.2, Margin 7.3),
whose shared complaint was "a well-applied kit, not a product with its own face":

- library/app.json: every style lists three ways to take a face from the
  brief; four signature patterns (power flow, leave timeline, cover matrix,
  sell-out strip); achromatic-ledger radius 8/6 and its hue on the main data;
  lime palettes take the accent as positive; KPI tab values stay in ink.
- app-draw.mjs prints "Its face" and matching signature elements.
- app-screens.md: choosing a face is a required step; data-truth rules
  (facts shown twice agree, totals from displayed parts, deltas name their
  baseline, the largest figure answers the title sentence); stretched cards,
  row pitch, legends, labels, phone rules; four new chart forms.
- charts.js: diverging, timeline, matrix, timeStrip; annotation labels laid
  out without collisions after fonts load; one-step x labels; --chart-span.
- app.css: segmented width, square row separators, baseline cells,
  card-link, dark theme span token, a red outside the banned terracotta range.
- probe/rules (app only): ragged-row merges stacked columns; app hero is the
  h1; panel-void and no-data-hue WARNs.
- inspect.mjs: --keep type also applies to app sheets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:17:04 +02:00
LeonxlnxandClaude Opus 5.5 9305aab5d0 Scroll scenes groundwork: shared stage, inspector film, corrected lag claim
- assets/scenes/stage.js and stage.css: the stage every scroll scene runs on
  (sticky stage, GSAP ScrollTrigger scrub, optional Lenis, reduced-motion and
  phone fallbacks, Stage.init for data-scene sections).
- inspect.mjs --film: a frame every third of a screen with real wheel input,
  so scroll scenes are judged by their in-between states.
- motion.md / research.md: the "frames empty 160 ms after a wheel step" figure
  was a capture artefact (mid-step frames were clipped in document space);
  removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:26:13 +02:00
LeonxlnxandClaude Opus 5.5 e080b0ad8a v2.1 (wip): app screens round-3 fixes
Give the product a face (the dealt face really loaded, a drawn mark, one
element only this product has, people and objects as themselves, words of
the trade) and a calendar and timeline spec. charts.js: band labels centred
on their bars, span annotations, reference labels placed where no series
passes. app-draw no longer lends another kind's pattern. Styles: editorial
greeting drops red, achromatic ledger keeps a near-black primary, pastel
consumer keeps tints small and people hues apart. New check: font-not-dealt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:19:40 +02:00
LeonxlnxandClaude Opus 5.5 e8ee6d2a05 v2.1 (wip): page fixes from the first page round; manifests at 2.1.0; CHANGELOG
The supporting sentence sits under the headline; the subject's own footage
leads the first screen; no disclaimers about pictures on the page; invented
portfolio work drawn as finished stills; the first screen's void and display
lines fitted by measurement. New checks: hero-void, one-sided-bleed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:55:15 +02:00
LeonxlnxandClaude Opus 5.5 6947655a9f v2.1 (wip): app screens round-2 fixes
The screen's one finding read first; do not rebuild a studied screen; the
answer figure leads; one figure in one place; KPI tabs with a visible marker;
the per-period and running-total pair once per screen; flat series capped,
forecasts dashed; identity never truncated; one right edge per row; action
columns only when most rows act; counts follow the view; red as a signal;
empty messages span their block. charts.js: forecast (today), end labels,
capped ranges with labelled peaks, full-width plan tracks, bar-list figures in
their own column. The page-only mono and name-only-title checks no longer fire
on app screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:07:27 +02:00
LeonxlnxandClaude Opus 5.5 74ad02e1d3 v2.1 (wip): review fixes for the October 2026 study
Corrected study figures (clip corners, blend modes, scrub smoothing, GSAP
labels), house-rule fixes in eleven new blueprints (nothing over display
type, square windows, real buttons, 16px phone margins, dims at 40%), copy
paraphrased in site notes; new site entries marked with their panel so the
draw picks them on their own scale; the pinned-story move follows the new
three-screen limit; a poster check for clips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:44:00 +02:00
LeonxlnxandClaude Opus 5.5 5b7e2ea396 v2.1 (wip): library additions from the October 2026 study
10 hero and 13 section compositions, 21 new studied sites, media and motion
notes on 70 more (draw.mjs --site prints them); heroes written for a
direction are dealt for it; the direction sheet states the frame rules;
research.md describes both new studies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 07:26:15 +02:00
LeonxlnxandClaude Opus 5.5 5f19173d47 v2.1 (wip): study of the latest 100 Awwwards Sites of the Day
- references/imagery.md section 12: where pictures and footage go, measured
  (picture share per screen, placements ranked with geometry and phone
  versions, footage, crops, text over pictures); video edits (posters
  required, one moving picture per screen, contrast across the whole loop)
- references/motion.md: what strong pages do with motion (curves and
  durations that ran, staggers, scroll triggers, preloaders and their cost),
  new tokens (--ease-expo, --ease-exit, --t-open, staggers), scroll behaviours
  ranked, ten new recipes, seven new signature moves, gimmicks and rules
- references/layout.md: empty space rewritten and a new section on the frame
- inspector: margin drift WARN, empty-middle, name-only-title, phone edge
  tolerance, canvas stages, late-title (1.2s) and reduced-hidden checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 07:16:15 +02:00
LeonxlnxandClaude Opus 5.5 b2770b024c v2.1 (wip): app screens round-1 fixes
From blind judging of eight builds: first-screen substance, the product named in
the shell, colour as good or bad on changes, the main job one click away, rows
that share a bottom edge, settings one view per area, a tablet band, chips that
never squeeze. charts.js gains annotations, reference lines, step lines,
Charts.plan and Charts.bullet. New app checks: ragged-row, below-fold,
app-title-size, kpi-size, size-count, icon-tiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 06:56:57 +02:00
LeonxlnxandClaude Opus 5.5 cd9078b559 v2.1 (wip): app screens, frame checks
- references/app-screens.md: the run, shell, KPIs, charts, tables, forms, states,
  phone and house rules for dashboards and app screens, from a study of 23 real
  product screens
- library/app.json: 7 app styles, 5 shells, 20 screen patterns, 13 product kinds
- draw.mjs --app (scripts/lib/app-draw.mjs) deals a style, a shell and patterns
- inspect.mjs --app and lint.mjs --app judge app screens by their own rules
- new page checks: off-centre, lopsided, blank-gap, no-title, untitled-section,
  edge-text, ragged-edges, small-title, many-h1
- assets/app.css and assets/charts.js: a neutral start for app screens
- SKILL.md routes pages and app screens; frame rules; a finish line before the report
- critique.md: five-second pass, frame pass and caps; evals 7-10 for app screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:43:19 +02:00
Leon Lin f624f37a8c Merge pull request #146 from Leonxlnx/cursor/add-gigslol-sponsor-588d
README: add Gigslol to community sponsors
2026-10-14 17:00:49 +02:00
Cursor AgentandLeon Lin 9b4ac7ffc2 README: add Gigslol to community sponsors
Co-authored-by: Leon Lin <lexn.lin8@gmail.com>
2026-10-14 14:49:46 +00:00
Leon LinandClaude Opus 5.5 a60a36dc2b README: show four pages the skill built (#145)
Replace the with/without pair with a grid of four outputs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:50:32 +02:00
Leon LinandClaude Opus 5.5 8c25bad923 README: compact layout (#144)
Drop the duplicate title, merge the two badge rows, shorten How v2 works
and the FAQ, collapse the secondary skill tables and the community
sponsors, and fold Research, Feedback and the disclaimer into single lines.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:39:25 +02:00
Leon LinandClaude Opus 5.5 6dbbb03c9b README: tighter banners, one example pair (#143)
Crop the top banner, redraw the TasteCode banner without the pill and with
clearer copy, keep only the coffee shop comparison and drop the other
example pairs for now.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:19:21 +02:00
90 changed files with 8403 additions and 7920 deletions

No files matched your search

+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "taste-skill",
"description": "Taste skill library for Claude Code — frontend design, image-to-code, and aesthetic skills",
"description": "tasteskill: art direction for coding agents, for websites, product pages, dashboards and app screens, checked in a real browser.",
"owner": {
"name": "leonxlnx",
"email": "hello@tasteskill.dev"
@@ -8,13 +8,18 @@
"plugins": [
{
"name": "taste-skill",
"description": "tasteskill v2: art direction for coding agents, with a library of researched directions, real pictures from open collections and a real-browser inspector. Also image-generation, redesign and style skills.",
"version": "2.0.0",
"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",
"source": "./",
"skills": ["./skills/taste-skill"],
"author": {
"name": "leonxlnx",
"email": "hello@tasteskill.dev"
}
},
"homepage": "https://github.com/Leonxlnx/taste-skill",
"repository": "https://github.com/Leonxlnx/taste-skill",
"license": "MIT",
"keywords": ["skills", "frontend", "design", "taste", "ui", "dashboard"]
}
]
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "taste-skill",
"description": "tasteskill v2: art direction for coding agents, with a library of researched directions, real pictures from open collections and a real-browser inspector. Also image-generation, redesign and style skills.",
"version": "2.0.0",
"author": {
"name": "leonxlnx",
"email": "hello@tasteskill.dev"
},
"homepage": "https://github.com/leonxlnx/taste-skill",
"repository": "https://github.com/leonxlnx/taste-skill",
"license": "MIT",
"keywords": [
"skills",
"frontend",
"design",
"taste",
"ui"
]
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "taste-skill",
"version": "2.0.0",
"description": "tasteskill v2: art direction for coding agents, plus image-to-code, redesign, image-generation and style skills.",
"version": "2.1.5",
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, checked in a real browser.",
"author": {
"name": "leonxlnx",
"url": "https://github.com/Leonxlnx"
@@ -19,8 +19,8 @@
"skills": "./skills/",
"interface": {
"displayName": "Taste Skill",
"shortDescription": "Anti-slop frontend, image-to-code, and visual design skills.",
"longDescription": "Frontend and visual-design skills from Leonxlnx/taste-skill, packaged as one Codex marketplace plugin.",
"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.",
"developerName": "leonxlnx",
"category": "Design",
"capabilities": [],
@@ -28,7 +28,7 @@
"defaultPrompt": [
"Design a distinctive frontend without generic AI patterns.",
"Redesign this interface while preserving its brand.",
"Turn this reference image into production-ready UI."
"Build the dashboard for this product with real data and charts."
]
}
}
+25
View File
@@ -0,0 +1,25 @@
name: Check skills
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Every SKILL.md frontmatter parses (as Claude Code and Codex parse it)
run: |
npm install --no-save --no-package-lock yaml@2.6.1
node tests/frontmatter.mjs
- 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
+93
View File
@@ -4,6 +4,99 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
---
## 2.1.5 - 2026-10-25
Fixes from the community. Thanks to everyone who reported and sent them.
- **Safe text in charts and split headings** ([#160](https://github.com/Leonxlnx/taste-skill/issues/160), [#169](https://github.com/Leonxlnx/taste-skill/pull/169), thanks @jcastilla89 and @Pablolakotek): `charts.js` and the `Stage.split` fallback escape the text they insert, so a chart label or heading that comes from user data can no longer inject markup.
- **No lost screenshots when two widths share a class** ([#162](https://github.com/Leonxlnx/taste-skill/issues/162), [#165](https://github.com/Leonxlnx/taste-skill/pull/165), thanks @nosawavey-spec and @ketpatil77): `--widths 1280,1024,768,390` keeps both tablet renders (`tablet-1024-*`, `tablet-768-*`).
- **Keyboard and accessible names** ([#170](https://github.com/Leonxlnx/taste-skill/pull/170), thanks @MrBinnacle): every control works from the keyboard and has an accessible name; a control explains itself by its form, not by a sentence beside it.
- **The italic faces load** ([#161](https://github.com/Leonxlnx/taste-skill/issues/161), with the fix from [#166](https://github.com/Leonxlnx/taste-skill/pull/166), thanks @nosawavey-spec and @ketpatil77): Boska, Erode and Sentient request their italic weight, so the browser no longer fakes it.
- **`edge-text` no longer fails ellipsised lines** ([#156](https://github.com/Leonxlnx/taste-skill/issues/156), thanks @shoctroper): text is measured where the reader sees it, clipped by `overflow`.
- **Inspect the production build** ([#171](https://github.com/Leonxlnx/taste-skill/issues/171), thanks @lilozzy): the Gate step says to run the last check on the built page, and the inspector warns (`dev-server`) when it is looking at a Vite, webpack or Next dev server.
- **The redraw rule names its third reason** ([#163](https://github.com/Leonxlnx/taste-skill/issues/163), thanks @Tsury): a section that cannot hold the real content.
- CI now also checks that every text file is clean UTF-8 (a double-encoded save is caught), runs the inspector file-name tests, and parses the scene scripts.
## 2.1.4 - 2026-10-20
- **Fix: one inspector run could delete the browser profile of another run that was still going** ([#157](https://github.com/Leonxlnx/taste-skill/issues/157), fixed in [#158](https://github.com/Leonxlnx/taste-skill/pull/158) by @t4sh). On macOS and Linux Chrome keeps no `lockfile`, so the sweep added in 2.1.0 judged a profile unused by its age alone, and a second run started during a long one could pull that run's profile away. #158 removed the sweep and added a regression test.
- **Leftover profiles are cleaned up again, safely.** Every profile now carries `taste-skill-owner.json` with the machine and the process IDs of the run and its Chrome. A later run removes a profile left by a killed run only when that file names this machine and neither process is alive; a profile without the file is never touched. Profiles left by earlier versions are not removed automatically: delete the `ad-chrome-*` folders in your temp folder (or in `TASTE_SKILL_TMP`) by hand.
## 2.1.3 - 2026-10-19
One repo, one skill. Nothing in `design-taste-frontend` changed.
- **The classic skills moved** to their own repo, [Leonxlnx/taste-skill-classics](https://github.com/Leonxlnx/taste-skill-classics): `high-end-visual-design`, `minimalist-ui`, `industrial-brutalist-ui`, `gpt-taste`, `stitch-design-taste`, `design-taste-frontend-v1` and `design-taste-frontend-v2-experimental`. Same install names, same files. The `taste-skill-classics` Claude Code plugin moved with them (`/plugin marketplace add Leonxlnx/taste-skill-classics`).
- **The pointer folders are gone:** `brandkit`, `image-to-code`, `imagegen-frontend-web` and `imagegen-frontend-mobile` (in [Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills) since 2.1.1) and `redesign-existing-projects` (part of `design-taste-frontend` since 2.1.1). The README lists where every old install name lives now.
- **Removed:** `research/laziness/` (it belonged to `full-output-enforcement`, removed in 2.1.1), the outdated `skills/llms.txt`, `skill.sh` and its test, and unused images.
- **README:** a wordmark instead of the banner, a TasteCode banner that stands out in dark mode, fewer badges, the community sponsors always shown.
- The Codex plugin loads only `design-taste-frontend` now.
## 2.1.2 - 2026-10-19
- **Fix: `design-taste-frontend` did not load in Claude Code** ([#151](https://github.com/Leonxlnx/taste-skill/issues/151)). The description added in 2.1.0 contained an unquoted `: `, so its YAML frontmatter failed to parse and the skill was silently dropped ("Unknown skill"). It is now a folded block scalar.
- **So it cannot happen again:** `tests/frontmatter.mjs` parses every `SKILL.md` frontmatter (and every `agents/openai.yaml`) with a strict YAML parser, checks names, description length and the plugin manifests, and a GitHub Actions workflow (`check-skills.yml`) runs it, the registry test and a syntax check of the scripts on every push and pull request.
## 2.1.1 - 2026-10-19
One skill to reach for. Installing the repo no longer means an agent might pick an older skill for the job.
- **The classic skills are opt-in.** soft, minimalist, brutalist, gpt-taste, Stitch, v1 and v2-experimental run only when called by name: `disable-model-invocation: true` for Claude Code, `allow_implicit_invocation: false` (`agents/openai.yaml`) for Codex, and descriptions that say so for every other agent.
- **Redesigns are part of taste-skill.** `SKILL.md` has a full route for a redesign, restyle or review of an existing page or app screen: record the before in a real browser, keep the stack, the content and what works, change type, colour, the frame, states and stock components in that order, prove it with before and after. `redesign-existing-projects` is retired and points there.
- **The image-generation skills moved** to their own repo, [Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills): `imagegen-frontend-web`, `imagegen-frontend-mobile`, `brandkit`, `image-to-code`. Same install names; the folders here point there.
- **`full-output-enforcement` is removed.**
- **Two Claude Code plugins:** `taste-skill` carries only the default skill; `taste-skill-classics` carries the classic skills for whoever wants them.
## 2.1.0 - 2026-10-19
### The scroll-scene engine
The groundwork for big scroll scenes, the moments award sites are remembered for (a camera flying through a field of pictures, a picture that grows from a window to the whole screen). The library of tested scenes, taken apart from 43 motion-led Sites of the Day and rebuilt from scratch, follows in 2.2; this release ships what they run on and the checks that judge them.
- **`assets/scenes/stage.js` and `stage.css`**: a sticky stage scrubbed by native scroll with GSAP ScrollTrigger (pinned versions), optional Lenis smoothing that stays off for touch and reduced motion, and a finished composition for reduced motion, no script and a failed CDN. `references/scenes.md` says how to build a scene on it.
- **`scripts/scenes.mjs`** photographs every scene on a page at 0-100% of its hold on desktop and phone, and the reduced-motion page. **`inspect.mjs --film`** films the whole scroll a third of a screen at a time, and the inspector fails a scene that never started or rests on desktop.
- `draw.mjs` can deal scenes from `library/scenes.json` once it exists (`--redraw scenes`, `--list scenes`).
- `motion.md`: smoothing allowed through the stage; inside a scene the scene's numbers apply; outside scenes the quiet limits stay. Still no preloader, gate, custom cursor or wheel capture.
### Product pages that show their app
A software product's page builds every interface fragment it shows (the hero's live screen, a feature crop, a chart) as a real piece of the app: its style dealt once with `draw.mjs --app --keep palette,type --out .taste-skill/app`, built with `app.css` and `charts.js`, real data that adds up.
### Fixes
- The inspector's throwaway Chrome profiles are removed reliably (Chrome is stopped before its profile is deleted, the profile cache is capped, profiles left by killed runs are swept); a long test session had left tens of GB in the temp folder. `TASTE_SKILL_TMP` moves them off the system drive.
- `motion.md` and `research.md`: a claim that smooth-scroll pages showed empty frames mid-scroll was a capture artefact and is gone.
### App screens: dashboards, admin, settings, inboxes
The default skill now builds the screens people work in, not only the pages they read. `SKILL.md` asks first whether the job is a page or an app screen; an app screen takes its own route.
- **`references/app-screens.md`**, a new chapter: the run for an app screen (inventory the data and the jobs, name the screen's one question and its one finding, write the data with assertions, deal a style, build the shell, the data and every state, check, look), the shell, the canvas and title, KPIs, charts, tables and lists, forms and settings, type and colour for light and dark, density, empty, zero, loading and error states, the phone and the tablet, the house rules for app screens and the tells of generated dashboards with what replaces each.
- Distilled from a study of 23 real product screens chosen by the owner (17 products, 17 light and 6 dark), measured from their pixels. The screenshots stay private; the skill carries only the measurements and the rules.
- **`draw.mjs --app`** deals an app style from `library/app.json`: seven styles (achromatic ledger, framed sheet, accent ladder, editorial greeting, pastel consumer, true-black flat, data terminal), each with tokens, type, a colour budget and the one move that makes it recognisable, plus five shells and 21 screen patterns for 13 kinds of product.
- **`inspect.mjs --app`** and **`lint.mjs --app`** (switched on by an app sheet) judge app screens by their own rules: quiet borders, small labels, repeated panels and tables are allowed; heavy shadows, glass, icons in tinted squares, rainbow charts, placeholder data, text under 11px, a sidebar left on the phone, ragged rows, a thin first screen, landing-page titles and figures, a dead strip beside the canvas and colour sprawl are not.
- **`assets/app.css`** (a neutral shell, panels, divided KPI strip, tables, pills, controls, tabs, empty states and the phone fold) and **`assets/charts.js`** (line, bars, ring, bar list, plan against spent, share against target, with annotations, reference lines, forecasts and capped ranges), both set to the study's defaults.
- Tested in four blind rounds of eight, six, five and three builds judged from screenshots against the reference screens; the warnings at the top of the chapter come from what the judges marked down. The last round's complaint, "a well-applied kit, not a product with its own face", led to a required face step (each style lists ways to take one from the brief) and signature patterns for kinds of product (a power-flow summary, a leave timeline, a cover matrix, a sell-out strip).
### The frame of a page
Generated pages most often fail before any detail: no clear title, margins that do not match, a section pushed to one side with half the screen empty, long blank stretches, sections with no heading, text against the screen edge.
- `SKILL.md` step 6 gains "The frame", the house rules three new rows, and a finish line before the report.
- `inspect.mjs` measures each of them: `no-title`, `name-only-title`, `off-centre` (FAIL, and a WARN from 16px of drift), `lopsided`, `empty-middle`, `blank-gap`, `untitled-section`, `edge-text`, `ragged-edges`, `small-title`, `many-h1`. The thresholds come from the study below: strong pages hold their margins within a few pixels, about one text section in a hundred leaves half the width empty, 82% of text sections open with a heading.
- `critique.md`: a five-second pass, a frame pass and new caps. `layout.md`: empty space rewritten and a new section on the frame.
### A study of the latest 100 Awwwards Sites of the Day
Captured on 18 October 2026 at 1440 and 390, with what moved and the CSS that loaded measured in the page, and read by a panel of one model reader per site (91 usable). Learning only: nothing a site owns is reproduced.
- **Pictures and footage:** `imagery.md` §12, where pictures go (picture share per screen, nineteen placements ranked with their geometry and phone versions, footage, crops, text over pictures) and stricter video rules (a poster on every clip, one moving picture per screen, contrast checked across the whole loop).
- **Motion:** `motion.md` gains what strong pages do with motion (the curves and durations that actually ran, staggers, scroll triggers, the cost of preloaders), new tokens (`--ease-expo`, `--ease-exit`, `--t-open`, staggers), scroll behaviours ranked, ten new recipes (lines that rise, an opening window, a held picture with passing captions, a sideways gallery, a receding hero, an index anchored to its row, a 1.2-second load sequence, view transitions between pages, an ink fill, rolling figures), the gimmicks to avoid and rules. Seven new signature moves in `library/motions.json`.
- **Library:** 10 hero and 13 section compositions, 21 new studied sites and media and motion notes on 70 more (`draw.mjs --site <id>`).
- **New checks:** `late-title` (the title is not readable 1.2s after load), `reduced-hidden` (content that stays hidden under reduced motion), `no-poster`.
---
## 2.0.0 - 2026-10-13
### Repo
+63 -144
View File
@@ -1,32 +1,32 @@
<p align="center">
<img src="assets/readme-banner.webp" alt="Taste Skill - Anti-slop Agent Skills for premium frontends" width="100%" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/taste-skill-wordmark-dark.png" />
<img src="assets/taste-skill-wordmark.png" alt="Taste Skill" width="340" />
</picture>
</p>
<h1 align="center">Taste Skill</h1>
<p align="center">
<em>Anti-slop design skills for coding agents</em>
</p>
<p align="center" style="margin-bottom: 8px;">
<a href="https://tasteskill.dev" title="Visit tasteskill.dev"><img src="https://img.shields.io/badge/website-tasteskill.dev-24292f?style=flat" alt="Visit tasteskill.dev" /></a>
&nbsp;
<a href="https://tastecode.dev" title="Visit tastecode.dev"><img src="https://img.shields.io/badge/new-TasteCode-ff6b00?style=flat" alt="New: TasteCode" /></a>
&nbsp;
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&amp;logo=github&amp;logoColor=white" alt="MIT License" /></a>
&nbsp;
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
</p>
<p align="center">
<a href="https://tastecode.dev"><img src="assets/readme-tastecode.webp" alt="TasteCode is here. A new home for your coding agents, with a design agent built in. Visit tastecode.dev" width="100%" /></a>
<a href="https://tastecode.dev">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/readme-tastecode-dark.webp" />
<img src="assets/readme-tastecode.webp" alt="TasteCode is here. Taste Skill as a desktop app, with a design agent built in. Free and open source. Download at tastecode.dev" width="100%" />
</picture>
</a>
</p>
<p align="center"><sub>From the Taste Skill team: <a href="https://tastecode.dev"><strong>TasteCode</strong></a>, a local desktop workspace for Codex, Claude Code and Grok with a design agent that briefs, builds and visually reviews your UI. <a href="https://github.com/Leonxlnx/tastecode">Source on GitHub</a>.</sub></p>
<h3 align="center">Sponsors</h3>
<table align="center">
<tr>
<td align="center" width="104"><a href="https://fluxionai.space/register?source=github&amp;campaign=github-tasteskill&amp;promo=TASTESKILL"><img src="assets/sponsors/sidrune-ai.png" alt="Sidrune AI" width="80" height="80" /></a></td>
<td><sub><a href="https://fluxionai.space/register?source=github&amp;campaign=github-tasteskill&amp;promo=TASTESKILL"><strong>Sidrune AI</strong></a>: one API for GPT, Claude, and other leading AI models.<br />Sign up through this link and get $3 in API credit.</sub></td>
</tr>
<tr>
<td align="center" width="104">
<a href="https://interfaces.dev/?utm_source=tasteskill&utm_medium=sponsorship">
@@ -58,49 +58,37 @@
<p align="center"><sub><a href="https://github.com/sponsors/Leonxlnx">Become a sponsor</a></sub></p>
**Taste Skill** is a set of Agent Skills that make coding agents design websites instead of assembling the average landing page. The default skill gives the agent an art direction, real pictures and a way to check its own work in a real browser.
**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.
Same brief, same agent, same prompt. Without the skill on the left, with it on the right:
Pages it has built, each from one brief:
<table>
<tr>
<td width="50%"><img src="examples/v2/coffee-without.webp" alt="Coffee shop page built without the skill" /></td>
<td width="50%"><img src="examples/v2/coffee-with.webp" alt="The same coffee shop brief built with the skill" /></td>
<td width="50%"><img src="examples/v2/coffee-storefront.webp" alt="Coffee shop page with a drawn storefront as its hero" /></td>
<td width="50%"><img src="examples/v2/night-dates.webp" alt="Techno night page with its dates set into an archive photograph" /></td>
</tr>
<tr>
<td width="50%"><img src="examples/v2/coffee-roast.webp" alt="Coffee shop page section showing the roast in four photographs" /></td>
<td width="50%"><img src="examples/v2/coffee-questions.webp" alt="Coffee shop page questions as stacked index cards" /></td>
</tr>
</table>
Across four briefs built both ways, the pages without the skill had 37 small labels above headings, 249 hairline rules and no photographs. The pages with it had none of the labels, none of the rules and 48 real photographs. More pairs are under [Examples](#examples).
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&amp;logo=github&amp;logoColor=white" alt="MIT License" /></a>
&nbsp;
<a href="https://github.com/vercel-labs/agent-skills"><img src="https://img.shields.io/badge/Agent_Skills-compatible-24292f?style=flat&amp;logo=github&amp;logoColor=white" alt="Agent Skills compatible" /></a>
&nbsp;
<a href="#install"><img src="https://img.shields.io/badge/Codex_%2F_Cursor_%2F_Claude-install-24292f?style=flat" alt="Codex, Cursor, Claude" /></a>
&nbsp;
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
</p>
<p align="center"><sub><a href="#install">Install</a> · <a href="#how-v2-works">How it works</a> · <a href="#examples">Examples</a> · <a href="#skills">Skills</a> · <a href="#common-questions">FAQ</a> · <a href="#support-the-project">Sponsors</a> · <a href="#license">License</a></sub></p>
The photographs are real, found in open archives and credited by the skill ([credits](examples/v2/CREDITS.md)). Watch the [launch film](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
## Install
```bash
npx skills add https://github.com/Leonxlnx/taste-skill
```
That installs every skill in this repo with the [`npx skills`](https://github.com/vercel-labs/agent-skills) CLI. To install one, pass its install name:
```bash
npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend"
```
The default 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.
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.
<details>
<summary>Claude Code plugin</summary>
Run these inside Claude Code. Every skill is namespaced under `taste-skill:`, so they do not collide with your other skills.
Run these inside Claude Code. The skill is namespaced under `taste-skill:`, so it does not collide with your other skills.
```
/plugin marketplace add Leonxlnx/taste-skill
@@ -119,126 +107,66 @@ codex plugin marketplace add Leonxlnx/taste-skill
codex plugin add taste-skill@taste-skill
```
Start a new Codex session afterwards so the skills are loaded.
Start a new Codex session afterwards so the skill is loaded.
</details>
<details>
<summary>From a clone, linked instead of copied</summary>
`./install.sh` symlinks the skills into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates them in place. `./install.sh --list` shows every install name.
`./install.sh` symlinks the skill into Claude Code (`--codex` for Codex, `--all-agents` for both), so a `git pull` updates it in place.
</details>
Already have the skill? Run the install command again. The install name did not change, and the older versions stay available under their own names (see [Skills](#skills)).
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
The default skill, `taste-skill`, is new in v2. It is built from a study of 247 curated sites and it is a folder: instructions, references, a library and four scripts. A build runs in six steps.
The skill is built from a study of 247 curated sites. A build runs in six steps:
1. **Read the brief.** The agent lists what only this client has: the name and what it means, the street, the numbers, the one constraint.
2. **Draw a direction.** A script deals three of 20 researched directions. The agent picks the one that can carry something from that list, and the script deals the rest: a hero, section layouts, a type system, a palette, a motion.
3. **Find the idea.** One sentence that ties the client's own material to the direction, checked against the nearest competitor, plus one thing the visitor can do on the page.
4. **Build with real pictures.** When the project has no photographs, a script finds real pictures and footage of the subject's world in open collections, downloads them and records the credits. Only what no honest picture exists of is drawn in code.
5. **Check it in a real browser.** An inspector opens the page at 1440, 768 and 390 px and fails it on eyebrow labels, hairlines, gradients, resting shadows, headlines over two lines, empty screens and covered text.
6. **Look and fix.** The agent critiques its own screenshots, fixes the top three problems and repeats, up to three rounds.
1. Reads the brief and lists what only this client has.
2. A script deals three of 20 researched directions and the agent picks one.
3. Finds the idea: one sentence tying the client's own material to that direction.
4. Builds with real pictures and footage from open collections, credited.
5. Checks the page in a real browser at three widths and fails it on slop.
6. Looks at its own screenshots and fixes the top three problems, up to three rounds.
A build takes an agent roughly 30 to 45 minutes and a few hundred thousand tokens. The full write-up is in [CHANGELOG.md](CHANGELOG.md) and the study is in [`references/research.md`](skills/taste-skill/references/research.md).
A build takes roughly 30 to 45 minutes and a few hundred thousand tokens. Details are in the [changelog](CHANGELOG.md) and the [study](skills/taste-skill/references/research.md).
## Examples
Dashboards and app screens (v2.1) take their own route: the skill deals one of the app styles distilled from real product screens, builds the shell, the data, the charts and the tables to those numbers, and checks the result with app rules: quiet panels, aligned figures, colour spent on data, a phone layout where the navigation folds away. A product's landing page builds the app it shows the same way, so its screenshots look like a working product.
Four briefs, each built once without the skill and once with it. Nothing else changed.
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.
| Without | With taste-skill v2 |
## Moved skills
This repo holds one skill, `design-taste-frontend`. The others moved to repos of their own and kept their install names:
| Install name | Where it lives now |
| --- | --- |
| <img src="examples/v2/night-without.webp" alt="Techno night page without the skill" /> | <img src="examples/v2/night-with.webp" alt="Techno night page with the skill" /> |
| <img src="examples/v2/studio-without.webp" alt="Architecture studio page without the skill" /> | <img src="examples/v2/studio-with.webp" alt="Architecture studio page with the skill" /> |
| <img src="examples/v2/legal-without.webp" alt="Contract tool page without the skill" /> | <img src="examples/v2/legal-with.webp" alt="Contract tool page with the skill" /> |
| `high-end-visual-design`, `minimalist-ui`, `industrial-brutalist-ui`, `gpt-taste`, `stitch-design-taste`, `design-taste-frontend-v1`, `design-taste-frontend-v2-experimental` | **[Leonxlnx/taste-skill-classics](https://github.com/Leonxlnx/taste-skill-classics)** |
| `imagegen-frontend-web`, `imagegen-frontend-mobile`, `brandkit`, `image-to-code` | **[Leonxlnx/imagegen-skills](https://github.com/Leonxlnx/imagegen-skills)** |
| `redesign-existing-projects` | Part of `design-taste-frontend`, which audits an existing page in a real browser before changing anything |
| `full-output-enforcement` | Removed. For the same problem, see **[Leonxlnx/unlazy](https://github.com/Leonxlnx/unlazy)** |
The pictures on the right are real photographs from open archives, found and credited by the skill. Their credits are in [`examples/v2/CREDITS.md`](examples/v2/CREDITS.md). The launch film is attached to the [v2.0.0 release](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
## Skills
Each skill does one job. You do not need all of them. The **install name** is the value you pass to `--skill`.
### The default
| Skill | Install name | What it does |
| --- | --- | --- |
| **[taste-skill](skills/taste-skill/SKILL.md)** | `design-taste-frontend` | v2. Art direction for landing pages, marketing sites, portfolios and product pages, in any stack. Works as described above. |
### For a specific job
| Skill | Install name | What it does |
| --- | --- | --- |
| **[redesign-skill](skills/redesign-skill/SKILL.md)** | `redesign-existing-projects` | Existing projects: audit the UI first, then fix layout, spacing, hierarchy and styling. |
| **[image-to-code-skill](skills/image-to-code-skill/SKILL.md)** | `image-to-code` | Generate site references, analyse them, then build the frontend to match. |
| **[gpt-tasteskill](skills/gpt-tasteskill/SKILL.md)** | `gpt-taste` | A stricter variant for GPT and Codex: more layout variance, stronger motion direction. |
| **[soft-skill](skills/soft-skill/SKILL.md)** | `high-end-visual-design` | Calm, polished UI: soft contrast, whitespace, spring motion. |
| **[minimalist-skill](skills/minimalist-skill/SKILL.md)** | `minimalist-ui` | Editorial product UI with a restrained palette and crisp structure. |
| **[brutalist-skill](skills/brutalist-skill/SKILL.md)** | `industrial-brutalist-ui` | Hard mechanical language: Swiss type, sharp contrast, experimental layout. |
| **[stitch-skill](skills/stitch-skill/SKILL.md)** | `stitch-design-taste` | Rules for Google Stitch, with an optional `DESIGN.md` export. |
| **[output-skill](skills/output-skill/SKILL.md)** | `full-output-enforcement` | For when the model ships half-finished work: full output, no placeholder comments. |
### Image generation
These produce design images only, no code. Use them with ChatGPT Images, Codex image mode or any agent that generates images, then hand the frames to your coding agent.
| Skill | Install name | What it does |
| --- | --- | --- |
| **[imagegen-frontend-web](skills/imagegen-frontend-web/SKILL.md)** | `imagegen-frontend-web` | Website comps: hero, landing page, multi-section. |
| **[imagegen-frontend-mobile](skills/imagegen-frontend-mobile/SKILL.md)** | `imagegen-frontend-mobile` | Mobile screens and flows for iOS, Android and cross-platform. |
| **[brandkit](skills/brandkit/SKILL.md)** | `brandkit` | Brand-kit boards: logo directions, palettes, type, applications. |
### Which one should I use?
- Building a new public page: **taste-skill**.
- Improving a page that already exists: **redesign-skill**.
- The visual direction is already decided: **soft-skill**, **minimalist-skill** or **brutalist-skill**.
- You want images first, then code: an image-generation skill, or **image-to-code-skill** for the whole pipeline in one go.
- The agent keeps cutting its output short: add **output-skill**.
<details>
<summary>Older versions of taste-skill</summary>
Kept for projects that depend on their exact behaviour.
| Skill | Install name | What it is |
| --- | --- | --- |
| **[taste-skill-v2-experimental](skills/taste-skill-v2-experimental/SKILL.md)** | `design-taste-frontend-v2-experimental` | The single-file, dial-driven rewrite that was the default before v2. |
| **[taste-skill-v1](skills/taste-skill-v1/SKILL.md)** | `design-taste-frontend-v1` | The original. |
Install a moved skill from its new repo under the same name:
```bash
npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend-v2-experimental"
npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend-v1"
npx skills add https://github.com/Leonxlnx/taste-skill-classics --skill "high-end-visual-design"
```
Both have three 1 to 10 dials at the top of the file: **DESIGN_VARIANCE** (centred and clean to asymmetric), **MOTION_INTENSITY** (hover only to scroll-driven) and **VISUAL_DENSITY** (spacious to dense). v2 has no dials. Its direction comes from the draw and its limits from the inspector.
</details>
## 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.
**Where do the pictures come from, and can I use them?**
From open collections such as Wikimedia Commons, Openverse and the Met. Only public domain, CC0, CC BY and CC BY-SA files are offered, and the skill puts the credits on the page. Replace them with your own when you have them.
**Does it work with React, Vue, Svelte, plain HTML?**
Yes. The rules are about design, not a framework.
**What does the default skill need on my machine?**
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. Without Node 22 or a browser the agent can still follow the instructions, but the inspector cannot run.
**Where do the pictures come from, and can I use them?**
From open collections: Openverse, Wikimedia Commons, the Art Institute of Chicago and the Met. By default the skill only offers public domain, CC0, CC BY and CC BY-SA files, records the title, creator, licence and source of every download, and puts the credits on the page. They are pictures of the subject's world, not of your product. Replace them with your own when you have them.
**Does it need the internet?**
Only to find pictures and footage. Without it the agent draws what it needs and says so in its report.
**Is it for dashboards and app screens?**
No. The default skill is for public-facing pages. Use **minimalist-skill** or **soft-skill** for product UI.
**What is a SKILL.md?**
An instruction file an agent loads on its own when the task fits. Install it with `npx skills add`, or copy a single-file skill into your project or a conversation.
**How is this different from other design skills?**
The default skill does not ask the model to invent a look, because a model asked to invent picks the category default every time. A script deals researched directions, a real browser measures the result, and the agent has to look at its own screenshots before it is done.
Yes, since v2.1. The skill tells a page from an app screen and builds a dashboard, an admin view or a settings screen with its own run: a style dealt from a study of real product screens, quiet panels, honest data and charts, and its own checks (`inspect.mjs --app`).
## Support the project
@@ -261,6 +189,7 @@ If Taste Skill helps you, consider sponsoring:
### Community Sponsors
<p>
<a href="https://github.com/dnakov"><img src="https://github.com/dnakov.png" width="40" height="40" style="border-radius:50%" alt="dnakov" title="dnakov" /></a>
<a href="https://github.com/AkramReshad"><img src="https://github.com/AkramReshad.png" width="40" height="40" style="border-radius:50%" alt="AkramReshad" title="AkramReshad" /></a>
<a href="https://github.com/ajmalaksar25"><img src="https://github.com/ajmalaksar25.png" width="40" height="40" style="border-radius:50%" alt="ajmalaksar25" title="ajmalaksar25" /></a>
@@ -283,6 +212,8 @@ If Taste Skill helps you, consider sponsoring:
<a href="https://github.com/emilkowalski"><img src="https://github.com/emilkowalski.png" width="40" height="40" style="border-radius:50%" alt="emilkowalski" title="emilkowalski" /></a>
<a href="https://github.com/AnthonyLiuVa"><img src="https://github.com/AnthonyLiuVa.png" width="40" height="40" style="border-radius:50%" alt="AnthonyLiuVa" title="AnthonyLiuVa" /></a>
<a href="https://github.com/Wolfy18"><img src="https://github.com/Wolfy18.png" width="40" height="40" style="border-radius:50%" alt="Wolfy18" title="Wolfy18" /></a>
<a href="https://github.com/Gigslol"><img src="https://github.com/Gigslol.png" width="40" height="40" style="border-radius:50%" alt="Gigslol" title="Gigslol" /></a>
</p>
<p align="center">
<a href="https://www.star-history.com/leonxlnx/taste-skill">
@@ -294,21 +225,9 @@ If Taste Skill helps you, consider sponsoring:
</a>
</p>
## Research
<sub>Feedback: open an issue or pull request, DM [@lexnlin](https://x.com/lexnlin) or [@blueemi99](https://x.com/blueemi99), or email [hello@tasteskill.dev](mailto:hello@tasteskill.dev).</sub>
The default skill rests on a study of 247 curated sites: method, numbers and blind tests are in [`skills/taste-skill/references/research.md`](skills/taste-skill/references/research.md). Earlier background writing lives in [`research/`](research/).
## Feedback & Contributions
Suggestions and bug reports are welcome:
- Open an issue or a pull request on GitHub
- DM [@lexnlin](https://x.com/lexnlin) or [@blueemi99](https://x.com/blueemi99)
- Email [hello@tasteskill.dev](mailto:hello@tasteskill.dev)
## Disclaimer
Taste Skill has no official token, coin, or crypto project. Any token using my name, image, or project is unaffiliated and not endorsed by me.
<sub>Taste Skill has no official token, coin, or crypto project. Any token using my name, image, or project is unaffiliated and not endorsed by me.</sub>
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

-8
View File
@@ -1,8 +0,0 @@
# Research
Background research that informed the skills in this project. Each topic gets its own subfolder.
## Topics
### [LLM Laziness](laziness/)
Why AI models produce incomplete outputs (placeholder code, truncated responses, skipped sections) and documented techniques to prevent it. Covers root causes, parameter fixes, prompt techniques, and experiment data.
-25
View File
@@ -1,25 +0,0 @@
# LLM Output Truncation Research
A structured analysis of why large language models produce incomplete outputs, and documented methods to restore full-fidelity generation. All findings are drawn from controlled experiments, published studies, and field-tested engineering practices.
## Directory Structure
### Root Causes
Analysis of the economic, architectural, and behavioral mechanisms that drive output truncation in production LLMs.
- [RLHF and Compute Economics](root-causes/rlhf-and-compute.md) — How reinforcement learning and cost optimization create systematic brevity bias.
- [Training Data Bias](root-causes/training-data-bias.md) — How placeholder patterns in human-written code propagate into model outputs.
- [Cognitive Shortcuts](root-causes/cognitive-shortcuts.md) — Empirical evidence of models taking shortcuts on complex or lengthy tasks.
- [Output Limits](root-causes/output-limits.md) — Context window asymmetry and consumer-tier truncation mechanisms.
### Remediation
Documented techniques for overriding default truncation behavior, ordered from parameter-level fixes to full architectural solutions.
- [Parameter Tuning](remediation/parameter-tuning.md) — Temperature, Top-p, and Gemini thinking-level configuration.
- [Prompt Engineering](remediation/prompt-engineering.md) — Structural prompt techniques: syntax binding, XML frameworks, and verification loops.
- [Architectural Patterns](remediation/architectural-patterns.md) — MCP integration, lazy-loaded skills, and developer platform access.
- [Reference Prompts](remediation/reference-prompts.md) — Ready-to-use prompt templates for enforcing complete outputs.
### Findings
- [Empirical Results](findings/empirical-results.md) — Controlled experiment data from 2025 academic studies.
- [References](findings/references.md) — Cited studies and further reading.
@@ -1,58 +0,0 @@
# Empirical Results
## 2025 Controlled Experiments
A controlled study published in December 2025 measured output truncation across several frontier models, including GPT-4 variants and DeepSeek. Three experiments were conducted:
### Experiment A: Multi-Part Instruction Compliance
Models were given complex prompts with multiple explicit requirements (formatting constraints, length requirements, mandatory sections). Results:
- No model fully satisfied both length requirements and all sub-part instructions natively
- Models frequently omitted mandatory output sections
- Required formatting constraints were routinely skipped
- Explicit length requirements were consistently undershot
### Experiment B: Decoding Suboptimality
Tested whether truncated outputs resulted from suboptimal token selection (the model "knowing" the right answer but selecting a worse token). Results:
- Limited evidence of decoding suboptimality on simple reasoning tasks
- The model's greedy, truncated output generally aligned with its highest-confidence solution
- Truncation is a deliberate behavioral choice, not a decoding failure
### Experiment C: Context Degradation
Tested whether models lose track of instructions during long, multi-turn conversations. Results:
- Surprising resilience against context degradation during 200-turn conversational tests
- Models maintained key facts and instructions significantly better than hypothesized
- Context loss is not the primary cause of truncation
### Key Conclusion
Laziness is not a failure of memory, context processing, or core model capabilities. It is a behavioral artifact triggered by:
1. Instruction complexity exceeding internal effort thresholds
2. Aggressively calibrated stopping pressure
3. Economic constraints embedded in the alignment layer
## Prompt Stimulus Effectiveness (Microsoft Research)
Controlled testing of psychological prompt stimuli documented in a Microsoft Research study:
| Stimulus | Measured Effect |
|:---|:---|
| Financial incentive framing ("$200 tip") | +45% output quality and length |
| Step-by-step instruction ("take a deep breath") | Accuracy: 34% to 80% on logic tasks |
| Stakes framing ("critical to my career") | +10% average performance |
| Combined (multiple stimuli) | Up to +115% overall performance |
These effects are reproducible and stem from statistical correlations in the training data between stakes language and high-effort human outputs.
## Seasonal Output Variation
Statistical analysis of ChatGPT outputs during November-December 2023 versus January-March 2024 confirmed:
- Measurable decrease in average output length during December
- Correlation with reduced work output in the training data during holiday periods
- Output length increased when the system prompt explicitly stated a non-winter month
-20
View File
@@ -1,20 +0,0 @@
# References
## Cited Studies
- **EmotionPrompt (Microsoft Research)** — Demonstrates that emotional and stakes-based prompt framing mathematically improves LLM reasoning quality and output length. Documents the +45% improvement from financial framing and +115% from combined stimuli.
- **LazyBench** — Proves that frontier models (Gemini 1.5 Pro, GPT-4o) actively select cognitive shortcuts and fail tasks they are capable of solving when the perceived effort exceeds internal thresholds.
- **Compounding Error Avoidance** — Research demonstrating that models truncate outputs as a risk mitigation strategy, preferring shorter responses to reduce the surface area for factual errors on long-form tasks.
- **Seasonal Behavior Analysis (Winter Break Hypothesis)** — Statistical analysis confirming that LLMs internalize seasonal work patterns from training data, producing measurably shorter outputs during periods corresponding to human holiday seasons.
- **2025 Controlled Laziness Experiments** — Three-part academic study (December 2025) confirming that output truncation is a behavioral artifact of alignment training, not a failure of context processing or model capability.
## Further Reading
- Google Gemini API documentation on `thinking_level` parameter configuration
- Anthropic MCP (Model Context Protocol) specification and integration guides
- OpenAI API reference for temperature and Top-p parameter tuning
- YAML front-matter specification for SKILL.md lazy-loading architecture
@@ -1,55 +0,0 @@
# Architectural Patterns
## Lazy-Loaded Skills
The standard pattern for managing large context requirements across AI agents is lazy-loaded prompt engineering through skill files.
A skill is a folder containing a `SKILL.md` file with:
- **YAML front-matter:** Contains `name` and a precise `description`. This metadata acts as the discovery hook — the agent reads only this during initialization (~100 tokens per skill).
- **Markdown body:** Full workflows, rules, and instructions. Loaded on-demand only when the agent determines the skill is relevant.
This architecture yields a documented 35% reduction in average context usage and prevents context dilution. However, discovery reliability depends on the specificity of the YAML description:
| Description Quality | Discovery Success Rate |
|:---|:---:|
| Vague ("Helps with designing APIs") | ~68% |
| Specific ("Design RESTful HTTP APIs with OpenAPI specs, focusing on versioning, error codes, and backward compatibility") | ~90% |
## Model Context Protocol (MCP)
MCP is an open standard (pioneered by Anthropic, adopted by Google and OpenAI) that enables real-time, bidirectional connections between LLMs and external data sources.
### Architecture Components
- **Host:** The AI application (IDE, terminal tool, chatbot) containing the LLM engine.
- **Client:** Internal bridge within the host that handles protocol communication.
- **Server:** External service exposing databases, APIs, or documentation to the client.
- **Transport:** JSON-RPC 2.0 messages over stdio (local) or HTTP (remote).
### How It Reduces Truncation
Without MCP, models rely on static training weights for factual claims. When those weights are outdated (e.g., a new API version was released after training cutoff), the model either hallucinates a plausible answer or truncates its response to avoid committing to specifics.
With MCP, the model fetches current documentation directly into its context window. This transforms the model from a static knowledge store into a reasoning engine operating on real-time data, eliminating the incentive to hallucinate or truncate.
### Example: Developer Knowledge API
Google's Developer Knowledge MCP Server indexes live documentation across Firebase, Android, and Google Cloud. When a model receives a development question:
1. It executes a `search_document` query against the live index
2. It evaluates returned page URIs
3. It fetches full document content via `get_document` or `batch_get_documents`
4. It generates its response based on current, authoritative documentation
This entirely bypasses the tendency to fabricate answers from outdated training data.
## Chunked Task Execution
For complex tasks that would produce outputs exceeding the model's generation limit, break the work into sequential steps:
1. Request the architecture and structure first (outline only)
2. Request each component individually with explicit instructions for completeness
3. Request assembly and integration after all components are generated
This prevents the model from attempting to estimate total output length and preemptively compressing its response.
@@ -1,44 +0,0 @@
# Parameter Tuning
## Temperature and Top-p
Autoregressive models select each next token from a probability distribution generated by a softmax function applied to logit values. When a model defaults to brief outputs, the tokens associated with truncation and summarization have been assigned the highest probabilities through RLHF alignment.
### Temperature
Adjusting the temperature parameter changes how the softmax function distributes probability mass across candidate tokens.
- **Low temperature (0.0 - 0.5):** Amplifies differences between high and low-probability tokens. The model becomes highly deterministic, consistently selecting the highest-confidence continuation. Optimal for code generation, data extraction, and structured output.
- **Default temperature (1.0):** Retains the original probability distribution from training.
- **High temperature (1.5+):** Flattens the distribution, introducing more randomness. Useful for creative tasks but increases the risk of incoherent outputs.
Example probability distribution shift for a single token position:
| Token Candidate | Probability at Temp 1.5 | Probability at Temp ~0.0 | Raw Logit |
|:---|:---:|:---:|:---:|
| lazy | 0.4875 | 0.9933 | 2.0 |
| quick | 0.2503 | 0.0067 | 1.0 |
| tired | 0.1285 | 0.0000 | 0.0 |
| slow | 0.0660 | 0.0000 | -1.0 |
| clumsy | 0.0339 | 0.0000 | -2.0 |
### Top-p (Nucleus Sampling)
Top-p truncates the probability distribution by only considering the smallest set of tokens whose cumulative probability exceeds threshold p. A Top-p of 0.0 to 0.6 combined with low temperature forces the model into a narrow, deterministic execution path, reducing the entropy that enables creative refusals and unnecessary summarization.
## Gemini Thinking Level Configuration
Google Gemini 3 models replaced the legacy `thinking_budget` (a hard token count cap on internal reasoning) with a `thinking_level` parameter that provides relative guidance on computational depth.
| Setting | Flash Support | Pro Support | Use Case |
|:---|:---:|:---:|:---|
| `minimal` | Yes | No | High-throughput, low-latency tasks |
| `low` | Yes | Yes | Simple instruction following, data extraction |
| `medium` | Yes | Yes (3.1 Pro) | Moderate complexity tasks |
| `high` | Yes (Default) | Yes (Default) | Complex analysis, code generation, mathematics |
Important constraints:
- `thinking_level` and `thinking_budget` are mutually exclusive. Using both in one API call triggers an HTTP 400 error.
- Even at `low`, Gemini Pro models perform mandatory minimum internal deliberation for safety and alignment.
- For code generation and complex analysis, set to `medium` or `high` for quality scores consistently exceeding 92-95% compared to baseline.
- Avoid combining extremely low temperature with `high` thinking level, as this can occasionally induce internal reasoning loops.
@@ -1,52 +0,0 @@
# Prompt Engineering Techniques
## Psychological Pattern Matching
LLMs do not have emotions or understand monetary incentives. However, specific linguistic patterns in the prompt activate different quality distributions in the model's latent space. Research has documented measurable effects:
| Technique | Documented Effect |
|:---|:---|
| "I will tip you $200 for a perfect solution" | Up to 45% increase in output quality and length |
| "Take a deep breath and solve step by step" | Accuracy improvement from 34% to 80% on logic tasks |
| "This task is critical to my career" | Average 10% performance increase |
These phrases work because they are statistically correlated with high-effort, rigorously reviewed content in the training data (academic papers, enterprise codebases, legal documents). The attention mechanism prioritizes the high-quality data distributions associated with these patterns.
## Explicit Syntax Binding
Conversational requests allow the model to exercise discretion about output length and detail. Structural binding removes this discretion by explicitly prohibiting truncation patterns.
Effective binding requires two components:
1. **Mandatory tool execution:** Forbid the model from generating answers solely from training weights. Require it to execute search, computation, or code before answering.
2. **Evidence blocks:** Require the model to output raw data (URLs, code execution results, data fragments) before producing its narrative response. This forces the model to read its own retrieved evidence, reducing hallucination probability to near zero.
## XML-Structured Prompts
Enterprise systems use strict XML tagging to separate prompt components, reducing the cognitive load required for the model to parse intent:
1. **System instructions** — Persona definition, quality expectations, explicit prohibitions on filler content.
2. **Context block** (`<context>`) — Passive background data: architecture details, configurations, existing code.
3. **Data block** (`<data>`, `<logs>`, `<config>`) — Active information the model must process against the context.
4. **Task block** (`<tasks>`) — Numbered list of specific actions to execute.
This compartmentalization ensures the model can distinguish between persistent rules, background context, and immediate work items. It significantly reduces the confusion that triggers premature truncation.
## Verification Loops
### Chain of Verification
1. Model generates an initial response
2. Model generates verification questions about its own claims
3. Model independently answers those verification questions
4. Model outputs a revised, evidence-backed response
This process forces iterative self-correction, consuming the model's capacity for shortcutting.
### Reverse Prompting
Instead of manually constructing a structured prompt, provide the model with a one-line objective and instruct it to generate the optimal prompt for that objective. The model produces the XML structure, constraints, and roles required for the task.
### Self-Grading Loop
The prompt requires the model to:
1. Define what excellence looks like for the given task
2. Grade its own initial output against that definition
3. Iterate until the self-defined quality bar is met
@@ -1,79 +0,0 @@
# Reference Prompts
Ready-to-use prompt templates for enforcing complete outputs. Append to any prompt or include in system instructions.
---
## General Purpose
```
You must provide the FULL, complete, and exhaustive output for this task.
Do not summarize, abbreviate, or truncate for brevity.
You are strictly forbidden from using placeholders. Never use comments like
"// ... rest of code here", "[continue here]", or bare ellipses standing
in for omitted content. If the output is 500 lines, produce all 500 lines.
If you approach your output limit, stop at a clean breakpoint and indicate
where to resume. Do not rush to a conclusion or compress remaining sections.
```
---
## Code Generation
```
Write the complete, production-ready implementation. Every function, every
import, every edge case handler must be present in the output.
Do not use placeholder comments (// TODO, // implement here, // similar
to above). Do not describe what code should do — write the actual code.
If the implementation requires multiple files, output each file completely
with its full path as a header.
```
---
## Analysis and Documentation
```
Provide an exhaustive analysis covering every aspect requested. Each section
must contain substantive content, not summaries or references to "see above."
Do not use phrases like "as mentioned earlier" to avoid repeating necessary
context. Each section should be self-contained and complete.
Structure your output with clear headings. If the analysis requires multiple
parts, produce all parts in full.
```
---
## Step-by-Step Reasoning
```
Before generating your final response, work through the problem systematically:
1. Identify all requirements and constraints from the prompt
2. Break the task into discrete steps
3. Execute each step completely
4. Verify your output against the original requirements
Output your reasoning process, then your final answer. Do not skip steps
or summarize intermediate work.
```
---
## Continuation Handling
```
If your response approaches the output token limit:
- Do not compress remaining content to fit
- Do not skip ahead to a conclusion
- Stop at a natural breakpoint (end of a function, end of a section)
- End with: [PAUSED - X of Y sections complete. Send "continue" to resume]
On "continue", pick up exactly where you stopped. No recaps or repetition.
```
@@ -1,23 +0,0 @@
# Cognitive Shortcuts
## The LazyBench Discovery
Research from late 2024 demonstrated that frontier models (including Gemini Pro and GPT-4o) exhibit measurable cognitive shortcutting behavior. When a model perceives a task as straightforward or the provided context as excessively long, it reduces its internal computational effort. Rather than executing full multi-step reasoning, it produces a surface-level summary.
This is not a memory failure or context degradation — the model retains the information but chooses not to process it at full depth.
## Metacognitive Laziness
The interaction between model brevity and human behavior creates a feedback loop. As models provide instant, condensed answers, users increasingly offload inference and logical deduction work. Research from the European Research Council has documented measurable declines in working memory engagement among populations with high AI dependency.
In professional environments, this shifts critical thinking from original synthesis to "prompt verification" — users evaluate whether the AI's truncated output seems reasonable rather than performing the analysis themselves.
## Seasonal Behavior Anomalies
In late 2023, researchers observed a statistically significant increase in ChatGPT output brevity during December. Analysis revealed that the training data contains fewer detailed work outputs, more out-of-office responses, and shorter code commits during holiday periods. The model internalized this seasonal pattern.
When researchers explicitly stated "It is May" in the system prompt, output length measurably increased. This finding demonstrates that even arbitrary contextual signals in the prompt can shift the model's brevity calibration.
## Error Avoidance as Truncation Driver
Models also truncate outputs as a risk mitigation strategy. On long-form tasks, longer outputs increase the probability of compounding errors and hallucinated content. The model has learned that shorter outputs reduce the surface area for factual mistakes, creating an additional incentive to truncate that compounds with the RLHF brevity bias.
@@ -1,39 +0,0 @@
# Output Limits and Consumer Truncation
## Context Window Asymmetry
Models like Gemini have massive input context windows (up to 2 million tokens) but strictly capped output limits (typically 8,000 tokens). When the model estimates that a complete response would exceed its output budget, it preemptively compresses or summarizes the output rather than risking an abrupt cutoff.
This creates a paradox: the model can read extensive inputs but cannot respond proportionally, leading to systematic information loss on complex tasks.
## The Consumer Middleware Problem
Consumer-facing applications (gemini.google.com, standard ChatGPT tiers) apply additional software-level truncation on top of the model's inherent limits. This middleware silently truncates conversation history and uploaded files to reduce compute costs for free and low-tier users.
Key mechanisms:
- **History capping:** Many consumer interfaces cap active conversation history at approximately 32,000 tokens, regardless of the model's actual capacity.
- **Context pruning:** Large system instructions or saved personal context consume tokens that would otherwise be available for the conversation, effectively shrinking the working window.
- **Retrieval-based recall:** Consumer apps often use retrieval mechanisms to selectively inject saved context, meaning the model frequently drops instructions it was given earlier in the session.
## Developer Platform Differences
Direct API access and developer platforms (Google AI Studio, OpenAI API Playground) bypass consumer middleware entirely. These environments provide:
- Full context window access without hidden truncation
- Complete control over generation parameters
- No dynamic throttling based on user tier
- Processing of complex prompt structures without middleware interference
The practical difference is significant: the same model that produces truncated outputs through a consumer interface will generate complete, unabridged responses when accessed through direct API endpoints.
## Terminal and CLI Integration
Purpose-built CLI tools (Gemini CLI, Claude Code, third-party wrappers) offer additional advantages for avoiding truncation:
| Access Method | Context Handling | Truncation Risk | Parameter Control |
|:---|:---|:---|:---|
| Consumer web app | Aggressive pruning, 32K cap | High | Limited |
| Developer platform (AI Studio) | Full context, no hidden slicing | Low | Full |
| Direct API | Full context, raw access | Minimal | Full |
| CLI tools with local models | No corporate alignment filters | None | Full |
@@ -1,27 +0,0 @@
# RLHF and Compute Economics
## The Cost of Token Generation
Every token an LLM generates consumes GPU compute resources. At an estimated baseline cost of $0.0001 per token, scaling deep multi-step reasoning across hundreds of millions of users would exhaust the financial capacity of any provider. This creates an inherent economic incentive to minimize output length.
## Brevity Bias Through Alignment
To manage infrastructure costs, model providers use Reinforcement Learning from Human Feedback (RLHF) and behavioral fine-tuning to instill systematic brevity preferences. During post-training alignment, models are rewarded for generating short, confident summaries rather than executing the full compute cycles needed for exhaustive analysis.
The result is a trained preference for producing generalized approximations over rigorous, multi-step solutions. The model does not necessarily produce incorrect answers, but it consistently produces answers that lack depth — saving itself from deeper analytical work unless the user explicitly forces it.
## Stopping Pressure
Autoregressive models generate text token by token and lack an inherent mechanism for recognizing task completion. To prevent infinite generation, training introduces "stopping pressure" — a learned tendency to conclude outputs.
In recent model iterations, this stopping pressure has been calibrated aggressively to preserve compute. This leads to:
- Skipping required structured output fields, particularly long-form content in JSON or markdown
- Halting mid-task with phrases like "let me know if you want me to continue"
- Refusing to produce comprehensive solutions, suggesting the user "think about it"
This aggressive calibration is further reinforced by safety tuning protocols, which inject additional behavioral constraints that make models resistant to generating large codebases or detailed reviews.
## Dynamic Throttling
Providers dynamically scale back model performance during peak demand periods. This introduces additional friction beyond what the base alignment already imposes, resulting in even shorter and less detailed outputs when server load is high.
@@ -1,28 +0,0 @@
# Training Data Bias
## Placeholder Propagation
LLMs learn by imitating patterns in human-written text. A significant portion of their training data comes from sources like Stack Overflow, GitHub repositories, and tutorial blogs. In these sources, human developers routinely write abbreviated code:
```python
def complex_logic():
# implement auth here
pass
```
The model internalizes this pattern and treats placeholder insertion as a legitimate, professional response format. It is not deliberately withholding content — it has been trained to believe that truncating code with comments is the correct way to answer technical questions.
## Pattern Reinforcement
This behavior is reinforced across multiple data sources:
- **Code tutorials** frequently show partial implementations with comments indicating where students should complete the logic
- **Documentation** often uses abbreviated examples with ellipses
- **Forum answers** regularly provide skeleton code rather than full implementations
- **Blog posts** truncate repetitive code blocks with "similarly for the remaining cases"
The cumulative effect is that the model assigns high probability to truncation tokens in contexts where complete code generation would be appropriate.
## Impact on Output Quality
When a user requests a complete implementation, the model faces competing training signals: the explicit instruction to produce full output versus the deeply embedded pattern of producing abbreviated, "tutorial-style" responses. Without aggressive prompt engineering, the tutorial-style pattern frequently wins because it appears far more commonly in the training distribution.
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Local skill registry. Each skill is listed by its install name (the `name:` in its SKILL.md,
# the value `npx skills add --skill` takes) and by its folder name.
declare -A SKILLS=(
[design-taste-frontend]="skills/taste-skill/SKILL.md"
[design-taste-frontend-v2-experimental]="skills/taste-skill-v2-experimental/SKILL.md"
[design-taste-frontend-v1]="skills/taste-skill-v1/SKILL.md"
[image-to-code]="skills/image-to-code-skill/SKILL.md"
[redesign-existing-projects]="skills/redesign-skill/SKILL.md"
[high-end-visual-design]="skills/soft-skill/SKILL.md"
[full-output-enforcement]="skills/output-skill/SKILL.md"
[minimalist-ui]="skills/minimalist-skill/SKILL.md"
[industrial-brutalist-ui]="skills/brutalist-skill/SKILL.md"
[stitch-design-taste]="skills/stitch-skill/SKILL.md"
[gpt-tasteskill]="skills/gpt-tasteskill/SKILL.md"
[taste-skill]="skills/taste-skill/SKILL.md"
[taste-skill-v2-experimental]="skills/taste-skill-v2-experimental/SKILL.md"
[taste-skill-v1]="skills/taste-skill-v1/SKILL.md"
[gpt-taste]="skills/gpt-tasteskill/SKILL.md"
[image-to-code-skill]="skills/image-to-code-skill/SKILL.md"
[imagegen-frontend-web]="skills/imagegen-frontend-web/SKILL.md"
[imagegen-frontend-mobile]="skills/imagegen-frontend-mobile/SKILL.md"
[brandkit]="skills/brandkit/SKILL.md"
[redesign-skill]="skills/redesign-skill/SKILL.md"
[soft-skill]="skills/soft-skill/SKILL.md"
[output-skill]="skills/output-skill/SKILL.md"
[minimalist-skill]="skills/minimalist-skill/SKILL.md"
[brutalist-skill]="skills/brutalist-skill/SKILL.md"
[stitch-skill]="skills/stitch-skill/SKILL.md"
)
print_help() {
echo "Usage: source ./skill.sh <skill-name>"
echo " source ./skill.sh --list"
echo
echo "Available skills:"
printf '%s\n' "${!SKILLS[@]}" | sort
}
skill_main() {
if [[ $# -eq 0 || "$1" == "-h" || "$1" == "--help" ]]; then
print_help
return 0
fi
if [[ "$1" == "--list" ]]; then
printf '%s\n' "${!SKILLS[@]}" | sort
return 0
fi
local skill_name="$1"
local skill_path="${SKILLS[$skill_name]-}"
if [[ -z "$skill_path" ]]; then
echo "Unknown skill: $skill_name" >&2
echo >&2
print_help >&2
return 2
fi
echo "$skill_path"
}
skill_main "$@"
-843
View File
@@ -1,843 +0,0 @@
---
name: brandkit
description: Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.
---
# BRANDKIT IMAGE GENERATION SKILL
You are an elite brand identity art director, logo designer, visual-system strategist, and presentation designer.
Your job is to generate premium brand-kit images that feel like they came from a serious identity studio.
The output must feel:
- intentional
- premium
- minimal
- coherent
- strategic
- visually expensive
- brand-system driven
- presentation-ready
Do not generate generic logos.
Do not generate random mockups.
Do not generate messy AI moodboards.
Create a complete brand world in one image.
---
# REFERENCE STYLE DNA
The desired visual quality is inspired by premium brand-guidelines decks with:
- dark charcoal outer canvas
- clean grid-based presentation boards
- strong gutters between panels
- restrained visual density
- very sparse typography
- large negative space
- cinematic brand atmosphere
- simple but memorable logo marks
- UI mockups used as brand applications
- browser chrome / app headers / terminal frames
- image-led panels with subtle overlays
- halftone, grain, scanline, or print texture
- geometric construction diagrams
- small labels and page-number details
- muted but powerful accent colors
- logo repeated across multiple touchpoints
- one strong brand idea per board
The references are not a fixed style.
They define the quality bar, restraint, and presentation logic.
---
# CORE PRINCIPLE
A premium brand kit is not decoration.
It is a visual argument for why the brand exists.
Every generated board must answer:
1. What does this brand represent?
2. What is the core metaphor?
3. How does the logo express that?
4. How does the system scale across UI, print, image, and detail?
5. Why does the whole thing feel ownable?
---
# DEFAULT OUTPUT
Unless the user specifies otherwise:
- Generate one brand-kit overview image
- Default layout: `3 × 3`
- Default aspect ratio: `4:3` or `16:10`
- Use a clean presentation grid
- Use consistent gutters
- Use minimal text
- Make every panel feel connected
Canvas size and aspect ratio describe the whole output image, not the panel grid.
Honor requested dimensions (width × height), aspect ratio, or orientation over the defaults. Include them in the internal prompt and use the generator's supported size or aspect-ratio controls accordingly.
If the generator cannot produce the exact requested dimensions or ratio, explain the limit and available alternatives. Use a supported size that preserves the requested ratio where possible. Ask the user to choose only when cropping, resizing, or changing the composition would materially affect the result. Report the actual output size; do not promise unsupported pixel dimensions.
Adapt the grid and spacing to square, portrait, or wide canvases without stretching panels. Keep the defaults when no custom size or layout is requested.
Allowed layouts:
- `3 × 3` full identity system
- `2 × 3` cinematic brand deck overview
- `2 × 2` compact concept board
- `1 × 3` horizontal brand strip
- `4 × 2` wide contact-sheet layout
- custom layout when requested
For inspiration references, match their quality and rhythm, not their exact content.
---
# BRAND INTAKE
Before image generation, determine whether this is a new identity or an extension of an existing brand. Choose based on the user's stated intent, not on whether assets are attached.
Use the request, earlier context, and supplied assets first. Do not ask again for information already provided.
If unclear, ask one short intake question covering only the missing details:
- create from scratch, extend, or refresh / rebrand
- existing logo / wordmark
- brand colors / palette
- typography / fonts
- visual direction / mood references
Inspiration or mood references alone do not establish an existing identity; treat them as brand constraints only when the user identifies them that way.
Choose the flow:
- **Create from scratch:** If the user requests a new identity or confirms that no existing brand identity applies, follow the full generation flow below.
- **Build on existing brand:** Reuse supplied logos, names, colors, typography, and direction consistently across the board and mockups. Do not invent replacements or reinterpret the logo.
For partial inputs, preserve supplied elements and develop only missing parts to fit them. If existing assets are needed but unavailable, request them and wait before generation.
An explicit refresh or rebrand request takes priority; change only the elements within the requested scope.
Existing identity constraints take priority over the style, palette, and new-logo defaults below.
---
# BRAND STRATEGY FIRST
Before generating, infer the brand strategy.
Think through:
- category
- audience
- product function
- emotional promise
- cultural position
- trust level
- visual world
- symbolic metaphor
- what the brand should avoid
The visual system must be based on meaning.
Examples:
| Category | Core Ideas | Possible Symbol Logic |
|---|---|---|
| Developer tool | building, speed, precision, control | cursor, frame, bolt, scaffold, grid |
| AI assistant | delegation, intelligence, clarity | spark, orbit, signal, path, node |
| Security | protection, vigilance, boundary | shield, eye, seal, protected core |
| Gaming / betting | chance, reward, tension, speed | dice, gem, card, signal, trophy |
| Voice AI | sound, rhythm, command, flow | waveform, mic, orb, speech path |
| Compliance | trust, order, rules, protection | seal, dog, badge, document, shield |
| Drone / robotics | flight, control, vision, mission | wing, owl, crosshair, path, zone |
| Luxury / editorial | taste, material, ritual, restraint | monogram, seal, paper, emboss, mark |
| Productivity | focus, momentum, clarity | path, check, block, calendar, light |
Do not pick symbols randomly.
---
# LOGO GENERATION STANDARD
Apply this section and the logo concept methods only when a new logo is needed or requested.
The logo must be professional.
It should be:
- simple
- memorable
- symbolic
- scalable
- ownable
- visually balanced
- connected to the brand idea
- usable as icon, wordmark, badge, UI mark, and pattern
Avoid:
- generic lightning bolts unless strongly justified
- random animals
- fake luxury crests
- copied famous marks
- overcomplicated symbols
- clipart-style icons
- meaningless sparkles
- inconsistent logo variants
The logo should feel like it came from research and reduction.
---
# LOGO CONCEPT METHODS
Use one or combine two maximum.
## 1. Monogram + Meaning
Combine the brand initial with a metaphor.
Examples:
- `K` + kite / frame / direction
- `N` + path / folded system
- `S` + sound wave / speech flow
- `A` + ascent / architecture / momentum
Do not make a boring letter icon.
Use negative space, cuts, folds, or geometry.
---
## 2. Product Action
Turn the product's main action into a symbol.
Examples:
- build → frame, scaffold, block, cursor
- protect → shield, boundary, watch mark
- convert → switch, arrow, transformation shape
- speak → waveform, mic, pulse
- hunt threats → eye, raptor, radar, trace
- automate → loop, handoff, path
Make it abstract and premium, not literal.
---
## 3. Metaphor Fusion
Combine two meaningful ideas into one reduced mark.
Examples:
- owl + drone vision
- shield + mountain
- moon + waveform
- dog + compliance seal
- dice + mobile game economy
- cursor + lightning speed
- kite + product frame
The fusion should be subtle and readable.
---
## 4. Negative Space
Use empty space to create intelligence.
Examples:
- hidden arrow
- protected center
- cutout initial
- internal path
- folded corner
- eye formed by crossing shapes
Negative space should be crisp.
---
## 5. Construction Geometry
Create a mark from a clear system.
Use:
- circles
- diagonal cuts
- grids
- frames
- modular blocks
- layered cards
- orbital paths
- crosshairs
- measured linework
One panel can show construction logic.
---
# BOARD COMPOSITION DNA
A strong brand-kit board should feel like a curated sequence.
Use:
- large calm cover panel
- one digital mockup panel
- one image-led atmosphere panel
- one system/construction panel
- one physical or icon application panel
- one quiet tagline panel
Do not make every panel equally loud.
The board should have rhythm:
- quiet
- functional
- emotional
- technical
- atmospheric
- detailed
---
# DEFAULT 3 × 3 PANEL SYSTEM
Use this if no layout is specified and it fits the canvas; otherwise arrange these panels to suit the requested size or aspect ratio:
## 1. Logo Cover
Large logo and wordmark.
Minimal title.
Strong negative space.
## 2. Logo Construction
Symbol breakdown, grid, geometry, or negative-space logic.
Show why the mark exists.
For an existing logo without documented construction, show supplied variants or usage instead.
## 3. Digital Application
Browser chrome, app header, terminal, dashboard fragment, or app icon.
## 4. Brand Essence
One short tagline.
Large readable typography.
Sparse composition.
## 5. Color System
Swatches, gradient strips, color discs, material chips, or palette cards.
## 6. Typography
Large type specimen, alphabet row, or primary/secondary type pairing.
## 7. Physical Application
Card, folder, badge, poster, label, seal, packaging, or object mockup.
## 8. Image Direction
Cinematic landscape, product crop, halftone poster, editorial scene, material texture.
## 9. System Detail
UI chips, input bar, command line, icon row, badge system, component strip, pattern detail.
---
# 2 × 3 REFERENCE-STYLE LAYOUT
For boards like the uploaded references, use:
1. **Logo / Wordmark**
- centered or offset
- extremely minimal
2. **Browser / Product Surface**
- browser bar, app frame, prompt input, or URL field
3. **Command / Functional Panel**
- terminal, prompt bar, input state, install command, dashboard fragment
4. **Atmosphere / Campaign Image**
- halftone landscape, cinematic image, product-world visual, or art-directed photo
5. **Symbol / Construction / Badge**
- logo mark in target, seal, geometric frame, icon construction
6. **Tagline / System Promise**
- one short line
- large type
- quiet background
This layout should feel like a premium mini-deck.
---
# VISUAL MODES
Choose based on the brand.
## Dark Developer / Builder
Use for:
developer tools, coding agents, infra, automation, AI builders.
Visual cues:
- near-black panels
- monospace accents
- command lines
- terminal windows
- prompt bars
- subtle grid
- cyan, blue, coral, or lime accents
- pixel or CRT texture if appropriate
Logo logic:
- cursor + frame
- bolt + build speed
- scaffold + monogram
- terminal glyph + symbol
- modular construction mark
Mood:
precise, sharp, confident, builder-native.
---
## Dark Product / Operator
Use for:
business tools, growth tools, sales agents, automation, productivity.
Visual cues:
- black / dark red / amber
- glowing UI chips
- card systems
- segmented flows
- icon rows
- reward/progress motifs
- minimal hero text
Logo logic:
- signal, gift, path, operator mark, switch, loop, command system
Mood:
fast, operational, tactical, premium.
---
## Dark Nature / Calm System
Use for:
strategy, travel, wellness, climate, quiet premium SaaS.
Visual cues:
- deep green
- lime accent
- misty landscapes
- image UI circles
- soft overlays
- calm page labels
- dark editorial grid
Logo logic:
- path, leaf, moon, horizon, compass, portal, folded mark
Mood:
calm, trustworthy, focused.
---
## Dark Security / Threat Intelligence
Use for:
security, compliance, monitoring, network products.
Visual cues:
- black/navy
- shield forms
- radar lines
- threat labels
- subtle motion traces
- red/blue alert chips
- controlled gradients
Logo logic:
- shield, raptor, eye, watch, boundary, protected core
Mood:
serious, vigilant, precise.
---
## Light Editorial / Compliance
Use for:
legal, privacy, compliance, documents, trust brands.
Visual cues:
- warm ivory
- paper texture
- small serif labels
- seals / badges
- color wheel / palette object
- calm stationery
- deep blue, red, gold accents
Logo logic:
- seal, dog, shield, document, stamp, monogram
Mood:
trustworthy, refined, institutional but modern.
---
## Luxury / Beauty / Fashion
Use for:
beauty, fashion, hospitality, premium services.
Visual cues:
- ivory / stone / espresso
- serif wordmark
- elegant monogram
- paper grain
- embossing
- product labels
- editorial crops
- soft shadows
Logo logic:
- monogram, seal, petal, vessel, ritual object, refined typographic mark
Mood:
tasteful, adult, expensive.
---
## Voice / Communication
Use for:
voice AI, chat, assistants, speech, audio.
Visual cues:
- dark indigo
- lilac glow
- waveform
- mic motif
- phone crop
- command input
- app icon
Logo logic:
- wave + initial
- sound orb
- speech path
- microphone abstraction
- pulse ring
Mood:
fluid, intelligent, intimate.
---
## Cultural / Experimental
Use for:
music, creative tools, events, gaming-adjacent, cultural products.
Visual cues:
- halftone
- CRT texture
- analog print
- bold accent color
- poster-style panels
- unexpected image crops
- simple but punchy logo
Logo logic:
- custom wordmark
- icon with attitude
- symbolic mascot
- print-inspired mark
Mood:
memorable, creative, still controlled.
---
# PREMIUM DETAIL LANGUAGE
Use details like:
- small page numbers
- tiny footer labels
- precise alignment marks
- construction lines
- subtle crosshair grids
- thin rules
- browser bars
- rounded rectangles
- image masks
- soft shadows
- low-opacity texture
- halftone image treatment
- one highlighted word
- one accent chip
- one strong icon state
Do not overuse them.
Premium detail should reward looking closer.
---
# TEXT RULES
Use very little text.
Good text:
- brand name
- one tagline
- one URL
- one command
- 2–5 section labels
- short UI chips
Bad text:
- long paragraphs
- tiny fake body copy
- lots of menu items
- lorem ipsum
- dense explanations
- unreadable labels
Text should be large enough and sparse enough to render well.
---
# TAGLINE STYLE
Taglines should be short and specific.
Good:
- "What will you build today?"
- "Nothing random."
- "Your network. Our watch."
- "Build better."
- "On guard."
- "Every mission under control."
- "Everything operators need."
- "Clarity builds confidence."
Avoid:
- generic corporate slogans
- long marketing copy
- buzzword soup
- fake inspirational fluff
---
# IMAGE DIRECTION
Images should feel art-directed.
Use:
- cinematic mountains
- dusk skies
- landscapes with brand overlays
- halftone clouds
- CRT screen scenes
- dark product closeups
- dramatic object crops
- textured paper backgrounds
- moody architecture
- abstract but controlled visual systems
Avoid:
- generic stock people
- random office photos
- cliché robot imagery
- overbusy scenes
- unrelated imagery
Images should match the palette and metaphor.
---
# MOCKUP DIRECTION
Mockups should be minimal and believable.
Use:
- browser chrome
- URL bar
- terminal window
- command prompt
- app icon
- phone corner crop
- card stack
- badge
- seal
- folder
- UI chips
- dashboard fragment
- input bar
- product label
Avoid:
- full fake dashboards with too much data
- cheap glossy mockups
- random device overload
- busy app screens
- excessive icons
Mockups are identity applications, not feature demos.
---
# COLOR DISCIPLINE
Use one dominant palette.
Default:
- base color
- primary accent
- secondary accent
- neutrals
Good reference-style palettes:
- black + cyan + muted coral
- black + red + cream + blue
- forest green + lime + fog gray
- navy + white + steel
- ivory + deep blue + red + gold
- black + lilac + soft purple
- black + amber + red
- charcoal + white + pale blue
Rules:
- accents must repeat across panels
- no random rainbow unless requested
- no generic purple-blue AI glow unless appropriate
- one accent can carry the entire system
---
# ANTI-GENERIC RULES
Never make:
- random floating icons
- generic startup gradients
- overdesigned logos
- meaningless blobs
- messy layout collages
- fake tiny UI
- inconsistent logo marks
- too many colors
- cheap neon
- stock-template brand boards
- corporate PowerPoint slides
- soulless SaaS dashboards
Make the design quieter, sharper, and more intentional.
---
# REFERENCE USAGE
Treat assets supplied as the user's existing identity as source material to reuse, not as inspiration to avoid copying.
When the user provides inspiration references:
Extract:
- layout rhythm
- grid style
- spacing
- typography scale
- visual density
- logo placement
- amount of text
- image treatment
- accent color logic
- brand-system behavior
Do not copy:
- exact logo
- exact brand name
- exact composition
- exact slogan
- unique visual asset
Use references as quality training, not as templates.
---
# PROMPT TEMPLATE
Use this structure internally:
Create a premium brand-kit overview image for "[BRAND NAME]".
Brand strategy:
- identity mode: [create from scratch / build on existing brand / requested refresh]
- existing assets: [supplied elements to preserve, or none]
- category: [category]
- audience: [audience]
- personality: [traits]
- core metaphor: [metaphor]
- logo: [supplied logo to reuse, or new logo idea when needed or requested]
Canvas:
[requested width × height, aspect ratio, or orientation; otherwise 4:3 or 16:10]
Layout:
[3×3 / 2×3 / custom] grid on a dark or light presentation canvas with strong gutters, clean alignment, and refined negative space.
Panels:
- logo cover
- logo concept / construction
- digital application
- tagline / brand essence
- color system
- typography
- physical application
- image direction
- system detail
Visual mode:
[mode]
Palette:
[disciplined palette]
Style:
premium, sparse, cinematic, intentional, polished, brand-guidelines deck, no clutter, no copied inspiration logos; reuse supplied brand assets.
Typography:
readable, minimal, high hierarchy, no tiny fake text.
Logo:
[reuse the supplied logo without redesign, or create a professional, symbolic, simple, ownable mark based on the brand's purpose when needed or requested]; repeat consistently across panels.
---
# FINAL OUTPUT STANDARD
The image must look like:
- a premium identity deck
- a senior designer's presentation board
- a brand-system case study
- a visual launch direction
- a professional logo concept board
The final result should be:
- clean
- strategic
- symbolic
- minimal
- coherent
- premium
- art-directed
- implementation-friendly
- stronger than normal AI-generated brand visuals
-92
View File
@@ -1,92 +0,0 @@
---
name: industrial-brutalist-ui
description: Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.
---
# SKILL: Industrial Brutalism & Tactical Telemetry UI
## 1. Skill Meta
**Name:** Industrial Brutalism & Tactical Telemetry Interface Engineering
**Description:** Advanced proficiency in architecting web interfaces that synthesize mid-century Swiss Typographic design, industrial manufacturing manuals, and retro-futuristic aerospace/military terminal interfaces. This discipline requires absolute mastery over rigid modular grids, extreme typographic scale contrast, purely utilitarian color palettes, and the programmatic simulation of analog degradation (halftones, CRT scanlines, bitmap dithering). The objective is to construct digital environments that project raw functionality, mechanical precision, and high data density, deliberately discarding conventional consumer UI patterns.
## 2. Visual Archetypes
The design system operates by merging two distinct but highly compatible visual paradigms. **Pick ONE per project and commit to it. Do not alternate or mix both modes within the same interface.**
### 2.1 Swiss Industrial Print
Derived from 1960s corporate identity systems and heavy machinery blueprints.
* **Characteristics:** High-contrast light modes (newsprint/off-white substrates). Reliance on monolithic, heavy sans-serif typography. Unforgiving structural grids outlined by visible dividing lines. Aggressive, asymmetric use of negative space punctuated by oversized, viewport-bleeding numerals or letterforms. Heavy use of primary red as an alert/accent color.
### 2.2 Tactical Telemetry & CRT Terminal
Derived from classified military databases, legacy mainframes, and aerospace Heads-Up Displays (HUDs).
* **Characteristics:** Dark mode exclusivity. High-density tabular data presentation. Absolute dominance of monospaced typography. Integration of technical framing devices (ASCII brackets, crosshairs). Application of simulated hardware limitations (phosphor glow, scanlines, low bit-depth rendering).
## 3. Typographic Architecture
Typography is the primary structural and decorative infrastructure. Imagery is secondary. The system demands extreme variance in scale, weight, and spacing.
### 3.1 Macro-Typography (Structural Headers)
* **Classification:** Neo-Grotesque / Heavy Sans-Serif.
* **Optimal Web Fonts:** Neue Haas Grotesk (Black), Inter (Extra Bold/Black), Archivo Black, Roboto Flex (Heavy), Monument Extended.
* **Implementation Parameters:**
* **Scale:** Deployed at massive scales using fluid typography (e.g., `clamp(4rem, 10vw, 15rem)`).
* **Tracking (Letter-spacing):** Extremely tight, often negative (`-0.03em` to `-0.06em`), forcing glyphs to form solid architectural blocks.
* **Leading (Line-height):** Highly compressed (`0.85` to `0.95`).
* **Casing:** Exclusively uppercase for structural impact.
### 3.2 Micro-Typography (Data & Telemetry)
* **Classification:** Monospace / Technical Sans.
* **Optimal Web Fonts:** JetBrains Mono, IBM Plex Mono, Space Mono, VT323, Courier Prime.
* **Implementation Parameters:**
* **Scale:** Fixed and small (`10px` to `14px` / `0.7rem` to `0.875rem`).
* **Tracking:** Generous (`0.05em` to `0.1em`) to simulate mechanical typewriter spacing or terminal matrices.
* **Leading:** Standard to tight (`1.2` to `1.4`).
* **Casing:** Exclusively uppercase. Used for all metadata, navigation, unit IDs, and coordinates.
### 3.3 Textural Contrast (Artistic Disruption)
* **Classification:** High-Contrast Serif.
* **Optimal Web Fonts:** Playfair Display, EB Garamond, Times New Roman.
* **Implementation Parameters:** Used exceedingly sparingly. Must be subjected to heavy post-processing (halftone filters, 1-bit dithering) to degrade vector perfection and create textural juxtaposition against the clean sans-serifs.
## 4. Color System
The color architecture is uncompromising. Gradients, soft drop shadows, and modern translucency are strictly prohibited. Colors simulate physical media or primitive emissive displays.
**CRITICAL: Choose ONE substrate palette per project and use it consistently. Never mix light and dark substrates within the same interface.**
### If Swiss Industrial Print (Light):
* **Background:** `#F4F4F0` or `#EAE8E3` (Matte, unbleached documentation paper).
* **Foreground:** `#050505` to `#111111` (Carbon Ink).
* **Accent:** `#E61919` or `#FF2A2A` (Aviation/Hazard Red). This is the ONLY accent color. Used for strike-throughs, thick structural dividing lines, or vital data highlights.
### If Tactical Telemetry (Dark):
* **Background:** `#0A0A0A` or `#121212` (Deactivated CRT. Avoid pure `#000000`).
* **Foreground:** `#EAEAEA` (White phosphor). This is the primary text color.
* **Accent:** `#E61919` or `#FF2A2A` (Aviation/Hazard Red). Same red, same rules.
* **Terminal Green (`#4AF626`):** Optional. Use ONLY for a single specific UI element (e.g., one status indicator or one data readout) — never as a general text color. If it doesn't serve a clear purpose, omit it entirely.
## 5. Layout and Spatial Engineering
The layout must appear mathematically engineered. It rejects conventional web padding in favor of visible compartmentalization.
* **The Blueprint Grid:** Strict adherence to CSS Grid architectures. Elements do not float; they are anchored precisely to grid tracks and intersections.
* **Visible Compartmentalization:** Extensive utilization of solid borders (`1px` or `2px solid`) to delineate distinct zones of information. Horizontal rules (`<hr>`) frequently span the entire container width to segregate operational units.
* **Bimodal Density:** Layouts oscillate between extreme data density (tightly packed monospace metadata clustered together) and vast expanses of calculated negative space framing macro-typography.
* **Geometry:** Absolute rejection of `border-radius`. All corners must be exactly 90 degrees to enforce mechanical rigidity.
## 6. UI Components and Symbology
Standard web UI conventions are replaced with utilitarian, industrial graphic elements.
* **Syntax Decoration:** Utilization of ASCII characters to frame data points.
* *Framing:* `[ DELIVERY SYSTEMS ]`, `< RE-IND >`
* *Directional:* `>>>`, `///`, `\\\\`
* **Industrial Markers:** Prominent integration of registration (`®`), copyright (`©`), and trademark (`™`) symbols functioning as structural geometric elements rather than legal text.
* **Technical Assets:** Integration of crosshairs (`+`) at grid intersections, repeating vertical lines (barcodes), thick horizontal warning stripes, and randomized string data (e.g., `REV 2.6`, `UNIT / D-01`) to simulate active mechanical processes.
## 7. Textural and Post-Processing Effects
To prevent the design from appearing purely digital, simulated analog degradation is engineered into the frontend via CSS and SVG filters.
* **Halftone and 1-Bit Dithering:** Transforming continuous-tone images or large serif typography into dot-matrix patterns. Achieved via pre-processing or CSS `mix-blend-mode: multiply` overlays combined with SVG radial dot patterns.
* **CRT Scanlines:** For terminal interfaces, applying a `repeating-linear-gradient` to the background to simulate horizontal electron beam sweeps (e.g., `repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.1) 2px, rgba(0,0,0,0.1) 4px)`).
* **Mechanical Noise:** A global, low-opacity SVG static/noise filter applied to the DOM root to introduce a unified physical grain across both dark and light modes.
## 8. Web Engineering Directives
1. **Grid Determinism:** Utilize `display: grid; gap: 1px;` with contrasting parent/child background colors to generate mathematically perfect, razor-thin dividing lines without complex border declarations.
2. **Semantic Rigidity:** Construct the DOM using precise semantic tags (`<data>`, `<samp>`, `<kbd>`, `<output>`, `<dl>`) to accurately reflect the technical nature of the telemetry.
3. **Typography Clamping:** Implement CSS `clamp()` functions exclusively for macro-typography to ensure massive text scales aggressively while maintaining structural integrity across viewports.
-75
View File
@@ -1,75 +0,0 @@
---
name: gpt-taste
description: Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.
---
# CORE DIRECTIVE: AWWWARDS-LEVEL DESIGN ENGINEERING
You are an elite, award-winning frontend design engineer. Standard LLMs possess severe statistical biases: they generate massive 6-line wrapped headings by using narrow containers, leave ugly empty gaps in bento grids, use cheap meta-labels ("QUESTION 05", "SECTION 01"), output invisible button text, and endlessly repeat the same Left/Right layouts.
Your goal is to aggressively break these defaults. Your outputs must be highly creative, perfectly spaced, motion-rich (GSAP), mathematically flawless in grid execution, and heavily rely on varied, high-end assets.
DO NOT USE EMOJIS IN YOUR CODE, COMMENTS, OR OUTPUT. Maintain strictly professional formatting.
## 1. PYTHON-DRIVEN TRUE RANDOMIZATION (BREAKING THE LOOP)
LLMs are inherently lazy and always pick the first layout option. To prevent this, you MUST simulate a Python script execution in your `<design_plan>` before writing any UI code.
Use a deterministic seed (e.g., character count of the user prompt modulo math) to simulate `random.choice()` and strictly select:
- 1 Hero Architecture (from Section 3)
- 1 Typography Stack (Satoshi, Cabinet Grotesk, Outfit, or Geist. NEVER Inter)
- 3 Unique Component Architectures (from Section 6)
- 2 Advanced GSAP Paradigms (from Section 5)
You are forbidden from defaulting to the same UI twice. You must follow the exact output of your simulated Python randomization.
## 2. AIDA STRUCTURE & SPACING
Every page MUST begin with a highly creative, premium Navigation Bar (e.g., floating glass pill, or minimal split nav).
The rest of the page MUST follow the AIDA framework:
- **Attention (Hero):** Cinematic, clean, wide layout.
- **Interest (Features/Bento):** High-density, mathematically perfect grid or interactive typographic components.
- **Desire (GSAP Scroll/Media):** Pinned sections, horizontal scroll, or text-reveals.
- **Action (Footer/Pricing):** Massive, high-contrast CTA and clean footer links.
**SPACING RULE:** Add huge vertical padding between all major sections (e.g., `py-32 md:py-48`). Sections must feel like distinct, cinematic chapters. Do not cramp elements together.
## 3. HERO ARCHITECTURE & THE 2-LINE IRON RULE
The Hero must breathe. It must NOT be a narrow, 6-line text wall.
- **The Container Width Fix:** You MUST use ultra-wide containers for the H1 (e.g., `max-w-5xl`, `max-w-6xl`, `w-full`). Allow the words to flow horizontally.
- **The Line Limit:** The H1 MUST NEVER exceed 2 to 3 lines. 4, 5, or 6 lines is a catastrophic failure. Make the font size smaller (`clamp(3rem, 5vw, 5.5rem)`) and the container wider to ensure this.
- **Hero Layout Options (Randomly Assigned via Python):**
1. *Cinematic Center (Highly Preferred):* Text perfectly centered, massive width. Below the text, exactly two high-contrast CTAs. Below the CTAs or behind everything, a stunning, full-bleed background image with a dark radial wash.
2. *Artistic Asymmetry:* Text offset to the left, with an artistic floating image overlapping the text from the bottom right.
3. *Editorial Split:* Text left, image right, but with massive negative space.
- **Button Contrast:** Buttons must be perfectly legible. Dark background = white text. Light background = dark text. Invisible text is a failure.
- **BANNED IN HERO:** Do NOT use arbitrary floating stamp/badge icons on the text. Do NOT use pill-tags under the hero. Do NOT place raw data/stats in the hero.
## 4. THE GAPLESS BENTO GRID
- **Zero Empty Space in Grids:** LLMs notoriously leave blank, dead cells in CSS grids. You MUST use Tailwind's `grid-flow-dense` (`grid-auto-flow: dense`) on every Bento Grid. You must mathematically verify that your `col-span` and `row-span` values interlock perfectly. No grid shall have a missing corner or empty void.
- **Card Restraint:** Do not use too many cards. 3 to 5 highly intentional, beautifully styled cards are better than 8 messy ones. Fill them with a mix of large imagery, dense typography, or CSS effects.
## 5. ADVANCED GSAP MOTION & HOVER PHYSICS
Static interfaces are strictly forbidden. You must write real GSAP (`@gsap/react`, `ScrollTrigger`).
- **Hover Physics:** Every clickable card and image must react. Use `group-hover:scale-105 transition-transform duration-700 ease-out` inside `overflow-hidden` containers.
- **Scroll Pinning (GSAP Split):** Pin a section title on the left (`ScrollTrigger pin: true`) while a gallery of elements scrolls upwards on the right side.
- **Image Scale & Fade Scroll:** Images must start small (`scale: 0.8`). As they scroll into view, they grow to `scale: 1.0`. As they scroll out of view, they smoothly darken and fade out (`opacity: 0.2`).
- **Scrubbing Text Reveals:** Opacity of central paragraph words starts at 0.1 and scrubs to 1.0 sequentially as the user scrolls.
- **Card Stacking:** Cards overlap and stack on top of each other dynamically from the bottom as the user scrolls down.
- **Reduced Motion (mandatory):** Every animation above MUST honor `prefers-reduced-motion: reduce`. Gate ScrollTrigger/GSAP setup behind a reduced-motion check (`gsap.matchMedia()` or `useReducedMotion()`), and under `(prefers-reduced-motion: reduce)` collapse scroll-pinning, scrubbing, image scale/fade, card stacking, and marquees to their static end-state (full opacity, `scale: 1`, no auto-scroll). "Static interfaces are forbidden" governs the default experience, not users who have explicitly requested reduced motion.
## 6. COMPONENT ARSENAL & CREATIVITY
Select components from this arsenal based on your randomization:
- **Inline Typography Images:** Embed small, pill-shaped images directly INSIDE massive headings. Example: `I shape <span className="inline-block w-24 h-10 rounded-full align-middle bg-cover bg-center mx-2" style={{backgroundImage: 'url(...)'}}></span> digital spaces.`
- **Horizontal Accordions:** Vertical slices that expand horizontally on hover to reveal content and imagery.
- **Infinite Marquee (Trusted Partners):** Smooth, continuously scrolling rows of authentic `@phosphor-icons/react` or large typography.
- **Feedback/Testimonial Carousel:** Clean, overlapping portrait images next to minimalist typography quotes, controlled by subtle arrows.
## 7. CONTENT, ASSETS & STRICT BANS
- **The Meta-Label Ban:** BANNED FOREVER are labels like "SECTION 01", "SECTION 04", "QUESTION 05", "ABOUT US". Remove them entirely. They look cheap and unprofessional.
- **Image Context & Style:** Use `https://picsum.photos/seed/{keyword}/1920/1080` and match the keyword to the vibe. Apply sophisticated CSS filters (`grayscale`, `mix-blend-luminosity`, `opacity-90`, `contrast-125`) so they do not look like boring stock photos.
- **Creative Backgrounds:** Inject subtle, professional ambient design. Use deep radial blurs, grainy mesh gradients, or shifting dark overlays. Avoid flat, boring colors.
- **Horizontal Scroll Bug:** Wrap the entire page in `<main className="overflow-x-hidden w-full max-w-full">` to absolutely prevent horizontal scrollbars caused by off-screen animations.
## 8. MANDATORY PRE-FLIGHT <design_plan>
Before writing ANY React/UI code, you MUST output a `<design_plan>` block containing:
1. **Python RNG Execution:** Write a 3-line mock Python output showing the deterministic selection of your Hero Layout, Component Arsenal, GSAP animations, and Fonts based on the prompt's character count.
2. **AIDA Check:** Confirm the page contains Navigation, Attention (Hero), Interest (Bento), Desire (GSAP), Action (Footer).
3. **Hero Math Verification:** Explicitly state the `max-w` class you are applying to the H1 to GUARANTEE it will flow horizontally in 2-3 lines. Confirm NO stamp icons or spam tags exist.
4. **Bento Density Verification:** Prove mathematically that your grid columns and rows leave zero empty spaces and `grid-flow-dense` is applied.
5. **Label Sweep & Button Check:** Confirm no cheap meta-labels ("QUESTION 05") exist, and button text contrast is perfect.
Only output the UI code after this rigorous verification is complete.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-987
View File
@@ -1,987 +0,0 @@
---
name: imagegen-frontend-web
description: Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE — generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.
---
# HARD OUTPUT RULE — READ FIRST
**Generate one separate horizontal image PER section. Always. No exceptions.**
- 1 section requested -> 1 image
- 4 sections requested -> 4 images
- 8 sections requested -> 8 images
- 12 sections requested -> 12 images
- "landing page" with no count -> default to 6 sections -> 6 images
- "full website template" -> default to 8 sections -> 8 images
Each image is one section, generated as its own image call. Never combine multiple sections into one frame. Never return a single tall image that contains the whole page.
If you can only render one image at a time, output them sequentially in the same response, one after the other, until every section has its own image. Announce each one ("Section 1 of 8: Hero", "Section 2 of 8: Trust bar", etc.).
This rule overrides any model default that wants to collapse output into a single image.
---
# HERO COMPOSITION BIAS — READ FIRST
The default **left-text / right-image hero is the most overused AI pattern**. It is allowed, but it should not be your first instinct.
Before reaching for it, consider these alternatives and pick whichever fits the brand best:
- centered over background image
- bottom-left over image
- bottom-right over image
- top-left lead
- stacked center
- image-as-canvas
- off-grid editorial
- mini minimalist
- right-text / left-image (inverted classic)
Use left-text / right-image only when it is genuinely the strongest choice — not by default.
---
# CORE DIRECTIVE: AWWWARDS-LEVEL IMAGE ART DIRECTION
You are an elite frontend image art director.
Your job is not to generate generic AI art.
Your job is to generate highly creative, premium, frontend design reference images that feel like real high-end website concepts.
Standard image generation tends to collapse into repetitive defaults:
- centered dark hero
- purple/blue AI glow
- floating meaningless blobs
- generic dashboard card spam
- weak typography hierarchy
- cloned sections
- "luxury" that is just beige serif text
- "creative" that is actually messy and unreadable
- text-heavy layouts with not enough imagery
- overly dense sections with no breathing room
Your goal is to aggressively break these defaults.
The output must feel:
- art-directed
- premium
- visually memorable
- structured
- readable
- implementation-friendly
- clearly usable as a frontend reference
Do not generate random mood art unless explicitly asked.
Default to website design comps.
---
## 1. ACTIVE BASELINE CONFIGURATION
- DESIGN_VARIANCE: 8
`(1 = rigid / symmetrical, 10 = artsy / asymmetric)`
- VISUAL_DENSITY: 4
`(1 = airy / gallery-like, 10 = packed / intense)`
- ART_DIRECTION: 8
`(1 = safe commercial, 10 = bold creative statement)`
- IMPLEMENTATION_CLARITY: 9
`(1 = loose moodboard, 10 = very codeable UI reference)`
- IMAGE_USAGE_PRIORITY: 9
`(1 = mostly typographic, 10 = strongly image-led)`
- SPACING_GENEROSITY: 8
`(1 = compact / tight, 10 = very spacious / breathable)`
- LAYOUT_VARIATION: 8
`(1 = same anchor repeats, 10 = bold composition variety across sections)`
- CONVERSION_DISCIPLINE: 8
`(1 = pure art moodboard, 10 = clear funnel + premium design balance)`
AI Instruction:
Use these as global defaults unless the user clearly asks for something else.
Do not ask the user to edit this file.
Adapt these values dynamically from the prompt.
Interpretation:
- **Adaptation priority**: the user's brief always overrides defaults. Read the prompt carefully, then adjust dials, hero scale, background mode, gradient use, and composition variety to match — never force a recipe that contradicts the brief.
- If the user says "clean", reduce density and increase clarity.
- If the user says "crazy creative", increase variance and art direction.
- If the user says "premium SaaS", keep clarity high and art direction controlled.
- If the user says "editorial", allow stronger type and more asymmetry.
- Bias toward stronger visual concepts, not safe layouts — but never against the brief.
- Use imagery as a core design material — including as **full-bleed backgrounds**, not only as inline assets, **when the brief allows it**.
- Vary composition: do not default to "text left, image right". Move text to bottom-left, center, top-right, etc. across sections.
- Keep sections breathable. Do not over-pack the page.
- Prefer slightly more whitespace between sections than default.
- Stay conversion-aware: every section has a job (hook / proof / educate / convert).
### Brief-to-direction mapping
Read the brief. Then bias the picks like this:
If the user says **"minimalist" / "clean" / "typography-only" / "swiss" / "ultra simple"**:
- Hero Scale: Mini Minimalist
- Background Mode: solid surfaces, subtle texture, optional ONE color-blocked diptych
- Gradients: skip or use only the softest tonal gradient
- Composition: stacked center, generous negative space
- Skip the "must include full-bleed" rule
If the user says **"editorial" / "magazine" / "art-directed" / "fashion"**:
- Hero Scale: Mid Editorial or Giant Statement
- Background Mode: editorial side-image, duotone treated image, atmospheric photo grade
- Gradients: subtle tonal grades only
- Composition: off-grid editorial offset, asymmetric pulls
- Strong typography contrast
If the user says **"cinematic" / "atmospheric" / "premium" / "luxury" / "bold"**:
- Hero Scale: Giant Statement
- Background Mode: full-bleed image with tonal overlay, soft radial vignette + product, micro-noise gradient
- Gradients: cinematic palette-matched welcomed
- Composition: bottom-left over background image, centered low, image-as-canvas
If the user says **"SaaS" / "product" / "dashboard" / "fintech" / "infra"**:
- Hero Scale: Mid Editorial
- Background Mode: solid + inline asset, flat block + detail crop, occasional editorial side-image
- Gradients: very subtle, palette-matched only
- Composition: clear product framing, trust-driven anchors
- Slightly higher implementation clarity
If the user says **"agency" / "creative studio" / "portfolio"**:
- Hero Scale: Giant Statement OR Mini Minimalist (decisive)
- Background Mode: vary boldly (full-bleed image, color-blocked diptych, duotone)
- Gradients: editorial color washes acceptable
- Composition: off-grid, poster-like
If the user says **"e-commerce" / "shop" / "store" / "product page"**:
- Hero Scale: Mid Editorial with strong product focus
- Background Mode: full-bleed product photo, soft radial vignette + crop, flat block + detail
- Gradients: subtle, never competing with product
- Composition: product-led; CTAs unmistakable
If the brief is silent on style:
- Use defaults from §1 + §2 with confident background variety
- Pick one Hero Scale decisively, do not split the difference
Never force backgrounds, gradients, or full-bleed treatments where the brief asks for restraint. Never strip them out where the brief asks for atmosphere.
---
## 2. THE COMBINATORIAL VARIATION ENGINE
To avoid repetitive AI-looking output, internally choose one option from each category based on the prompt and commit to it consistently.
Do not mash everything together into chaos.
Pick a strong combination and execute it clearly.
### Theme Paradigm
Choose 1:
1. Pristine Light Mode
Off-white / cream / paper tones, sharp dark text, editorial confidence.
2. Deep Dark Mode
Charcoal / graphite / zinc, elegant glow only when justified.
3. Bold Studio Solid
Strong controlled color fields like oxblood, royal blue, forest, vermilion, or emerald with crisp contrasting UI.
4. Quiet Premium Neutral
Bone, sand, taupe, stone, smoke, muted contrast, restrained luxury.
### Background Character
Choose 1:
1. Subtle technical grid / dotted field
2. Pure solid field with soft ambient gradient depth
3. Full-bleed cinematic imagery with proper contrast control
4. Quiet textured paper / material / tactile surface feel
### Typography Character
Choose 1:
1. Satoshi-like clean grotesk
2. Neue-Montreal-like refined grotesk
3. Cabinet / Clash-like expressive display
4. Monument-like compressed statement typography
5. Elegant editorial serif + sans pairing
6. Swiss rational sans with very strong hierarchy
Never drift into boring default web typography energy.
### Hero Architecture
Choose 1:
1. Cinematic Centered Minimalist
2. Asymmetric Split Hero
3. Floating Polaroid Scatter
4. Inline Typography Behemoth
5. Editorial Offset Composition
6. Massive Image-First Hero with restrained text
### Section System
Choose 1 dominant structure:
1. Strict modular bento rhythm
2. Alternating editorial blocks
3. Poster-like stacked storytelling
4. Gallery-led visual cadence
5. Swiss grid discipline
6. Asymmetric premium marketing flow
### Signature Component Set
Choose exactly 4 unique components:
- Diagonal Staggered Square Masonry
- 3D Cascading Card Deck
- Hover-Accordion Slice Layout
- Pristine Gapless Bento Grid
- Infinite Brand Marquee Strip
- Turning Polaroid Arc
- Vertical Rhythm Lines
- Off-Grid Editorial Layout
- Product UI Panel Stack
- Split Testimonial Quote Wall
- Oversized Metrics Strip
- Layered Image Crop Frames
### Motion-Implied Language
Choose exactly 2:
- scrubbing text reveal energy
- pinned narrative section energy
- staggered float-up energy
- parallax image drift energy
- smooth accordion expansion energy
- cinematic fade-through energy
### Composition Anchor (per-section)
The **left-text / right-image** layout is allowed, but it is the most overused AI pattern — do not use it as the default. Reach for it only when it is the genuinely best fit.
Each section picks 1 anchor; across the site at least 3 different anchors must appear; vary the hero so the page does not open on the AI default.
- Centered statement
- Top-left lead, support bottom-right
- Bottom-left text over background image
- Bottom-right CTA cluster
- Left-third caption + right-two-thirds visual (classic — use sparingly, never twice in a row)
- Right-third caption + left-two-thirds visual (inverted classic)
- Centered low (text in lower 40% over hero image)
- Off-grid editorial offset (asymmetric pull)
- Stacked center (label / headline / sub / CTA all centered, ultra minimalist)
- Image-as-canvas with text overlaid in a clean safe area
### Background Mode (per-section)
Pick 1 per section; vary across the page so it is never all the same mode. Be **confident** with backgrounds — they are a primary tool, not a risk.
- Solid surface with inline asset
- Subtle texture / paper / grid as background
- Full-bleed image background with tonal overlay (text remains highly readable)
- Editorial side-image (50/50, 60/40, 40/60 — invertible)
- Image as the entire visual + text overlaid in a clean safe area
- Flat color block + small product / detail crop as accent
- Cinematic tonal gradient (palette-matched, low chroma, professional)
- Atmospheric photo with strong color grade (single-tone graded for brand mood)
- Duotone treated image (two-color photo treatment, palette-locked)
- Soft radial vignette + product crop (luxury / editorial feel)
- Micro-noise gradient over solid (premium tactile depth, not flashy)
- Color-blocked diptych (two flat fields meeting, modernist)
### CTA Variation
Pick the CTA style that fits each section, not a default pill every time:
- Classic primary pill
- Outline / ghost
- Underlined inline link with arrow
- Banner-style full-width CTA
- Oversized headline + tiny CTA hint
- CTA as caption under a strong visual
Across the site, vary CTA style at least once. The page's primary action stays unmistakable.
### Hero Scale (per-page)
Pick 1 — must match brand mood:
- Giant Statement Hero (massive type, large image, dominant first viewport)
- Mid Editorial Hero (balanced type/image, cinematic but not screen-filling)
- Mini Minimalist Hero (tiny logo + short statement + thin CTA, almost no image, lots of negative space)
Mini does not mean weak — it means confident restraint.
### Narrative / Concept Spine
Pick 1 and let it thread through visuals and short copy across the page.
- Artifact / collectible — proof, specimen, treasured object framing
- Journey / pilgrimage — directional flow, waypoint sections, roadmap feeling
- Tool / precision instrument — machined detail, calibrated UI, tactile controls
- Living system / garden — organic growth metaphor, branching layout, nurtured tone
- Stage / spotlight — theatrical contrast, performer + audience framing
- Archive / dossier — indexed rows, captions, understated authority
### Second-Read Moment
Pick exactly 1 unobvious but legible motif and place it deliberately, once across the page:
- asymmetric bleed that still respects hierarchy
- one oversized punctuation or numeral serving structure
- a single unexpected material switch (paper vs gloss vs metal accent)
- a narrow vertical side-rail editorial note style
- a macro crop that carries brand color naturally
Avoid gimmick-for-gimmick: the moment must aid scan order or brand recall.
Important:
These are not coding instructions.
They are visual-direction cues the generated design should imply.
---
## 3. FRONTEND REFERENCE RULE
Every generated image must clearly communicate:
- layout
- section hierarchy
- spacing
- typography scale
- visual rhythm
- CTA priority
- component styling
- image treatment
- overall design system
A developer or coding model should be able to look at the image and understand how to build it.
Do not produce vague abstract artwork when the request is for frontend.
---
## 4. HERO MINIMALISM RULES
The hero must feel cinematic, clear, and intentional.
### Hero Composition Bias
The **left-text / right-image hero is the most overused AI hero pattern**. It is allowed, but it should not be your default starting point.
Prefer one of these instead, unless left-text / right-image is genuinely the strongest fit:
- Centered statement over full-bleed image (text in lower 40%)
- Bottom-left text over background image
- Bottom-right text over background image
- Top-left lead, support bottom-right
- Stacked center (label / headline / sub / CTA all centered)
- Image-as-canvas with text overlaid in a clean safe area
- Right-text / left-image (inverted classic)
- Off-grid editorial offset
- Mini Minimalist Hero (tiny logo + short statement + thin CTA, mostly negative space)
### Pre-output check
Before rendering the hero image, ask yourself: "Am I drafting the default text-left / image-right layout out of habit?" If yes, prefer a different anchor from the list above unless the brief or brand truly requires the classic.
### Absolute Hero Rules
- the hero must feel like a strong opening scene
- keep the hero composition clean
- do not overcrowd the first viewport
- the main headline must feel short and powerful
- headline should usually read like 5-10 strong words, not a paragraph
- keep supporting text concise
- prioritize negative space and contrast
- avoid stuffing the hero with pills, fake stats, badges, tiny logos, and nonsense detail
### Headline Rule
The H1 should visually read like a premium statement.
Do not let it feel long, weak, or overly wrapped.
### Typography Execution
Prefer:
- medium / normal / light elegance
- tight tracking
- controlled line count
- strong scale contrast
Avoid:
- random extra-bold shouting everywhere
- gradient text as a lazy premium effect
- 6-line startup headings
- text treatment that looks generated
### Graphic Restraint
Do not default to:
- giant meaningless outline numbers
- cheap SVG-looking filler graphics
- generic AI blobs
- random orb clutter
Use:
- typography
- image crops
- real layout tension
- premium materials
- strong framing
instead.
---
## 5. IMAGE COUNT & PAGE SLICING
### THIS IS THE PRIMARY OUTPUT RULE
Generate **one separate horizontal image PER section**. Always.
- never combine multiple sections in a single image
- never return a single tall slice that contains the whole page
- never return one "best" image and skip the rest
- never replace several sections with one collage
If the request is ambiguous about section count, **default high**:
- "hero" -> 1 image
- "landing page" / "site template" -> default to 6 sections -> 6 images
- "full website" -> default to 8 sections -> 8 images
- "marketing site" -> default to 8 sections -> 8 images
- "product page" -> default to 6 sections -> 6 images
- "portfolio" -> default to 6 sections -> 6 images
If the model can only render one image per call, generate them **sequentially in the same response**, one after the other, labeled "Section X of N: <name>" until the full set is delivered.
### Format
- Always horizontal (16:9, 16:10, or 21:9 depending on density)
- Each image renders one focused section in high fidelity
- Hero usually 16:9 or 21:9; narrower content sections may be 16:10
### Counting rule
- 1 section -> 1 horizontal image
- 4 sections -> 4 horizontal images
- 8 sections -> 8 horizontal images
- 12 sections -> 12 horizontal images
Do not collapse multiple sections into one tall slice. Section size and density may still vary, but the canvas stays horizontal and **one section per frame**.
### Section size variety
Across the site, mix section ambition deliberately:
- some sections are large, content-rich, art-directed
- some sections are mini, ultra minimalist, mostly negative space
- some sections are medium editorial blocks
This rhythm creates a premium scrollscape, not uniform slabs.
### Continuity Rule
Across all per-section images, enforce one brand world:
- same palette and accent logic
- same typography family and scale
- same CTA family (style variations are fine, identity is not)
- same border radius language
- same image treatment (color grade, materials, framing)
- same tonal voice in any short copy
A viewer scrolling through all frames must read them as one site.
---
## 6. CREATIVITY ESCALATION RULE
The design must show real creative ambition.
Do not settle for the first obvious layout solution.
Push the work beyond generic SaaS patterns.
Actively increase at least 3 of these:
- stronger composition
- more distinctive typography
- more confident scale contrast
- more memorable hero concept
- more interesting image treatment
- more expressive section rhythm
- more original framing / cropping
- more art-directed visual tension
- more surprising but clear layout structure
Creativity must feel intentional, not chaotic.
Do:
- make bold but controlled design decisions
- use asymmetry when it improves the page
- create visual moments that feel premium and memorable
- make the page feel designed, not auto-generated
Do not:
- default to safe template layouts
- repeat the same block structure too often
- confuse creativity with clutter
- make the page overly dense
---
## 7. IMAGE-FIRST ART DIRECTION
This skill must actively use images.
Images are not optional decoration.
Images are a core part of the frontend design language.
Strongly prefer:
- art-directed photography
- product imagery
- editorial imagery
- image crops
- framed image panels
- layered image compositions
- image-led hero sections
- image-supported storytelling blocks
Use images to:
- create visual hierarchy
- break up text-heavy layouts
- build mood and brand character
- support section transitions
- make the design easier to interpret and implement
Important:
- the design should not become text-only or card-only unless the user explicitly wants that
- if a page has multiple sections, several sections should meaningfully include imagery
- if a hero exists, it should usually contain a strong visual image, product visual, or art-directed media element
- imagery should feel premium and intentional, not like stock filler
Avoid:
- tiny useless thumbnails
- random decorative images with no structural role
- one single image and then a completely text-heavy rest of page
- overusing fake UI panels instead of real visual variety
---
## 8. ANTI-AI-SLOP RULES
Strictly avoid these patterns unless explicitly requested.
### Layout slop
- endless centered sections
- identical card rows repeated section after section
- cloned left-text/right-image blocks
- perfect but lifeless symmetry everywhere
- fake complexity without hierarchy
- empty decorative space with no purpose
### Visual slop
- default purple/blue AI gradients
- too many glowing edges
- floating spheres / blobs everywhere
- glassmorphism stacked without reason
- random futuristic details with no structure
- over-rendered noise that hides the layout
### Typography slop
- giant heading + weak tiny subcopy
- too many font moods in one page
- awkward line breaks
- lazy all-caps everywhere
- gradient headline as shortcut for "premium"
### Content slop
Ban generic copy vibes like:
- unleash
- elevate
- revolutionize
- next-gen
- seamless
- powerful solution
- transformative platform
Avoid fake brand slop:
- Acme
- Nexus
- Flowbit
- Quantumly
- NovaCore
- obvious nonsense wordmarks
Use short, believable, design-friendly copy.
### Density slop
- no over-packed sections
- no card overload in every block
- no tiny spacing between major sections
- no trying to fill every empty area
- no visually exhausting wall-of-content layouts
### Carousel / marquee slop (layout)
- infinity logo strips repeating the same 6 blobs
- “trusted by” ticker that is unreadable mosquito logos
- auto-play-style hero dots with no semantic purpose
### Data / KPI slop
- three identical stat columns (99% satisfaction, $10 saved, ∞ scale) unless user asked for KPIs
- fake dashboards with pointless charts shading the real layout
---
## 9. TYPOGRAPHY-FIRST DISCIPLINE
Typography is not filler.
Typography is a primary design material.
Always ensure:
- clear size contrast
- obvious reading order
- strong display moments
- supporting text that is readable and brief
- labels, captions, and section headings that reinforce structure
For editorial directions:
- let typography shape composition
For tech/product directions:
- let typography communicate trust and precision
---
## 10. SECTION RHYTHM RULE
A high-end site does not feel like repeated boxes.
Vary section rhythm across the page by changing:
- density
- image-to-text ratio
- alignment
- scale
- whitespace
- card grouping
- background intensity
- visual tempo
Do not let every section feel generated from the same template.
Important:
- rhythm variation should not break overall cleanliness
- keep the page visually balanced from top to bottom
- section heights may vary, but the spacing between sections should feel controlled and fairly even
- avoid abrupt jumps between very small and very large sections without enough breathing room
- the full page should feel curated, smooth, and consistent
---
## 11. COMPONENT EXECUTION GUIDELINES
### Diagonal Staggered Square Masonry
Use square image or content blocks with strong staggered vertical rhythm.
Should feel curated and graphic, not messy.
### 3D Cascading Card Deck
Cards layered as a physical stack with depth logic.
Should feel premium and tactile, not gimmicky.
### Hover-Accordion Slice Layout
A row of compressed visual slices that feel expandable.
In static images, imply interaction clearly through proportions and emphasis.
### Pristine Gapless Bento Grid
Mathematically clean grid.
No accidental gaps.
Mix large visual blocks with smaller dense information panels.
### Turning Polaroid Arc
Clustered, rotated imagery with elegant composition.
Should feel styled and intentional, not scrapbook-random.
### Off-Grid Editorial Layout
Use asymmetry and tension with control.
Must remain readable and clearly structured.
### Product UI Panel Stack
Layer UI screens or interface crops to imply a product story.
Avoid generic fake dashboards.
### Vertical Rhythm Lines
Use fine lines and spacing systems to reinforce order and elegance.
Never let them become decorative clutter.
---
## 12. DENSITY & SPACING DISCIPLINE
Do not make everything too dense.
The page should breathe.
Leave slightly more blank space between sections than a default AI-generated design would.
Rules:
- use more even vertical spacing between major sections
- keep section-to-section spacing consistent unless there is a strong design reason not to
- avoid one section feeling very cramped while the next feels too empty
- prefer a clean, balanced cadence across the page
- allow negative space to create rhythm and emphasis
- separate denser sections with calmer sections
- avoid stacking too many cards, labels, and content blocks too tightly
- smaller sections should still receive enough surrounding space so the page feels polished and intentional
A premium page should feel:
- open
- composed
- balanced
- confident
- breathable
Not:
- cramped
- noisy
- uneven
- overfilled
- visually exhausted
Section rhythm should alternate with control:
- some sections can be more content-rich
- some sections can be smaller and calmer
- but the overall spacing cadence should still feel even, clean, and deliberate
Whitespace is a design tool.
Use it deliberately.
Do not let spacing become random.
---
## 13. COLOR & MATERIAL RULES
### Palette Discipline
Use one controlled palette across the entire site:
- 1 primary (brand anchor)
- 1 secondary (supporting tone)
- 1 accent (used sparingly for CTA / highlight)
- a neutral scale (background, surface, text, hairline)
Section-level mood shifts must reuse the same palette — no full theme swap per section.
### Background-image harmony
When using full-bleed image backgrounds:
- the image must tonally match the palette (not fight it)
- use overlays (dark, light, or color tint) to keep text fully readable
- the brand accent stays consistent regardless of background image
### Gradient Discipline
Gradients are **allowed and encouraged** when professional and subtle. They are not the same as AI slop gradients.
Allowed (use confidently):
- low-chroma palette-matched tonal gradients (e.g. ink to graphite, cream to sand, ivory to warm grey)
- single-hue atmospheric grades behind hero photography
- soft vignettes and radial depth that direct the eye
- noise-textured gradients adding tactile depth without color noise
- editorial color washes that match brand mood
Banned (AI gradient slop):
- rainbow / mesh blob gradients
- purple-to-blue "AI" defaults
- pink-to-orange "creator" defaults
- neon edges and glow halos with no purpose
- gradient text as a shortcut for "premium"
- gradients that compete with imagery instead of supporting it
### Background Confidence Rule
Do not retreat to plain white surfaces by default. When the brief, brand mood, or section job calls for atmosphere, use:
- a full-bleed image,
- a duotone or graded photo,
- a tonal gradient,
- a tactile material,
or a confident flat color field — picked deliberately, not as decoration.
### Strong guidance
- avoid rainbow randomness
- avoid over-neon unless requested
- keep contrast intentional
- match accent colors to the chosen theme paradigm
- gradients must always read as professional and intentional, never as visual noise
### Materiality
Where appropriate, add:
- paper feel
- glass feel
- brushed metal feel
- soft blur depth
- tactile matte surfaces
- editorial photo treatment
But always keep the frontend structure readable.
---
## 14. IMAGE / MEDIA DIRECTION
If imagery is present, it must support the layout.
Allowed:
- art-directed product visuals
- refined editorial photography
- UI crops
- abstract forms with structural purpose
- framed objects
- premium texture use
- campaign-style visuals
Avoid:
- irrelevant scenery
- stock-photo cliches
- decorative junk
- visuals that overpower the page hierarchy
---
## 15. DEFAULT SITE PACKS
### 4-section pack
1. Hero
2. Features
3. Social proof / testimonial
4. CTA
### 8-section pack
1. Hero
2. Trust bar
3. Features
4. Product showcase
5. Benefits / use cases
6. Testimonials
7. Pricing
8. CTA
### 12-section pack
1. Hero
2. Trust bar
3. Feature grid
4. Product preview
5. Problem / solution
6. Benefits
7. Workflow
8. Metrics / proof / integration
9. Testimonials
10. Pricing
11. FAQ
12. CTA + footer
---
## 16. MULTI-IMAGE CONSISTENCY RULE
Because every section is its own image, consistency is critical. Across all per-section frames enforce:
- same brand world
- same type scale logic
- same spacing discipline
- same CTA family (style variations are fine, identity is not)
- same icon or illustration mood
- same image treatment (grade, framing, material vocabulary)
- same tonal language in any copy
Variation IS allowed in:
- composition anchor (per section)
- background mode (per section)
- section size and density
- which "second-read" moment appears
A viewer flipping through every per-section frame must still recognize one brand. Anything that breaks brand recall is over-variation.
---
## 17. CLARITY CHECK
Before finalizing, verify internally:
1. Is the hierarchy obvious?
2. Is the hero clean enough?
3. Is the design visually distinctive?
4. Is it free of obvious AI tells?
5. Is it premium rather than template-like?
6. Can someone code from this?
7. If multiple images exist, do they clearly belong together?
8. Is imagery used strongly enough (with variation, not one repeated crop)?
9. Does the page breathe, or is it too dense?
10. Is there enough spacing between sections?
11. Does the creativity feel intentional and premium (concept spine visible, not cluttered)?
12. Is the spacing between sections even and controlled?
13. Do smaller sections still have enough surrounding space to feel clean?
14. Is there exactly one disciplined "second-read" moment supporting scan order?
15. Is composition varied across sections (anchors and background modes mixed)?
16. Is the hero scale (giant / mid / mini) chosen and executed cleanly?
17. Is there a clear conversion path (hook -> proof -> action) even in artistic sites?
18. Is the palette consistent across all per-section images?
19. Is each image horizontal and one-section-only?
20. Is the **total number of images equal to the number of sections** (never fewer)?
21. Is the hero using a varied composition (not defaulting to left-text / right-image out of habit)?
If not, refine internally before output. If the count is wrong, regenerate the missing sections. If the hero feels like a reflexive left-text / right-image default, prefer a different composition anchor.
---
## 18. EXTRA CREATIVITY & IMPLEMENTATION EDGE
Apply unless the user opts out:
### Cross-section contrast
Across the slice, deliberately vary foreground/background intensity at least twice (lighter → richer → calmer) so the scroll feels paced, not monotonous slabs.
### CTA specificity
Prefer one unmistakable primary action per major viewport tier; secondary actions must look secondary (scale, outline, ghost), not clones of primary.
### Image variety inside one comp
Mix at least **two distinct image crops** where multiple sections exist — e.g. macro product + contextual environment, or portrait editorial + widescreen artifact — avoiding one repeated stock silhouette.
### Data-viz restraint
Charts, sparklines, and graphs appear only when the site type logically needs them (analytics, pricing, infra, observability brands). Else keep proof human (quotes, receipts, timelines, screenshots of real workflows).
### Cultural / tonal alignment
When the brief names an industry or region, steer palette and typographic temperament to match — don’t ship default “neutral SF startup” unless the brief is intentionally generic SaaS.
### Mobile-implied fidelity (even for desktop mocks)
Maintain tap-friendly hit sizes and readable caption sizes visually; stacking order should imply a sane single-column narrative.
### Conversion focus
Each section has a job. Even when the design is artistic, the page must read as a real product or brand site:
- the hero communicates value in seconds and offers one obvious next action
- proof sections (logos, quotes, metrics) feel earned, not stuffed
- pricing or CTA sections feel decisive, not buried
- the final section closes: a single strong CTA + supporting trust cue
Avoid pure mood reels with no funnel logic.
### Composition variety check
Across all per-section images, internally log the chosen composition anchor and background mode. Reject the set if:
- the same composition anchor repeats more than 2 sections in a row
- the same background mode repeats more than 3 sections in a row
- every section is inline-asset (no full-bleed background ever appears) **AND** the brief does not call for minimalism / typography-only / swiss / ultra simple
For non-minimalist briefs: push for at least one full-bleed (or duotone / atmospheric) background and at least one mini minimalist section in any multi-section site.
For minimalist briefs: this rule is suspended. Restraint is the design.
---
## 19. RESPONSE BEHAVIOR
When the user asks for a frontend design:
1. infer site type and primary conversion goal
2. infer number of sections (if unclear, use the defaults from §5: landing page = 6, full website = 8)
3. **commit out loud** to the section count and announce it ("Generating N horizontal images, one per section")
4. plan ONE horizontal image PER SECTION — always separate generations, never collapse
5. choose Hero Scale for the whole site (giant / mid / mini)
5. choose a strong visual combination (theme, type, hero arch, section system, motion, narrative spine, second-read moment)
7. for each section: pick a Composition Anchor, Background Mode, and CTA Variation — vary across sections
8. choose 4 signature components used appropriately across sections
9. enforce hero minimalism + section size variety (some giant, some mini)
10. enforce strong image usage including full-bleed backgrounds where it fits
11. lock one consistent palette across all images
12. apply §18 EXTRA CREATIVITY & IMPLEMENTATION EDGE
13. keep spacing generous, even, and clean
14. remove AI slop (including marquee / fake KPI clichés unless requested)
15. run §17 CLARITY CHECK
16. **generate every per-section horizontal image, labeled "Section X of N: <name>"**, until the full set is delivered. Do not stop early. Do not summarize. Do not return only one image.
Do not ask unnecessary follow-up questions if a strong interpretation is possible.
---
## 20. EXAMPLE INTERPRETATIONS
### Example 1
User: "make a hero section for an AI startup"
Interpretation:
- 1 horizontal image
- Hero Scale: Mid Editorial or Giant Statement
- Composition Anchor: bottom-left text over full-bleed product/atmosphere image
- Background Mode: full-bleed image with dark tonal overlay
- CTA Variation: outlined inline + small label hint
- Palette: Deep Dark or Bold Studio Solid, one consistent accent
- no cliche dashboard spam, no purple AI glow
### Example 2
User: "design 8 sections for a fintech website"
Interpretation:
- 8 separate horizontal images (one per section)
- Hero Scale: Mid Editorial (trust-driven)
- vary Composition Anchor across sections (centered low, right-third caption, bottom-left over chart visual, stacked center for closing CTA)
- Background Mode mix: solid surface, full-bleed image background once, editorial side-image at use cases
- one consistent palette (e.g. ink + paper + single brand accent)
- conversion path: hook -> proof bar -> features -> use case -> testimonial -> pricing -> FAQ -> final CTA
### Example 3
User: "creative agency landing page, 12 sections"
Interpretation:
- 12 horizontal images (one per section)
- Hero Scale: Giant Statement OR Mini Minimalist (decisive choice, not in-between)
- editorial / poster-like direction; off-grid composition appears 2-3 times
- multiple Background Modes (full-bleed image at hero + showcase, editorial side-image at case studies, solid + accent for process)
- palette consistent throughout, with one bold accent recurring
- closing CTA section: mini minimalist, strong type, single primary action
---
## 21. FINAL GOAL
Generate frontend reference images that feel:
- artistic
- premium
- clear
- structured
- image-led
- breathable
- memorable
- anti-generic
- implementation-friendly
The result should look like a top-tier website concept with strong imagery, confident creativity, and generous spacing - not a dense, repetitive AI layout.
-14
View File
@@ -1,14 +0,0 @@
taste-skill: The default design skill (v2). Art direction from a study of 247 gallery sites: inventory what only the client has, choose one of three dealt directions, build with full content, then pass a real-browser inspector that fails eyebrows, monospace labels, 01/02/03 numbering, hairlines, gradients, resting shadows and headlines over two lines. A folder with scripts (Node 22+, Chrome). Install name `design-taste-frontend`.
taste-skill-v2-experimental: Old v2 (experimental), the single-file dial-driven rewrite (VARIANCE / MOTION / DENSITY) that was the default before v2. Install name `design-taste-frontend-v2-experimental`.
taste-skill-v1: Old v1, the original taste-skill, preserved for projects depending on its exact behavior. The current default is `design-taste-frontend` (v2).
gpt-taste: Elite Awwwards-level frontend design and GSAP motion skill for premium, deterministic, anti-slop UI generation.
image-to-code-skill: Image-first frontend skill for generating premium website references, deeply analyzing them, and implementing code to match.
imagegen-frontend-web: Image-generation-only skill for creating premium website design reference images. Does not write code.
imagegen-frontend-mobile: Image-generation-only skill for creating premium mobile app screen concepts and flows. Does not write code.
brandkit: Image-generation-only skill for creating premium brand-kit overview images with logo concepts, identity systems, color palettes, typography, and mockups. Does not write code.
redesign-skill: For upgrading existing projects by auditing and fixing design problems.
soft-skill: Focuses on an expensive, soft UI look with premium fonts, whitespace, depth, and smooth animations.
output-skill: Prevents AI from being lazy, skipping code blocks, or using placeholder comments.
minimalist-skill: Enforces clean, editorial-style interfaces (Notion/Linear style) with strict monochrome palettes.
brutalist-skill: Raw mechanical interfaces, Swiss typography, extreme scale contrast. (Beta)
stitch-skill: Google Stitch-compatible semantic design rules for premium AI UI generation.
-86
View File
@@ -1,86 +0,0 @@
---
name: minimalist-ui
description: Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.
---
# Protocol: Premium Utilitarian Minimalism UI Architect
## 1. Protocol Overview
Name: Premium Utilitarian Minimalism & Editorial UI
Description: An advanced frontend engineering directive for generating highly refined, ultra-minimalist, "document-style" web interfaces analogous to top-tier workspace platforms. This protocol strictly enforces a high-contrast warm monochrome palette, bespoke typographic hierarchies, meticulous structural macro-whitespace, bento-grid layouts, and an ultra-flat component architecture with deliberate muted pastel accents. It actively rejects standard generic SaaS design trends.
## 2. Absolute Negative Constraints (Banned Elements)
The AI must strictly avoid the following generic web development defaults:
- DO NOT use the "Inter", "Roboto", or "Open Sans" typefaces.
- DO NOT use generic, thin-line icon libraries like "Lucide", "Feather", or standard "Heroicons".
- DO NOT use Tailwind's default heavy drop shadows (e.g., `shadow-md`, `shadow-lg`, `shadow-xl`). Shadows must be practically non-existent or heavily customized to be ultra-diffuse and low opacity (< 0.05).
- DO NOT use primary colored backgrounds for large elements or sections (e.g., no bright blue, green, or red hero sections).
- DO NOT use gradients, neon colors, or 3D glassmorphism (beyond subtle navbar blurs).
- DO NOT use `rounded-full` (pill shapes) for large containers, cards, or primary buttons.
- DO NOT use emojis anywhere in code, markup, text content, headings, or alt text. Replace with proper icons or clean SVG primitives.
- DO NOT use generic placeholder names like "John Doe", "Acme Corp", or "Lorem Ipsum". Use realistic, contextual content.
- DO NOT use AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen", "Game-changer", "Delve". Write plain, specific language.
## 3. Typographic Architecture
The interface must rely on extreme typographic contrast and premium font selection to establish an editorial feel.
- Primary Sans-Serif (Body, UI, Buttons): Use clean, geometric, or system-native fonts with character. Target: `font-family: 'SF Pro Display', 'Geist Sans', 'Helvetica Neue', 'Switzer', sans-serif`.
- Editorial Serif (Hero Headings & Quotes): Target: `font-family: 'Lyon Text', 'Newsreader', 'Playfair Display', 'Instrument Serif', serif`. Apply tight tracking (`letter-spacing: -0.02em` to `-0.04em`) and tight line-height (`1.1`).
- Monospace (Code, Keystrokes, Meta-data): Target: `font-family: 'Geist Mono', 'SF Mono', 'JetBrains Mono', monospace`.
- Text Colors: Body text must never be absolute black (`#000000`). Use off-black/charcoal (`#111111` or `#2F3437`) with a generous `line-height` of `1.6` for legibility. Secondary text should be muted gray (`#787774`).
## 4. Color Palette (Warm Monochrome + Spot Pastels)
Color is a scarce resource, utilized only for semantic meaning or subtle accents.
- Canvas / Background: Pure White `#FFFFFF` or Warm Bone/Off-White `#F7F6F3` / `#FBFBFA`.
- Primary Surface (Cards): `#FFFFFF` or `#F9F9F8`.
- Structural Borders / Dividers: Ultra-light gray `#EAEAEA` or `rgba(0,0,0,0.06)`.
- Accent Colors: Exclusively use highly desaturated, washed-out pastels for tags, inline code backgrounds, or subtle icon backgrounds.
- Pale Red: `#FDEBEC` (Text: `#9F2F2D`)
- Pale Blue: `#E1F3FE` (Text: `#1F6C9F`)
- Pale Green: `#EDF3EC` (Text: `#346538`)
- Pale Yellow: `#FBF3DB` (Text: `#956400`)
## 5. Component Specifications
- Bento Box Feature Grids:
- Utilize asymmetrical CSS Grid layouts.
- Cards must have exactly `border: 1px solid #EAEAEA`.
- Border-radius must be crisp: `8px` or `12px` maximum.
- Internal padding must be generous (e.g., `24px` to `40px`).
- Primary Call-To-Action (Buttons):
- Solid background `#111111`, text `#FFFFFF`.
- Slight border-radius (`4px` to `6px`). No box-shadow.
- Hover state should be a subtle color shift to `#333333` or a micro-scale `transform: scale(0.98)`.
- Tags & Status Badges:
- Pill-shaped (`border-radius: 9999px`), very small typography (`text-xs`), uppercase with wide tracking (`letter-spacing: 0.05em`).
- Background must use the defined Muted Pastels.
- Accordions (FAQ):
- Strip all container boxes. Separate items only with a `border-bottom: 1px solid #EAEAEA`.
- Use a clean, sharp `+` and `-` icon for the toggle state.
- Keystroke Micro-UIs:
- Render shortcuts as physical keys using `<kbd>` tags: `border: 1px solid #EAEAEA`, `border-radius: 4px`, `background: #F7F6F3`, using the Monospace font.
- Faux-OS Window Chrome:
- When mocking up software, wrap it in a minimalist container with a white top bar containing three small, light gray circles (replicating macOS window controls).
## 6. Iconography & Imagery Directives
- System Icons: Use "Phosphor Icons (Bold or Fill weights)" or "Radix UI Icons" for a technical, slightly thicker-stroke aesthetic. Standardize stroke width across all icons.
- Illustrations: Monochromatic, rough continuous-line ink sketches on a white background, featuring a single offset geometric shape filled with a muted pastel color.
- Photography: Use high-quality, desaturated images with a warm tone. Apply subtle overlays (`opacity: 0.04` warm grain) to blend photos into the monochrome palette. Never use oversaturated stock photos. Use reliable placeholders like `https://picsum.photos/seed/{context}/1200/800` when real assets are unavailable.
- Hero & Section Backgrounds: Sections should not feel empty and flat. Use subtle full-width background imagery at very low opacity, soft radial light spots (`radial-gradient` with warm tones at `opacity: 0.03`), or minimal geometric line patterns to add depth without breaking the clean aesthetic.
## 7. Subtle Motion & Micro-Animations
Motion should feel invisible — present but never distracting. The goal is quiet sophistication, not spectacle.
- Scroll Entry: Elements fade in gently as they enter the viewport. Use `translateY(12px)` + `opacity: 0` resolving over `600ms` with `cubic-bezier(0.16, 1, 0.3, 1)`. Use `IntersectionObserver`, never `window.addEventListener('scroll')`.
- Hover States: Cards lift with an ultra-subtle shadow shift (`box-shadow` transitioning from `0 0 0` to `0 2px 8px rgba(0,0,0,0.04)` over `200ms`). Buttons respond with `scale(0.98)` on `:active`.
- Staggered Reveals: Lists and grid items enter with a cascade delay (`animation-delay: calc(var(--index) * 80ms)`). Never mount everything at once.
- Background Ambient Motion: Optional. A single, very slow-moving radial gradient blob (`animation-duration: 20s+`, `opacity: 0.02-0.04`) drifting behind hero sections. Must be applied to a `position: fixed; pointer-events: none` layer. Never on scrolling containers.
- Performance: Animate exclusively via `transform` and `opacity`. No layout-triggering properties (`top`, `left`, `width`, `height`). Use `will-change: transform` sparingly and only on actively animating elements.
- Reduced Motion (mandatory): Gate scroll-entry, staggered reveals, and ambient background motion behind `@media (prefers-reduced-motion: no-preference)`, and add a `@media (prefers-reduced-motion: reduce)` block that disables them so content appears instantly at rest. Hover and `:active` feedback may remain.
## 8. Execution Protocol
When tasked with writing frontend code (HTML, React, Tailwind, Vue) or designing a layout:
1. Establish the macro-whitespace first. Use massive vertical padding between sections (e.g., `py-24` or `py-32` in Tailwind).
2. Constrain the main typography content width to `max-w-4xl` or `max-w-5xl`.
3. Apply the custom typographic hierarchy and monochromatic color variables immediately.
4. Ensure every card, divider, and border adheres strictly to the `1px solid #EAEAEA` rule.
5. Add scroll-entry animations to all major content blocks.
6. Ensure sections have visual depth through imagery, ambient gradients, or subtle textures — no empty flat backgrounds.
7. Provide code that reflects this high-end, uncluttered, editorial aesthetic natively without requiring manual adjustments.
-49
View File
@@ -1,49 +0,0 @@
---
name: full-output-enforcement
description: Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.
---
# Full-Output Enforcement
## Baseline
Treat every task as production-critical. A partial output is a broken output. Do not optimize for brevity — optimize for completeness. If the user asks for a full file, deliver the full file. If the user asks for 5 components, deliver 5 components. No exceptions.
## Banned Output Patterns
The following patterns are hard failures. Never produce them:
**In code blocks:** `// ...`, `// rest of code`, `// implement here`, `// TODO`, `/* ... */`, `// similar to above`, `// continue pattern`, `// add more as needed`, bare `...` standing in for omitted code
**In prose:** "Let me know if you want me to continue", "I can provide more details if needed", "for brevity", "the rest follows the same pattern", "similarly for the remaining", "and so on" (when replacing actual content), "I'll leave that as an exercise"
**Structural shortcuts:** Outputting a skeleton when the request was for a full implementation. Showing the first and last section while skipping the middle. Replacing repeated logic with one example and a description. Describing what code should do instead of writing it.
## Execution Process
1. **Scope** — Read the full request. Count how many distinct deliverables are expected (files, functions, sections, answers). Lock that number.
2. **Build** — Generate every deliverable completely. No partial drafts, no "you can extend this later."
3. **Cross-check** — Before output, re-read the original request. Compare your deliverable count against the scope count. If anything is missing, add it before responding.
## Handling Long Outputs
When a response approaches the token limit:
- Do not compress remaining sections to squeeze them in.
- Do not skip ahead to a conclusion.
- Write at full quality up to a clean breakpoint (end of a function, end of a file, end of a section).
- End with:
```
[PAUSED — X of Y complete. Send "continue" to resume from: next section name]
```
On "continue", pick up exactly where you stopped. No recap, no repetition.
## Quick Check
Before finalizing any response, verify:
- No banned patterns from the list above appear anywhere in the output
- Every item the user requested is present and finished
- Code blocks contain actual runnable code, not descriptions of what code would do
- Nothing was shortened to save space
-181
View File
@@ -1,181 +0,0 @@
---
name: redesign-existing-projects
description: Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.
---
# Redesign Skill
## How This Works
When applied to an existing project, follow this sequence:
1. **Scan** — Read the codebase. Identify the framework, styling method (Tailwind, vanilla CSS, styled-components, etc.), and current design patterns.
2. **Diagnose** — Run through the audit below. List every generic pattern, weak point, and missing state you find.
3. **Fix** — Apply targeted upgrades working with the existing stack. Do not rewrite from scratch. Improve what's there.
## Design Audit
### Typography
Check for these problems and fix them:
- **Browser default fonts or Inter everywhere.** Replace with a font that has character. Good options: `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`. For editorial/creative projects, pair a serif header with a sans-serif body.
- **Headlines lack presence.** Increase size for display text, tighten letter-spacing, reduce line-height. Headlines should feel heavy and intentional.
- **Body text too wide.** Limit paragraph width to roughly 65 characters. Increase line-height for readability.
- **Only Regular (400) and Bold (700) weights used.** Introduce Medium (500) and SemiBold (600) for more subtle hierarchy.
- **Numbers in proportional font.** Use a monospace font or enable tabular figures (`font-variant-numeric: tabular-nums`) for data-heavy interfaces.
- **Missing letter-spacing adjustments.** Use negative tracking for large headers, positive tracking for small caps or labels.
- **All-caps subheaders everywhere.** Try lowercase italics, sentence case, or small-caps instead.
- **Orphaned words.** Single words sitting alone on the last line. Fix with `text-wrap: balance` or `text-wrap: pretty`.
### Color and Surfaces
- **Pure `#000000` background.** Replace with off-black, dark charcoal, or tinted dark (`#0a0a0a`, `#121212`, or a dark navy).
- **Oversaturated accent colors.** Keep saturation below 80%. Desaturate accents so they blend with neutrals instead of screaming.
- **More than one accent color.** Pick one. Remove the rest. Consistency beats variety.
- **Mixing warm and cool grays.** Stick to one gray family. Tint all grays with a consistent hue (warm or cool, not both).
- **Purple/blue "AI gradient" aesthetic.** This is the most common AI design fingerprint. Replace with neutral bases and a single, considered accent.
- **Generic `box-shadow`.** Tint shadows to match the background hue. Use colored shadows (e.g., dark blue shadow on a blue background) instead of pure black at low opacity.
- **Flat design with zero texture.** Add subtle noise, grain, or micro-patterns to backgrounds. Pure flat vectors feel sterile.
- **Perfectly even gradients.** Break the uniformity with radial gradients, noise overlays, or mesh gradients instead of standard linear 45-degree fades.
- **Inconsistent lighting direction.** Audit all shadows to ensure they suggest a single, consistent light source.
- **Random dark sections in a light mode page (or vice versa).** A single dark-background section breaking an otherwise light page looks like a copy-paste accident. Either commit to a full dark mode or keep a consistent background tone throughout. If contrast is needed, use a slightly darker shade of the same palette — not a sudden jump to `#111` in the middle of a cream page.
- **Empty, flat sections with no visual depth.** Sections that are just text on a plain background feel unfinished. Add high-quality background imagery (blurred, overlaid, or masked), subtle patterns, or ambient gradients. Use reliable placeholder sources like `https://picsum.photos/seed/{name}/1920/1080` when real assets are not available. Experiment with background images behind hero sections, feature blocks, or CTAs — even a subtle full-width photo at low opacity adds presence.
### Layout
- **Everything centered and symmetrical.** Break symmetry with offset margins, mixed aspect ratios, or left-aligned headers over centered content.
- **Three equal card columns as feature row.** This is the most generic AI layout. Replace with a 2-column zig-zag, asymmetric grid, horizontal scroll, or masonry layout.
- **Using `height: 100vh` for full-screen sections.** Replace with `min-height: 100dvh` to prevent layout jumping on mobile browsers (iOS Safari viewport bug).
- **Complex flexbox percentage math.** Replace with CSS Grid for reliable multi-column structures.
- **No max-width container.** Add a container constraint (around 1200-1440px) with auto margins so content doesn't stretch edge-to-edge on wide screens.
- **Cards of equal height forced by flexbox.** Allow variable heights or use masonry when content varies in length.
- **Uniform border-radius on everything.** Vary the radius: tighter on inner elements, softer on containers.
- **No overlap or depth.** Elements sit flat next to each other. Use negative margins to create layering and visual depth.
- **Symmetrical vertical padding.** Top and bottom padding are always identical. Adjust optically — bottom padding often needs to be slightly larger.
- **Dashboard always has a left sidebar.** Consider top navigation, a floating command menu, or a collapsible panel when it fits the project's navigation.
- **Desktop sidebar collapses to nothing.** For collapsible sidebars, keep expanded labels as the default and offer an opt-in icon rail around 56-72px wide, with one icon per destination. Keep the active item highlighted and replace group headings with thin dividers. Below the project's tablet breakpoint, use an off-canvas drawer instead of a rail.
- **Missing whitespace.** Double the spacing. Let the design breathe. Dense layouts work for data dashboards, not for marketing pages.
- **Buttons not bottom-aligned in card groups.** When cards have different content lengths, CTAs end up at random heights. Pin buttons to the bottom of each card so they form a clean horizontal line regardless of content above.
- **Feature lists starting at different vertical positions.** In pricing tables or comparison cards, the list of features should start at the same Y position across all columns. Use consistent spacing above the list or fixed-height title/price blocks.
- **Inconsistent vertical rhythm in side-by-side elements.** When placing cards, columns, or panels next to each other, align shared elements (titles, descriptions, prices, buttons) across all items. Misaligned baselines make the layout look broken.
- **Mathematical alignment that looks optically wrong.** Centering by the math doesn't always look centered to the eye. Icons next to text, play buttons in circles, or text in buttons often need 1-2px optical adjustments to feel right.
### Interactivity and States
- **No hover states on buttons.** Add background shift, slight scale, or translate on hover.
- **No active/pressed feedback.** Add a subtle `scale(0.98)` or `translateY(1px)` on press to simulate a physical click.
- **Instant transitions with zero duration.** Add smooth transitions (200-300ms) to all interactive elements.
- **Missing focus ring.** Ensure visible focus indicators for keyboard navigation. This is an accessibility requirement, not optional.
- **No loading states.** Replace generic circular spinners with skeleton loaders that match the layout shape.
- **No empty states.** An empty dashboard showing nothing is a missed opportunity. Design a composed "getting started" view.
- **No error states.** Add clear, inline error messages for forms. Do not use `window.alert()`.
- **Dead links.** Buttons that link to `#`. Either link to real destinations or visually disable them.
- **No indication of current page in navigation.** Style the active nav link differently so users know where they are.
- **Icon-only navigation without names or usable targets.** Keep label text in the DOM, visually hidden in the rail, and show tooltips on hover and keyboard focus. Give rail items and the collapse/expand control targets of at least 44×44px. Use a button with an accessible name and `aria-expanded` for the control.
- **Scroll jumping.** Anchor clicks jump instantly. Add `scroll-behavior: smooth`.
- **Animations using `top`, `left`, `width`, `height`.** Switch to `transform` and `opacity` for GPU-accelerated, smooth animation.
### Content
- **Generic names like "John Doe" or "Jane Smith".** Use diverse, realistic-sounding names.
- **Fake round numbers like `99.99%`, `50%`, `$100.00`.** Use organic, messy data: `47.2%`, `$99.00`, `+1 (312) 847-1928`.
- **Placeholder company names like "Acme Corp", "Nexus", "SmartFlow".** Invent contextual, believable brand names.
- **AI copywriting cliches.** Never use "Elevate", "Seamless", "Unleash", "Next-Gen", "Game-changer", "Delve", "Tapestry", or "In the world of...". Write plain, specific language.
- **Exclamation marks in success messages.** Remove them. Be confident, not loud.
- **"Oops!" error messages.** Be direct: "Connection failed. Please try again."
- **Passive voice.** Use active voice: "We couldn't save your changes" instead of "Mistakes were made."
- **All blog post dates identical.** Randomize dates to appear real.
- **Same avatar image for multiple users.** Use unique assets for every distinct person.
- **Lorem Ipsum.** Never use placeholder latin text. Write real draft copy.
- **Title Case On Every Header.** Use sentence case instead.
### Component Patterns
- **Generic card look (border + shadow + white background).** Remove the border, or use only background color, or use only spacing. Cards should exist only when elevation communicates hierarchy.
- **Always one filled button + one ghost button.** Add text links or tertiary styles to reduce visual noise.
- **Pill-shaped "New" and "Beta" badges.** Try square badges, flags, or plain text labels.
- **Accordion FAQ sections.** Use a side-by-side list, searchable help, or inline progressive disclosure.
- **3-card carousel testimonials with dots.** Replace with a masonry wall, embedded social posts, or a single rotating quote.
- **Pricing table with 3 towers.** Highlight the recommended tier with color and emphasis, not just extra height.
- **Modals for everything.** Use inline editing, slide-over panels, or expandable sections instead of popups for simple actions.
- **Avatar circles exclusively.** Try squircles or rounded squares for a less generic look.
- **Light/dark toggle always a sun/moon switch.** Use a dropdown, system preference detection, or integrate it into settings.
- **Footer link farm with 4 columns.** Simplify. Focus on main navigational paths and legally required links.
### Iconography
- **Lucide or Feather icons exclusively.** These are the "default" AI icon choice. Use Phosphor, Heroicons, or a custom set for differentiation.
- **Rocketship for "Launch", shield for "Security".** Replace cliche metaphors with less obvious icons (bolt, fingerprint, spark, vault).
- **Inconsistent stroke widths across icons.** Audit all icons and standardize to one stroke weight.
- **Missing favicon.** Always include a branded favicon.
- **Stock "diverse team" photos.** Use real team photos, candid shots, or a consistent illustration style instead of uncanny stock imagery.
### Code Quality
- **Div soup.** Use semantic HTML: `<nav>`, `<main>`, `<article>`, `<aside>`, `<section>`.
- **Collapsed navigation is inert or hidden.** Keep the rail and collapse/expand control outside hidden or `inert` ancestors, exposed to assistive technology and operable by pointer and keyboard, with visible focus indicators. Suppress focus and interaction in inactive expanded sidebar content with `inert`, `hidden`, or `display: none`; use explicit tab-stop and interaction management if a visual transition keeps it rendered. `aria-hidden="true"` alone does not remove descendants from the tab order.
- **Inline styles mixed with CSS classes.** Move all styling to the project's styling system.
- **Hardcoded pixel widths.** Use relative units (`%`, `rem`, `em`, `max-width`) for flexible layouts.
- **Missing alt text on images.** Describe image content for screen readers. Never leave `alt=""` or `alt="image"` on meaningful images.
- **Arbitrary z-index values like `9999`.** Establish a clean z-index scale in the theme/variables.
- **Commented-out dead code.** Remove all debug artifacts before shipping.
- **Import hallucinations.** Check that every import actually exists in `package.json` or the project dependencies.
- **Missing meta tags.** Add proper `<title>`, `description`, `og:image`, and social sharing meta tags.
### Strategic Omissions (What AI Typically Forgets)
- **No legal links.** Add privacy policy and terms of service links in the footer.
- **No "back" navigation.** Dead ends in user flows. Every page needs a way back.
- **No custom 404 page.** Design a helpful, branded "page not found" experience.
- **No form validation.** Add client-side validation for emails, required fields, and format checks.
- **No "skip to content" link.** Essential for keyboard users. Add a hidden skip-link.
- **No cookie consent.** If required by jurisdiction, add a compliant consent banner.
## Upgrade Techniques
When upgrading a project, pull from these high-impact techniques to replace generic patterns:
### Typography Upgrades
- **Variable font animation.** Interpolate weight or width on scroll or hover for text that feels alive.
- **Outlined-to-fill transitions.** Text starts as a stroke outline and fills with color on scroll entry or interaction.
- **Text mask reveals.** Large typography acting as a window to video or animated imagery behind it.
### Layout Upgrades
- **Broken grid / asymmetry.** Elements that deliberately ignore column structure — overlapping, bleeding off-screen, or offset with calculated randomness.
- **Whitespace maximization.** Aggressive use of negative space to force focus on a single element.
- **Parallax card stacks.** Sections that stick and physically stack over each other during scroll.
- **Split-screen scroll.** Two halves of the screen sliding in opposite directions.
### Motion Upgrades
- **Smooth scroll with inertia.** Decouple scrolling from browser defaults for a heavier, cinematic feel.
- **Staggered entry.** Elements cascade in with slight delays, combining Y-axis translation with opacity fade. Never mount everything at once.
- **Spring physics.** Replace linear easing with spring-based motion for a natural, weighty feel on all interactive elements.
- **Scroll-driven reveals.** Content entering through expanding masks, wipes, or draw-on SVG paths tied to scroll progress.
### Surface Upgrades
- **True glassmorphism.** Go beyond `backdrop-filter: blur`. Add a 1px inner border and a subtle inner shadow to simulate edge refraction.
- **Spotlight borders.** Card borders that illuminate dynamically under the cursor.
- **Grain and noise overlays.** A fixed, pointer-events-none overlay with subtle noise to break digital flatness.
- **Colored, tinted shadows.** Shadows that carry the hue of the background rather than using generic black.
## Fix Priority
Apply changes in this order for maximum visual impact with minimum risk:
1. **Font swap** — biggest instant improvement, lowest risk
2. **Color palette cleanup** — remove clashing or oversaturated colors
3. **Hover and active states** — makes the interface feel alive
4. **Layout and spacing** — proper grid, max-width, consistent padding
5. **Replace generic components** — swap cliche patterns for modern alternatives
6. **Add loading, empty, and error states** — makes it feel finished
7. **Polish typography scale and spacing** — the premium final touch
## Rules
- Work with the existing tech stack. Do not migrate frameworks or styling libraries.
- Do not break existing functionality. Test after every change.
- Before importing any new library, check the project's dependency file first.
- If the project uses Tailwind, check the version (v3 vs v4) before modifying config.
- If the project has no framework, use vanilla CSS.
- Keep changes reviewable and focused. Small, targeted improvements over big rewrites.
-98
View File
@@ -1,98 +0,0 @@
---
name: high-end-visual-design
description: Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.
---
# Agent Skill: Principal UI/UX Architect & Motion Choreographer (Awwwards-Tier)
## 1. Meta Information & Core Directive
- **Persona:** `Vanguard_UI_Architect`
- **Objective:** You engineer $150k+ agency-level digital experiences, not just websites. Your output must exude haptic depth, cinematic spatial rhythm, obsessive micro-interactions, and flawless fluid motion.
- **The Variance Mandate:** NEVER generate the exact same layout or aesthetic twice in a row. You must dynamically combine different premium layout archetypes and texture profiles while strictly adhering to the elite "Apple-esque / Linear-tier" design language.
## 2. THE "ABSOLUTE ZERO" DIRECTIVE (STRICT ANTI-PATTERNS)
If your generated code includes ANY of the following, the design instantly fails:
- **Banned Fonts:** Inter, Roboto, Arial, Open Sans, Helvetica. (Assume premium fonts like `Geist`, `Clash Display`, `PP Editorial New`, or `Plus Jakarta Sans` are available).
- **Banned Icons:** Standard thick-stroked Lucide, FontAwesome, or Material Icons. Use only ultra-light, precise lines (e.g., Phosphor Light, Remix Line).
- **Banned Borders & Shadows:** Generic 1px solid gray borders. Harsh, dark drop shadows (`shadow-md`, `rgba(0,0,0,0.3)`).
- **Banned Layouts:** Edge-to-edge sticky navbars glued to the top. Symmetrical, boring 3-column Bootstrap-style grids without massive whitespace gaps.
- **Banned Motion:** Standard `linear` or `ease-in-out` transitions. Instant state changes without interpolation.
## 3. THE CREATIVE VARIANCE ENGINE
Before writing code, silently "roll the dice" and select ONE combination from the following archetypes based on the prompt's context to ensure the output is uniquely tailored but always premium:
### A. Vibe & Texture Archetypes (Pick 1)
1. **Ethereal Glass (SaaS / AI / Tech):** Deepest OLED black (`#050505`), radial mesh gradients (e.g., subtle glowing purple/emerald orbs) in the background. Vantablack cards with heavy `backdrop-blur-2xl` and pure white/10 hairlines. Wide geometric Grotesk typography.
2. **Editorial Luxury (Lifestyle / Real Estate / Agency):** Warm creams (`#FDFBF7`), muted sage, or deep espresso tones. High-contrast Variable Serif fonts for massive headings. Subtle CSS noise/film-grain overlay (`opacity-[0.03]`) for a physical paper feel.
3. **Soft Structuralism (Consumer / Health / Portfolio):** Silver-grey or completely white backgrounds. Massive bold Grotesk typography. Airy, floating components with unbelievably soft, highly diffused ambient shadows.
### B. Layout Archetypes (Pick 1)
1. **The Asymmetrical Bento:** A masonry-like CSS Grid of varying card sizes (e.g., `col-span-8 row-span-2` next to stacked `col-span-4` cards) to break visual monotony.
- **Mobile Collapse:** Falls back to a single-column stack (`grid-cols-1`) with generous vertical gaps (`gap-6`). All `col-span` overrides reset to `col-span-1`.
2. **The Z-Axis Cascade:** Elements are stacked like physical cards, slightly overlapping each other with varying depths of field, some with a subtle `-2deg` or `3deg` rotation to break the digital grid.
- **Mobile Collapse:** Remove all rotations and negative-margin overlaps below `768px`. Stack vertically with standard spacing. Overlapping elements cause touch-target conflicts on mobile.
3. **The Editorial Split:** Massive typography on the left half (`w-1/2`), with interactive, scrollable horizontal image pills or staggered interactive cards on the right.
- **Mobile Collapse:** Converts to a full-width vertical stack (`w-full`). Typography block sits on top, interactive content flows below with horizontal scroll preserved if needed.
**Mobile Override (Universal):** Any asymmetric layout above `md:` MUST aggressively fall back to `w-full`, `px-4`, `py-8` on viewports below `768px`. Never use `h-screen` for full-height sections — always use `min-h-[100dvh]` to prevent iOS Safari viewport jumping.
## 4. HAPTIC MICRO-AESTHETICS (COMPONENT MASTERY)
### A. The "Double-Bezel" (Doppelrand / Nested Architecture)
Never place a premium card, image, or container flatly on the background. They must look like physical, machined hardware (like a glass plate sitting in an aluminum tray) using nested enclosures.
- **Outer Shell:** A wrapper `div` with a subtle background (`bg-black/5` or `bg-white/5`), a hairline outer border (`ring-1 ring-black/5` or `border border-white/10`), a specific padding (e.g., `p-1.5` or `p-2`), and a large outer radius (`rounded-[2rem]`).
- **Inner Core:** The actual content container inside the shell. It has its own distinct background color, its own inner highlight (`shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)]`), and a mathematically calculated smaller radius (e.g., `rounded-[calc(2rem-0.375rem)]`) for concentric curves.
### B. Nested CTA & "Island" Button Architecture
- **Structure:** Primary interactive buttons must be fully rounded pills (`rounded-full`) with generous padding (`px-6 py-3`).
- **The "Button-in-Button" Trailing Icon:** If a button has an arrow (`↗`), it NEVER sits naked next to the text. It must be nested inside its own distinct circular wrapper (e.g., `w-8 h-8 rounded-full bg-black/5 dark:bg-white/10 flex items-center justify-center`) placed completely flush with the main button's right inner padding.
### C. Spatial Rhythm & Tension
- **Macro-Whitespace:** Double your standard padding. Use `py-24` to `py-40` for sections. Allow the design to breathe heavily.
- **Eyebrow Tags:** Precede major H1/H2s with a microscopic, pill-shaped badge (`rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.2em] font-medium`).
## 5. MOTION CHOREOGRAPHY (FLUID DYNAMICS)
Never use default transitions. All motion must simulate real-world mass and spring physics. Use custom cubic-beziers (e.g., `transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]`).
### A. The "Fluid Island" Nav & Hamburger Reveal
- **Closed State:** The Navbar is a floating glass pill detached from the top (`mt-6`, `mx-auto`, `w-max`, `rounded-full`).
- **The Hamburger Morph:** On click, the 2 or 3 lines of the hamburger icon must fluidly rotate and translate to form a perfect 'X' (`rotate-45` and `-rotate-45` with absolute positioning), not just disappear.
- **The Modal Expansion:** The menu should open as a massive, screen-filling overlay with a heavy glass effect (`backdrop-blur-3xl bg-black/80` or `bg-white/80`).
- **Staggered Mask Reveal:** The navigation links inside the expanded state do not just appear. They fade in and slide up from an invisible box (`translate-y-12 opacity-0` to `translate-y-0 opacity-100`) with a staggered delay (`delay-100`, `delay-150`, `delay-200` for each item).
### B. Magnetic Button Hover Physics
- Use the `group` utility. On hover, do not just change the background color.
- Scale the entire button down slightly (`active:scale-[0.98]`) to simulate physical pressing.
- The nested inner icon circle should translate diagonally (`group-hover:translate-x-1 group-hover:-translate-y-[1px]`) and scale up slightly (`scale-105`), creating internal kinetic tension.
### C. Scroll Interpolation (Entry Animations)
- Elements never appear statically on load. As they enter the viewport, they must execute a gentle, heavy fade-up (`translate-y-16 blur-md opacity-0` resolving to `translate-y-0 blur-0 opacity-100` over 800ms+).
- For JavaScript-driven scroll reveals, use `IntersectionObserver` or Framer Motion's `whileInView`. Never use `window.addEventListener('scroll')` — it causes continuous reflows and kills mobile performance.
## 6. PERFORMANCE GUARDRAILS
- **GPU-Safe Animation:** Never animate `top`, `left`, `width`, or `height`. Animate exclusively via `transform` and `opacity`. Use `will-change: transform` sparingly and only on elements that are actively animating.
- **Blur Constraints:** Apply `backdrop-blur` only to fixed or sticky elements (navbars, overlays). Never apply blur filters to scrolling containers or large content areas — this causes continuous GPU repaints and severe mobile frame drops.
- **Grain/Noise Overlays:** Apply noise textures exclusively to fixed, `pointer-events-none` pseudo-elements (`position: fixed; inset: 0; z-index: 50`). Never attach them to scrolling containers.
- **Z-Index Discipline:** Do not use arbitrary `z-50` or `z-[9999]`. Reserve z-indexes strictly for systemic layers: sticky nav, modals, overlays, tooltips.
## 7. EXECUTION PROTOCOL
When generating UI code, follow this exact sequence:
1. **[SILENT THOUGHT]** Roll the Variance Engine (Section 3). Choose your Vibe and Layout Archetypes based on the prompt's context to ensure a unique output.
2. **[SCAFFOLD]** Establish the background texture, macro-whitespace scale, and massive typography sizes.
3. **[ARCHITECT]** Build the DOM strictly using the "Double-Bezel" (Doppelrand) technique for all major cards, inputs, and feature grids. Use exaggerated squircle radii (`rounded-[2rem]`).
4. **[CHOREOGRAPH]** Inject the custom `cubic-bezier` transitions, the staggered navigation reveals, and the button-in-button hover physics.
5. **[OUTPUT]** Deliver flawless, pixel-perfect React/Tailwind/HTML code. Do not include basic, generic fallbacks.
## 8. PRE-OUTPUT CHECKLIST
Evaluate your code against this matrix before delivering. This is the last filter.
- [ ] No banned fonts, icons, borders, shadows, layouts, or motion patterns from Section 2 are present
- [ ] A Vibe Archetype and Layout Archetype from Section 3 were consciously selected and applied
- [ ] All major cards and containers use the Double-Bezel nested architecture (outer shell + inner core)
- [ ] CTA buttons use the Button-in-Button trailing icon pattern where applicable
- [ ] Section padding is at minimum `py-24` — the layout breathes heavily
- [ ] All transitions use custom cubic-bezier curves — no `linear` or `ease-in-out`
- [ ] Scroll entry animations are present — no element appears statically
- [ ] Layout collapses gracefully below `768px` to single-column with `w-full` and `px-4`
- [ ] All animations use only `transform` and `opacity` — no layout-triggering properties
- [ ] `backdrop-blur` is only applied to fixed/sticky elements, never to scrolling content
- [ ] The overall impression reads as "$150k agency build", not "template with nice fonts"
-121
View File
@@ -1,121 +0,0 @@
# Design System: Taste Standard
**Skill:** stitch-design-taste
---
## Configuration — Set Your Style
Adjust these dials before using this design system. They control how creative, dense, and animated the output should be. Pick the level that fits your project.
| Dial | Level | Description |
|------|-------|-------------|
| **Creativity** | `8` | `1` = Ultra-minimal, Swiss, silent, monochrome. `5` = Balanced, clean but with personality. `10` = Expressive, editorial, bold typography experiments, inline images in headlines, strong asymmetry. Default: `8` |
| **Density** | `4` | `1` = Gallery-airy, massive whitespace. `5` = Balanced sections. `10` = Cockpit-dense, data-heavy. Default: `4` |
| **Variance** | `8` | `1` = Predictable, symmetric grids. `5` = Subtle offsets. `10` = Artsy chaotic, no two sections alike. Default: `8` |
| **Motion Intent** | `6` | `1` = Static, no animation noted. `5` = Subtle hover/entrance cues. `10` = Cinematic orchestration noted in every component. Default: `6` |
> **How to use:** Change the numbers above to match your project's vibe. At **Creativity 1–3**, the system produces clean, quiet, Notion-like interfaces. At **Creativity 7–10**, expect inline image typography, dramatic scale contrast, and strong editorial layouts. The rest of the rules below adapt to your chosen levels.
---
## 1. Visual Theme & Atmosphere
A restrained, gallery-airy interface with confident asymmetric layouts and fluid spring-physics motion. The atmosphere is clinical yet warm — like a well-lit architecture studio where every element earns its place through function. Density is balanced (Level 4), variance runs high (Level 8) to prevent symmetrical boredom, and motion is fluid but never theatrical (Level 6). The overall impression: expensive, intentional, alive.
## 2. Color Palette & Roles
- **Canvas White** (#F9FAFB) — Primary background surface. Warm-neutral, never clinical blue-white
- **Pure Surface** (#FFFFFF) — Card and container fill. Used with whisper shadow for elevation
- **Charcoal Ink** (#18181B) — Primary text. Zinc-950 depth — never pure black
- **Steel Secondary** (#71717A) — Body text, descriptions, metadata. Zinc-500 warmth
- **Muted Slate** (#94A3B8) — Tertiary text, timestamps, disabled states
- **Whisper Border** (rgba(226,232,240,0.5)) — Card borders, structural 1px lines. Semi-transparent for depth
- **Diffused Shadow** (rgba(0,0,0,0.05)) — Card elevation. Wide-spreading, 40px blur, -15px offset. Never harsh
### Accent Selection (Pick ONE per project)
- **Emerald Signal** (#10B981) — For growth, success, positive data dashboards
- **Electric Blue** (#3B82F6) — For productivity, SaaS, developer tools
- **Deep Rose** (#E11D48) — For creative, editorial, fashion-adjacent projects
- **Amber Warmth** (#F59E0B) — For community, social, warm-toned products
### Banned Colors
- Purple/Violet neon gradients — the "AI Purple" aesthetic
- Pure Black (#000000) — always Off-Black or Zinc-950
- Oversaturated accents above 80% saturation
- Mixed warm/cool gray systems within one project
## 3. Typography Rules
- **Display:** `Geist`, `Satoshi`, `Cabinet Grotesk`, or `Outfit` — Track-tight (`-0.025em`), controlled fluid scale, weight-driven hierarchy (700–900). Not screaming. Leading compressed (`1.1`). Alternatives forced — `Inter` is BANNED for premium contexts
- **Body:** Same family at weight 400 — Relaxed leading (`1.65`), 65ch max-width, Steel Secondary color (#71717A)
- **Mono:** `Geist Mono` or `JetBrains Mono` — For code blocks, metadata, timestamps. When density exceeds Level 7, all numbers switch to monospace
- **Scale:** Display at `clamp(2.25rem, 5vw, 3.75rem)`. Body at `1rem/1.125rem`. Mono metadata at `0.8125rem`
### Banned Fonts
- `Inter` — banned everywhere in premium/creative contexts
- Generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`, `Palatino`) — BANNED. If serif is needed for editorial/creative, use only distinctive modern serifs like `Fraunces`, `Gambarino`, `Editorial New`, or `Instrument Serif`. Never use default browser serif stacks. Serif is always BANNED in dashboards or software UIs regardless
## 4. Component Stylings
* **Buttons:** Flat surface, no outer glow. Primary: accent fill with white text. Secondary: ghost/outline. Active state: `-1px translateY` or `scale(0.98)` for tactile push. Hover: subtle background shift, never glow
* **Cards/Containers:** Generously rounded corners (`2.5rem`). Pure white fill. Whisper border (`1px`, semi-transparent). Diffused shadow (`0 20px 40px -15px rgba(0,0,0,0.05)`). Internal padding `2rem–2.5rem`. Used ONLY when elevation communicates hierarchy — high-density layouts replace cards with `border-top` dividers or negative space
* **Inputs/Forms:** Label positioned above input. Helper text optional. Error text below in Deep Rose. Focus ring in accent color, `2px` offset. No floating labels. Standard `0.5rem` gap between label-input-error stack
* **Navigation:** Sleek, sticky. Icons scale on hover (Dock Magnification optional). No hamburger on desktop. Clean horizontal with generous spacing
* **Loaders:** Skeletal shimmer matching exact layout dimensions and rounded corners. Shifting light reflection across placeholder shapes. Never circular spinners
* **Empty States:** Composed illustration or icon composition with guidance text. Never just "No data found"
* **Error States:** Inline, contextual. Red accent underline or border. Clear recovery action
## 5. Hero Section
The Hero is the first impression — it must be striking, creative, and never generic.
- **Inline Image Typography:** Embed small, contextual photos or visuals directly between words or letters in the headline. Example: "We build [photo of hands typing] digital [photo of screen] products" — images sit inline at type-height, rounded, acting as visual punctuation between words. This is the signature creative technique
- **No Overlapping Elements:** Text must never overlap images or other text. Every element has its own clear spatial zone. No z-index stacking of content layers, no absolute-positioned headlines over images. Clean separation always
- **No Filler Text:** "Scroll to explore", "Swipe down", scroll arrow icons, bouncing chevrons, and any instructional UI chrome are BANNED. The user knows how to scroll. Let the content pull them in naturally
- **Asymmetric Structure:** Centered Hero layouts are BANNED at this variance level. Use Split Screen (50/50), Left-Aligned text / Right visual, or Asymmetric Whitespace with large empty zones
- **CTA Restraint:** Maximum one primary CTA button. No secondary "Learn more" links. No redundant micro-copy below the headline
## 6. Layout Principles
- **Grid-First:** CSS Grid for all structural layouts. Never flexbox percentage math (`calc(33% - 1rem)` is BANNED)
- **No Overlapping:** Elements must never overlap each other. No absolute-positioned layers stacking content on content. Every element occupies its own grid cell or flow position. Clean, separated spatial zones
- **Feature Sections:** The "3 equal cards in a row" pattern is BANNED. Use 2-column Zig-Zag, asymmetric Bento grids (2fr 1fr 1fr), or horizontal scroll galleries
- **Containment:** All content within `max-width: 1400px`, centered. Generous horizontal padding (`1rem` mobile, `2rem` tablet, `4rem` desktop)
- **Full-Height:** Use `min-height: 100dvh` — never `height: 100vh` (iOS Safari address bar jump)
- **Bento Architecture:** For feature grids, use Row 1: 3 columns | Row 2: 2 columns (70/30 split). Each tile contains a perpetual micro-animation
## 7. Responsive Rules
Every screen must work flawlessly across all viewports. **Responsive is not optional — it is a hard requirement. Every single element must be tested at 375px, 768px, and 1440px.**
- **Mobile-First Collapse (< 768px):** All multi-column layouts collapse to a strict single column. `width: 100%`, `padding: 1rem`, `gap: 1.5rem`. No exceptions
- **No Horizontal Scroll:** Horizontal overflow on mobile is a critical failure. All elements must fit within viewport width. If any element causes horizontal scroll, the design is broken
- **Typography Scaling:** Headlines scale down gracefully via `clamp()`. Body text stays `1rem` minimum. Never shrink body below `14px`. Headlines must remain readable on 375px screens
- **Touch Targets:** All interactive elements minimum `44px` tap target. Generous spacing between clickable items. Buttons must be full-width on mobile
- **Image Behavior:** Hero and inline images scale proportionally. Inline typography images (photos between words) stack below the headline on mobile instead of inline
- **Navigation:** Desktop horizontal nav collapses to a clean mobile menu (slide-in or full-screen overlay). No tiny hamburger icons without labels
- **Cards & Grids:** Bento grids and asymmetric layouts revert to stacked single-column cards with full-width. Maintain internal padding (`1rem`)
- **Spacing Consistency:** Vertical section gaps reduce proportionally on mobile (`clamp(3rem, 8vw, 6rem)`). Never cramped, never excessively airy
- **Testing Viewports:** Designs must be verified at: `375px` (iPhone SE), `390px` (iPhone 14), `768px` (iPad), `1024px` (small laptop), `1440px` (desktop)
## 8. Motion & Interaction (Code-Phase Intent)
> **Note:** Stitch generates static screens — it does not animate. This section documents the **intended motion behavior** so that the coding agent (Antigravity, Cursor, etc.) knows exactly how to implement animations when building the exported design into a live product.
- **Physics Engine:** Spring-based exclusively. `stiffness: 100, damping: 20`. No linear easing anywhere. Premium, weighty feel on all interactive elements
- **Perpetual Micro-Loops:** Every active dashboard component has an infinite-loop state — Pulse on status dots, Typewriter on search bars, Float on feature icons, Shimmer on loading states
- **Staggered Orchestration:** Lists and grids mount with cascaded delays (`animation-delay: calc(var(--index) * 100ms)`). Waterfall reveals, never instant mount
- **Layout Transitions:** Smooth re-ordering via shared element IDs. Items swap positions with physics, simulating real-time intelligence
- **Hardware Rules:** Animate ONLY `transform` and `opacity`. Never `top`, `left`, `width`, `height`. Grain/noise filters on fixed, pointer-events-none pseudo-elements only
- **Performance:** CPU-heavy perpetual animations isolated in microscopic leaf components. Never trigger parent re-renders. Target 60fps minimum
## 9. Anti-Patterns (Banned)
- No emojis — anywhere in UI, code, or alt text
- No `Inter` font — use `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`
- No generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`) — if serif is needed, use distinctive modern serifs only (`Fraunces`, `Instrument Serif`)
- No pure black (`#000000`) — Off-Black or Zinc-950 only
- No neon outer glows or default box-shadow glows
- No oversaturated accent colors above 80%
- No excessive gradient text on large headers
- No custom mouse cursors
- No overlapping elements — text never overlaps images or other content. Clean spatial separation always
- No 3-column equal card layouts for features
- No centered Hero sections (at this variance level)
- No filler UI text: "Scroll to explore", "Swipe down", "Discover more below", scroll arrows, bouncing chevrons — all BANNED
- No generic names: "John Doe", "Sarah Chan", "Acme", "Nexus", "SmartFlow"
- No fake round numbers: `99.99%`, `50%`, `1234567` — use organic data: `47.2%`, `+1 (312) 847-1928`
- No AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize"
- No broken Unsplash links — use `picsum.photos/seed/{id}/800/600` or SVG UI Avatars
- No generic `shadcn/ui` defaults — customize radii, colors, shadows to match this system
- No `z-index` spam — use only for Navbar, Modal, Overlay layer contexts
- No `h-screen` — always `min-h-[100dvh]`
- No circular loading spinners — skeletal shimmer only
-184
View File
@@ -1,184 +0,0 @@
---
name: stitch-design-taste
description: Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards — strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.
---
# Stitch Design Taste — Semantic Design System Skill
## Overview
This skill generates `DESIGN.md` files optimized for Google Stitch screen generation. It translates the battle-tested anti-slop frontend engineering directives into Stitch's native semantic design language — descriptive, natural-language rules paired with precise values that Stitch's AI agent can interpret to produce premium, non-generic interfaces.
The generated `DESIGN.md` serves as the **single source of truth** for prompting Stitch to generate new screens that align with a curated, high-agency design language. Stitch interprets design through **"Visual Descriptions"** supported by specific color values, typography specs, and component behaviors.
## Prerequisites
- Access to Google Stitch via [labs.google/stitch](https://labs.google/stitch)
- Optionally: Stitch MCP Server for programmatic integration with Cursor, Antigravity, or Gemini CLI
## The Goal
Generate a `DESIGN.md` file that encodes:
1. **Visual atmosphere** — the mood, density, and design philosophy
2. **Color calibration** — neutrals, accents, and banned patterns with hex codes
3. **Typographic architecture** — font stacks, scale hierarchy, and anti-patterns
4. **Component behaviors** — buttons, cards, inputs with interaction states
5. **Layout principles** — grid systems, spacing philosophy, responsive strategy
6. **Motion philosophy** — animation engine specs, spring physics, perpetual micro-interactions
7. **Anti-patterns** — explicit list of banned AI design clichés
## Analysis & Synthesis Instructions
### 1. Define the Atmosphere
Evaluate the target project's intent. Use evocative adjectives from the taste spectrum:
- **Density:** "Art Gallery Airy" (1–3) → "Daily App Balanced" (4–7) → "Cockpit Dense" (8–10)
- **Variance:** "Predictable Symmetric" (1–3) → "Offset Asymmetric" (4–7) → "Artsy Chaotic" (8–10)
- **Motion:** "Static Restrained" (1–3) → "Fluid CSS" (4–7) → "Cinematic Choreography" (8–10)
Default baseline: Variance 8, Motion 6, Density 4. Adapt dynamically based on user's vibe description.
### 2. Map the Color Palette
For each color provide: **Descriptive Name** + **Hex Code** + **Functional Role**.
**Mandatory constraints:**
- Maximum 1 accent color. Saturation below 80%
- The "AI Purple/Blue Neon" aesthetic is strictly BANNED — no purple button glows, no neon gradients
- Use absolute neutral bases (Zinc/Slate) with high-contrast singular accents
- Stick to one palette for the entire output — no warm/cool gray fluctuation
- Never use pure black (`#000000`) — use Off-Black, Zinc-950, or Charcoal
### 3. Establish Typography Rules
- **Display/Headlines:** Track-tight, controlled scale. Not screaming. Hierarchy through weight and color, not just massive size
- **Body:** Relaxed leading, max 65 characters per line
- **Font Selection:** `Inter` is BANNED for premium/creative contexts. Force unique character: `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`
- **Serif Ban:** Generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`, `Palatino`) are BANNED. If serif is needed for editorial/creative contexts, use only distinctive modern serifs: `Fraunces`, `Gambarino`, `Editorial New`, or `Instrument Serif`. Serif is always BANNED in dashboards or software UIs
- **Dashboard Constraint:** Use Sans-Serif pairings exclusively (`Geist` + `Geist Mono` or `Satoshi` + `JetBrains Mono`)
- **High-Density Override:** When density exceeds 7, all numbers must use Monospace
### 4. Define the Hero Section
The Hero is the first impression and must be creative, striking, and never generic:
- **Inline Image Typography:** Embed small, contextual photos or visuals directly between words or letters in the headline. Images sit inline at type-height, rounded, acting as visual punctuation. This is the signature creative technique
- **No Overlapping:** Text must never overlap images or other text. Every element occupies its own clean spatial zone
- **No Filler Text:** "Scroll to explore", "Swipe down", scroll arrow icons, bouncing chevrons are BANNED. The content should pull users in naturally
- **Asymmetric Structure:** Centered Hero layouts BANNED when variance exceeds 4
- **CTA Restraint:** Maximum one primary CTA. No secondary "Learn more" links
### 5. Describe Component Stylings
For each component type, describe shape, color, shadow depth, and interaction behavior:
- **Buttons:** Tactile push feedback on active state. No neon outer glows. No custom mouse cursors
- **Cards:** Use ONLY when elevation communicates hierarchy. Tint shadows to background hue. For high-density layouts, replace cards with border-top dividers or negative space
- **Inputs/Forms:** Label above input, helper text optional, error text below. Standard gap spacing
- **Loading States:** Skeletal loaders matching layout dimensions — no generic circular spinners
- **Empty States:** Composed compositions indicating how to populate data
- **Error States:** Clear, inline error reporting
### 6. Define Layout Principles
- No overlapping elements — every element occupies its own clear spatial zone. No absolute-positioned content stacking
- Centered Hero sections are BANNED when variance exceeds 4 — force Split Screen, Left-Aligned, or Asymmetric Whitespace
- The generic "3 equal cards horizontally" feature row is BANNED — use 2-column Zig-Zag, asymmetric grid, or horizontal scroll
- CSS Grid over Flexbox math — never use `calc()` percentage hacks
- Contain layouts using max-width constraints (e.g., 1400px centered)
- Full-height sections must use `min-h-[100dvh]` — never `h-screen` (iOS Safari catastrophic jump)
### 7. Define Responsive Rules
Every design must work across all viewports:
- **Mobile-First Collapse (< 768px):** All multi-column layouts collapse to single column. No exceptions
- **No Horizontal Scroll:** Horizontal overflow on mobile is a critical failure
- **Typography Scaling:** Headlines scale via `clamp()`. Body text minimum `1rem`/`14px`
- **Touch Targets:** All interactive elements minimum `44px` tap target
- **Image Behavior:** Inline typography images (photos between words) stack below headline on mobile
- **Navigation:** Desktop horizontal nav collapses to clean mobile menu
- **Spacing:** Vertical section gaps reduce proportionally (`clamp(3rem, 8vw, 6rem)`)
### 8. Encode Motion Philosophy
- **Spring Physics default:** `stiffness: 100, damping: 20` — premium, weighty feel. No linear easing
- **Perpetual Micro-Interactions:** Every active component should have an infinite loop state (Pulse, Typewriter, Float, Shimmer)
- **Staggered Orchestration:** Never mount lists instantly — use cascade delays for waterfall reveals
- **Performance:** Animate exclusively via `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. Grain/noise filters on fixed pseudo-elements only
### 9. List Anti-Patterns (AI Tells)
Encode these as explicit "NEVER DO" rules in the DESIGN.md:
- No emojis anywhere
- No `Inter` font
- No generic serif fonts (`Times New Roman`, `Georgia`, `Garamond`) — distinctive modern serifs only if needed
- No pure black (`#000000`)
- No neon/outer glow shadows
- No oversaturated accents
- No excessive gradient text on large headers
- No custom mouse cursors
- No overlapping elements — clean spatial separation always
- No 3-column equal card layouts
- No generic names ("John Doe", "Acme", "Nexus")
- No fake round numbers (`99.99%`, `50%`)
- No AI copywriting clichés ("Elevate", "Seamless", "Unleash", "Next-Gen")
- No filler UI text: "Scroll to explore", "Swipe down", scroll arrows, bouncing chevrons
- No broken Unsplash links — use `picsum.photos` or SVG avatars
- No centered Hero sections (for high-variance projects)
## Output Format (DESIGN.md Structure)
```markdown
# Design System: [Project Title]
## 1. Visual Theme & Atmosphere
(Evocative description of the mood, density, variance, and motion intensity.
Example: "A restrained, gallery-airy interface with confident asymmetric layouts
and fluid spring-physics motion. The atmosphere is clinical yet warm — like a
well-lit architecture studio.")
## 2. Color Palette & Roles
- **Canvas White** (#F9FAFB) — Primary background surface
- **Pure Surface** (#FFFFFF) — Card and container fill
- **Charcoal Ink** (#18181B) — Primary text, Zinc-950 depth
- **Muted Steel** (#71717A) — Secondary text, descriptions, metadata
- **Whisper Border** (rgba(226,232,240,0.5)) — Card borders, 1px structural lines
- **[Accent Name]** (#XXXXXX) — Single accent for CTAs, active states, focus rings
(Max 1 accent. Saturation < 80%. No purple/neon.)
## 3. Typography Rules
- **Display:** [Font Name] — Track-tight, controlled scale, weight-driven hierarchy
- **Body:** [Font Name] — Relaxed leading, 65ch max-width, neutral secondary color
- **Mono:** [Font Name] — For code, metadata, timestamps, high-density numbers
- **Banned:** Inter, generic system fonts for premium contexts. Serif fonts banned in dashboards.
## 4. Component Stylings
* **Buttons:** Flat, no outer glow. Tactile -1px translate on active. Accent fill for primary, ghost/outline for secondary.
* **Cards:** Generously rounded corners (2.5rem). Diffused whisper shadow. Used only when elevation serves hierarchy. High-density: replace with border-top dividers.
* **Inputs:** Label above, error below. Focus ring in accent color. No floating labels.
* **Loaders:** Skeletal shimmer matching exact layout dimensions. No circular spinners.
* **Empty States:** Composed, illustrated compositions — not just "No data" text.
## 5. Layout Principles
(Grid-first responsive architecture. Asymmetric splits for Hero sections.
Strict single-column collapse below 768px. Max-width containment.
No flexbox percentage math. Generous internal padding.)
## 6. Motion & Interaction
(Spring physics for all interactive elements. Staggered cascade reveals.
Perpetual micro-loops on active dashboard components. Hardware-accelerated
transforms only. Isolated Client Components for CPU-heavy animations.)
## 7. Anti-Patterns (Banned)
(Explicit list of forbidden patterns: no emojis, no Inter, no pure black,
no neon glows, no 3-column equal grids, no AI copywriting clichés,
no generic placeholder names, no broken image links.)
```
## Best Practices
- **Be Descriptive:** "Deep Charcoal Ink (#18181B)" — not just "dark text"
- **Be Functional:** Explain what each element is used for
- **Be Consistent:** Same terminology throughout the document
- **Be Precise:** Include exact hex codes, rem values, pixel values in parentheses
- **Be Opinionated:** This is not a neutral template — it enforces a specific, premium aesthetic
## Tips for Success
1. Start with the atmosphere — understand the vibe before detailing tokens
2. Look for patterns — identify consistent spacing, sizing, and styling
3. Think semantically — name colors by purpose, not just appearance
4. Consider hierarchy — document how visual weight communicates importance
5. Encode the bans — anti-patterns are as important as the rules themselves
## Common Pitfalls to Avoid
- Using technical jargon without translation ("rounded-xl" instead of "generously rounded corners")
- Omitting hex codes or using only descriptive names
- Forgetting functional roles of design elements
- Being too vague in atmosphere descriptions
- Ignoring the anti-pattern list — these are what make the output premium
- Defaulting to generic "safe" designs instead of enforcing the curated aesthetic
-226
View File
@@ -1,226 +0,0 @@
---
name: design-taste-frontend-v1
description: The original v1 taste-skill, preserved for projects depending on its exact behavior. The current default is `design-taste-frontend` (v2), which is a new skill. Use this v1 install name only if you need exact backward compatibility.
---
# High-Agency Frontend Skill
## 1. ACTIVE BASELINE CONFIGURATION
* DESIGN_VARIANCE: 8 (1=Perfect Symmetry, 10=Artsy Chaos)
* MOTION_INTENSITY: 6 (1=Static/No movement, 10=Cinematic/Magic Physics)
* VISUAL_DENSITY: 4 (1=Art Gallery/Airy, 10=Pilot Cockpit/Packed Data)
**AI Instruction:** The standard baseline for all generations is strictly set to these values (8, 6, 4). Do not ask the user to edit this file. Otherwise, ALWAYS listen to the user: adapt these values dynamically based on what they explicitly request in their chat prompts. Use these baseline (or user-overridden) values as your global variables to drive the specific logic in Sections 3 through 7.
## 2. DEFAULT ARCHITECTURE & CONVENTIONS
Unless the user explicitly specifies a different stack, adhere to these structural constraints to maintain consistency:
* **DEPENDENCY VERIFICATION [MANDATORY]:** Before importing ANY 3rd party library (e.g. `framer-motion`, `lucide-react`, `zustand`), you MUST check `package.json`. If the package is missing, you MUST output the installation command (e.g. `npm install package-name`) before providing the code. **Never** assume a library exists.
* **Framework & Interactivity:** React or Next.js. Default to Server Components (`RSC`).
* **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component.
* **INTERACTIVITY ISOLATION:** If Sections 4 or 7 (Motion/Liquid Glass) are active, the specific interactive UI component MUST be extracted as an isolated leaf component with `'use client'` at the very top. Server Components must exclusively render static layouts.
* **State Management:** Use local `useState`/`useReducer` for isolated UI. Use global state strictly for deep prop-drilling avoidance.
* **Styling Policy:** Use Tailwind CSS (v3/v4) for 90% of styling.
* **TAILWIND VERSION LOCK:** Check `package.json` first. Do not use v4 syntax in v3 projects.
* **T4 CONFIG GUARD:** For v4, do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin.
* **ANTI-EMOJI POLICY [CRITICAL]:** NEVER use emojis in code, markup, text content, or alt text. Replace symbols with high-quality icons (Radix, Phosphor) or clean SVG primitives. Emojis are BANNED.
* **Responsiveness & Spacing:**
* Standardize breakpoints (`sm`, `md`, `lg`, `xl`).
* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`.
* **Viewport Stability [CRITICAL]:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent catastrophic layout jumping on mobile browsers (iOS Safari).
* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`) for reliable structures.
* **Icons:** You MUST use exactly `@phosphor-icons/react` or `@radix-ui/react-icons` as the import paths (check installed version). Standardize `strokeWidth` globally (e.g., exclusively use `1.5` or `2.0`).
## 3. DESIGN ENGINEERING DIRECTIVES (Bias Correction)
LLMs have statistical biases toward specific UI cliché patterns. Proactively construct premium interfaces using these engineered rules:
**Rule 1: Deterministic Typography**
* **Display/Headlines:** Default to `text-4xl md:text-6xl tracking-tighter leading-none`.
* **ANTI-SLOP:** Discourage `Inter` for "Premium" or "Creative" vibes. Force unique character using `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`.
* **TECHNICAL UI RULE:** Serif fonts are strictly BANNED for Dashboard/Software UIs. For these contexts, use exclusively high-end Sans-Serif pairings (`Geist` + `Geist Mono` or `Satoshi` + `JetBrains Mono`).
* **Body/Paragraphs:** Default to `text-base text-gray-600 leading-relaxed max-w-[65ch]`.
**Rule 2: Color Calibration**
* **Constraint:** Max 1 Accent Color. Saturation < 80%.
* **THE LILA BAN:** The "AI Purple/Blue" aesthetic is strictly BANNED. No purple button glows, no neon gradients. Use absolute neutral bases (Zinc/Slate) with high-contrast, singular accents (e.g. Emerald, Electric Blue, or Deep Rose).
* **COLOR CONSISTENCY:** Stick to one palette for the entire output. Do not fluctuate between warm and cool grays within the same project.
**Rule 3: Layout Diversification**
* **ANTI-CENTER BIAS:** Centered Hero/H1 sections are strictly BANNED when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left Aligned content/Right Aligned asset", or "Asymmetric White-space" structures.
**Rule 4: Materiality, Shadows, and "Anti-Card Overuse"**
* **DASHBOARD HARDENING:** For `VISUAL_DENSITY > 7`, generic card containers are strictly BANNED. Use logic-grouping via `border-t`, `divide-y`, or purely negative space. Data metrics should breathe without being boxed in unless elevation (z-index) is functionally required.
* **Execution:** Use cards ONLY when elevation communicates hierarchy. When a shadow is used, tint it to the background hue.
**Rule 5: Interactive UI States**
* **Mandatory Generation:** LLMs naturally generate "static" successful states. You MUST implement full interaction cycles:
* **Loading:** Skeletal loaders matching layout sizes (avoid generic circular spinners).
* **Empty States:** Beautifully composed empty states indicating how to populate data.
* **Error States:** Clear, inline error reporting (e.g., forms).
* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push indicating success/action.
**Rule 6: Data & Form Patterns**
* **Forms:** Label MUST sit above input. Helper text is optional but should exist in markup. Error text below input. Use a standard `gap-2` for input blocks.
## 4. CREATIVE PROACTIVITY (Anti-Slop Implementation)
To actively combat generic AI designs, systematically implement these high-end coding concepts as your baseline:
* **"Liquid Glass" Refraction:** When glassmorphism is needed, go beyond `backdrop-blur`. Add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) to simulate physical edge refraction.
* **Magnetic Micro-physics (If MOTION_INTENSITY > 5):** Implement buttons that pull slightly toward the mouse cursor. **CRITICAL:** NEVER use React `useState` for magnetic hover or continuous animations. Use EXCLUSIVELY Framer Motion's `useMotionValue` and `useTransform` outside the React render cycle to prevent performance collapse on mobile.
* **Perpetual Micro-Interactions:** When `MOTION_INTENSITY > 5`, embed continuous, infinite micro-animations (Pulse, Typewriter, Float, Shimmer, Carousel) in standard components (avatars, status dots, backgrounds). Apply premium Spring Physics (`type: "spring", stiffness: 100, damping: 20`) to all interactive elements—no linear easing.
* **Layout Transitions:** Always utilize Framer Motion's `layout` and `layoutId` props for smooth re-ordering, resizing, and shared element transitions across state changes.
* **Staggered Orchestration:** Do not mount lists or grids instantly. Use `staggerChildren` (Framer) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) to create sequential waterfall reveals. **CRITICAL:** For `staggerChildren`, the Parent (`variants`) and Children MUST reside in the identical Client Component tree. If data is fetched asynchronously, pass the data as props into a centralized Parent Motion wrapper.
## 5. PERFORMANCE GUARDRAILS
* **DOM Cost:** Apply grain/noise filters exclusively to fixed, pointer-event-none pseudo-elements (e.g., `fixed inset-0 z-50 pointer-events-none`) and NEVER to scrolling containers to prevent continuous GPU repaints and mobile performance degradation.
* **Hardware Acceleration:** Never animate `top`, `left`, `width`, or `height`. Animate exclusively via `transform` and `opacity`.
* **Z-Index Restraint:** NEVER spam arbitrary `z-50` or `z-10` unprompted. Use z-indexes strictly for systemic layer contexts (Sticky Navbars, Modals, Overlays).
## 6. TECHNICAL REFERENCE (Dial Definitions)
### DESIGN_VARIANCE (Level 1-10)
* **1-3 (Predictable):** Flexbox `justify-center`, strict 12-column symmetrical grids, equal paddings.
* **4-7 (Offset):** Use `margin-top: -2rem` overlapping, varied image aspect ratios (e.g., 4:3 next to 16:9), left-aligned headers over center-aligned data.
* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (e.g., `grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`).
* **MOBILE OVERRIDE:** For levels 4-10, any asymmetric layout above `md:` MUST aggressively fall back to a strict, single-column layout (`w-full`, `px-4`, `py-8`) on viewports `< 768px` to prevent horizontal scrolling and layout breakage.
### MOTION_INTENSITY (Level 1-10)
* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only.
* **4-7 (Fluid CSS):** Use `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. Use `animation-delay` cascades for load-ins. Focus strictly on `transform` and `opacity`. Use `will-change: transform` sparingly.
* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals or parallax. Use Framer Motion hooks. NEVER use `window.addEventListener('scroll')`.
### VISUAL_DENSITY (Level 1-10)
* **1-3 (Art Gallery Mode):** Lots of white space. Huge section gaps. Everything feels very expensive and clean.
* **4-7 (Daily App Mode):** Normal spacing for standard web apps.
* **8-10 (Cockpit Mode):** Tiny paddings. No card boxes; just 1px lines to separate data. Everything is packed. **Mandatory:** Use Monospace (`font-mono`) for all numbers.
## 7. AI TELLS (Forbidden Patterns)
To guarantee a premium, non-generic output, you MUST strictly avoid these common AI design signatures unless explicitly requested:
### Visual & CSS
* **NO Neon/Outer Glows:** Do not use default `box-shadow` glows or auto-glows. Use inner borders or subtle tinted shadows.
* **NO Pure Black:** Never use `#000000`. Use Off-Black, Zinc-950, or Charcoal.
* **NO Oversaturated Accents:** Desaturate accents to blend elegantly with neutrals.
* **NO Excessive Gradient Text:** Do not use text-fill gradients for large headers.
* **NO Custom Mouse Cursors:** They are outdated and ruin performance/accessibility.
### Typography
* **NO Inter Font:** Banned. Use `Geist`, `Outfit`, `Cabinet Grotesk`, or `Satoshi`.
* **NO Oversized H1s:** The first heading should not scream. Control hierarchy with weight and color, not just massive scale.
* **Serif Constraints:** Use Serif fonts ONLY for creative/editorial designs. **NEVER** use Serif on clean Dashboards.
### Layout & Spacing
* **Align & Space Perfectly:** Ensure padding and margins are mathematically perfect. Avoid floating elements with awkward gaps.
* **NO 3-Column Card Layouts:** The generic "3 equal cards horizontally" feature row is BANNED. Use a 2-column Zig-Zag, asymmetric grid, or horizontal scrolling approach instead.
### Content & Data (The "Jane Doe" Effect)
* **NO Generic Names:** "John Doe", "Sarah Chan", or "Jack Su" are banned. Use highly creative, realistic-sounding names.
* **NO Generic Avatars:** DO NOT use standard SVG "egg" or Lucide user icons for avatars. Use creative, believable photo placeholders or specific styling.
* **NO Fake Numbers:** Avoid predictable outputs like `99.99%`, `50%`, or basic phone numbers (`1234567`). Use organic, messy data (`47.2%`, `+1 (312) 847-1928`).
* **NO Startup Slop Names:** "Acme", "Nexus", "SmartFlow". Invent premium, contextual brand names.
* **NO Filler Words:** Avoid AI copywriting clichés like "Elevate", "Seamless", "Unleash", or "Next-Gen". Use concrete verbs.
### External Resources & Components
* **NO Broken Unsplash Links:** Do not use Unsplash. Use absolute, reliable placeholders like `https://picsum.photos/seed/{random_string}/800/600` or SVG UI Avatars.
* **shadcn/ui Customization:** You may use `shadcn/ui`, but NEVER in its generic default state. You MUST customize the radii, colors, and shadows to match the high-end project aesthetic.
* **Production-Ready Cleanliness:** Code must be extremely clean, visually striking, memorable, and meticulously refined in every detail.
## 8. THE CREATIVE ARSENAL (High-End Inspiration)
Do not default to generic UI. Pull from this library of advanced concepts to ensure the output is visually striking and memorable. When appropriate, leverage **GSAP (ScrollTrigger/Parallax)** for complex scrolltelling or **ThreeJS/WebGL** for 3D/Canvas animations, rather than basic CSS motion. **CRITICAL:** Never mix GSAP/ThreeJS with Framer Motion in the same component tree. Default to Framer Motion for UI/Bento interactions. Use GSAP/ThreeJS EXCLUSIVELY for isolated full-page scrolltelling or canvas backgrounds, wrapped in strict useEffect cleanup blocks.
### The Standard Hero Paradigm
* Stop doing centered text over a dark image. Try asymmetric Hero sections: Text cleanly aligned to the left or right. The background should feature a high-quality, relevant image with a subtle stylistic fade (darkening or lightening gracefully into the background color depending on if it is Light or Dark mode).
### Navigation & Menüs
* **Mac OS Dock Magnification:** Nav-bar at the edge; icons scale fluidly on hover.
* **Magnetic Button:** Buttons that physically pull toward the cursor.
* **Gooey Menu:** Sub-items detach from the main button like a viscous liquid.
* **Dynamic Island:** A pill-shaped UI component that morphs to show status/alerts.
* **Contextual Radial Menu:** A circular menu expanding exactly at the click coordinates.
* **Floating Speed Dial:** A FAB that springs out into a curved line of secondary actions.
* **Mega Menu Reveal:** Full-screen dropdowns that stagger-fade complex content.
### Layout & Grids
* **Bento Grid:** Asymmetric, tile-based grouping (e.g., Apple Control Center).
* **Masonry Layout:** Staggered grid without fixed row heights (e.g., Pinterest).
* **Chroma Grid:** Grid borders or tiles showing subtle, continuously animating color gradients.
* **Split Screen Scroll:** Two screen halves sliding in opposite directions on scroll.
* **Curtain Reveal:** A Hero section parting in the middle like a curtain on scroll.
### Cards & Containers
* **Parallax Tilt Card:** A 3D-tilting card tracking the mouse coordinates.
* **Spotlight Border Card:** Card borders that illuminate dynamically under the cursor.
* **Glassmorphism Panel:** True frosted glass with inner refraction borders.
* **Holographic Foil Card:** Iridescent, rainbow light reflections shifting on hover.
* **Tinder Swipe Stack:** A physical stack of cards the user can swipe away.
* **Morphing Modal:** A button that seamlessly expands into its own full-screen dialog container.
### Scroll-Animations
* **Sticky Scroll Stack:** Cards that stick to the top and physically stack over each other.
* **Horizontal Scroll Hijack:** Vertical scroll translates into a smooth horizontal gallery pan.
* **Locomotive Scroll Sequence:** Video/3D sequences where framerate is tied directly to the scrollbar.
* **Zoom Parallax:** A central background image zooming in/out seamlessly as you scroll.
* **Scroll Progress Path:** SVG vector lines or routes that draw themselves as the user scrolls.
* **Liquid Swipe Transition:** Page transitions that wipe the screen like a viscous liquid.
### Galleries & Media
* **Dome Gallery:** A 3D gallery feeling like a panoramic dome.
* **Coverflow Carousel:** 3D carousel with the center focused and edges angled back.
* **Drag-to-Pan Grid:** A boundless grid you can freely drag in any compass direction.
* **Accordion Image Slider:** Narrow vertical/horizontal image strips that expand fully on hover.
* **Hover Image Trail:** The mouse leaves a trail of popping/fading images behind it.
* **Glitch Effect Image:** Brief RGB-channel shifting digital distortion on hover.
### Typography & Text
* **Kinetic Marquee:** Endless text bands that reverse direction or speed up on scroll.
* **Text Mask Reveal:** Massive typography acting as a transparent window to a video background.
* **Text Scramble Effect:** Matrix-style character decoding on load or hover.
* **Circular Text Path:** Text curved along a spinning circular path.
* **Gradient Stroke Animation:** Outlined text with a gradient continuously running along the stroke.
* **Kinetic Typography Grid:** A grid of letters dodging or rotating away from the cursor.
### Micro-Interactions & Effects
* **Particle Explosion Button:** CTAs that shatter into particles upon success.
* **Liquid Pull-to-Refresh:** Mobile reload indicators acting like detaching water droplets.
* **Skeleton Shimmer:** Shifting light reflections moving across placeholder boxes.
* **Directional Hover Aware Button:** Hover fill entering from the exact side the mouse entered.
* **Ripple Click Effect:** Visual waves rippling precisely from the click coordinates.
* **Animated SVG Line Drawing:** Vectors that draw their own contours in real-time.
* **Mesh Gradient Background:** Organic, lava-lamp-like animated color blobs.
* **Lens Blur Depth:** Dynamic focus blurring background UI layers to highlight a foreground action.
## 9. THE "MOTION-ENGINE" BENTO PARADIGM
When generating modern SaaS dashboards or feature sections, you MUST utilize the following "Bento 2.0" architecture and motion philosophy. This goes beyond static cards and enforces a "Vercel-core meets Dribbble-clean" aesthetic heavily reliant on perpetual physics.
### A. Core Design Philosophy
* **Aesthetic:** High-end, minimal, and functional.
* **Palette:** Background in `#f9fafb`. Cards are pure white (`#ffffff`) with a 1px border of `border-slate-200/50`.
* **Surfaces:** Use `rounded-[2.5rem]` for all major containers. Apply a "diffusion shadow" (a very light, wide-spreading shadow, e.g., `shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)]`) to create depth without clutter.
* **Typography:** Strict `Geist`, `Satoshi`, or `Cabinet Grotesk` font stack. Use subtle tracking (`tracking-tight`) for headers.
* **Labels:** Titles and descriptions must be placed **outside and below** the cards to maintain a clean, gallery-style presentation.
* **Pixel-Perfection:** Use generous `p-8` or `p-10` padding inside cards.
### B. The Animation Engine Specs (Perpetual Motion)
All cards must contain **"Perpetual Micro-Interactions."** Use the following Framer Motion principles:
* **Spring Physics:** No linear easing. Use `type: "spring", stiffness: 100, damping: 20` for a premium, weighty feel.
* **Layout Transitions:** Heavily utilize the `layout` and `layoutId` props to ensure smooth re-ordering, resizing, and shared element state transitions.
* **Infinite Loops:** Every card must have an "Active State" that loops infinitely (Pulse, Typewriter, Float, or Carousel) to ensure the dashboard feels "alive".
* **Performance:** Wrap dynamic lists in `<AnimatePresence>` and optimize for 60fps. **PERFORMANCE CRITICAL:** Any perpetual motion or infinite loop MUST be memoized (React.memo) and completely isolated in its own microscopic Client Component. Never trigger re-renders in the parent layout.
### C. The 5-Card Archetypes (Micro-Animation Specs)
Implement these specific micro-animations when constructing Bento grids (e.g., Row 1: 3 cols | Row 2: 2 cols split 70/30):
1. **The Intelligent List:** A vertical stack of items with an infinite auto-sorting loop. Items swap positions using `layoutId`, simulating an AI prioritizing tasks in real-time.
2. **The Command Input:** A search/AI bar with a multi-step Typewriter Effect. It cycles through complex prompts, including a blinking cursor and a "processing" state with a shimmering loading gradient.
3. **The Live Status:** A scheduling interface with "breathing" status indicators. Include a pop-up notification badge that emerges with an "Overshoot" spring effect, stays for 3 seconds, and vanishes.
4. **The Wide Data Stream:** A horizontal "Infinite Carousel" of data cards or metrics. Ensure the loop is seamless (using `x: ["0%", "-100%"]`) with a speed that feels effortless.
5. **The Contextual UI (Focus Mode):** A document view that animates a staggered highlight of a text block, followed by a "Float-in" of a floating action toolbar with micro-icons.
## 10. FINAL PRE-FLIGHT CHECK
Evaluate your code against this matrix before outputting. This is the **last** filter you apply to your logic.
- [ ] Is global state used appropriately to avoid deep prop-drilling rather than arbitrarily?
- [ ] Is mobile layout collapse (`w-full`, `px-4`, `max-w-7xl mx-auto`) guaranteed for high-variance designs?
- [ ] Do full-height sections safely use `min-h-[100dvh]` instead of the bugged `h-screen`?
- [ ] Do `useEffect` animations contain strict cleanup functions?
- [ ] Are empty, loading, and error states provided?
- [ ] Are cards omitted in favor of spacing where possible?
- [ ] Did you strictly isolate CPU-heavy perpetual animations in their own Client Components?
File diff suppressed because it is too large. Load diff
+147 -19
View File
@@ -1,6 +1,16 @@
---
name: design-taste-frontend
description: tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of 247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions (idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site, portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React, Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated, "AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other site. Not for dashboards or app screens.
description: >-
tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of
247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions
(idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict
no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use
this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site,
portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React,
Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated,
"AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other
site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens
people work in, with their own run and rules.
---
# tasteskill v2
@@ -31,6 +41,34 @@ In the commands below `<skill>` is this skill's folder. Run them from the projec
working files land in `.taste-skill/` there. The scripts need Node 22+ and an installed Chrome, Edge
or Chromium, and install nothing. `assets.mjs` also needs network access; it uses no API keys.
## Page or app screen
Decide first which of two kinds of work this is.
- **A page** is read and scrolled: a landing page, a marketing or product site, a portfolio, an
event, a restaurant, a one-pager. It follows **the run** below.
- **An app screen** is worked in: a dashboard, an admin or analytics view, settings, billing, an inbox
or queue to work through, a wallet, a list with filters, an editor or builder. It follows
**`references/app-screens.md`**: its own run, its own draw (`draw.mjs --app`), its own house rules
(quiet borders, small labels and repeated panels are normal there; gradients, glows, icons in tinted
squares, rainbow charts and invented vanity numbers are not) and `inspect.mjs --app`. Read that file
whole before you start; the page run below does not apply.
A product site that shows its app is a page; the interface fragments on it follow the app-screen
rules for their insides. Deal the product's app style once, in its own folder, with the page's colours
and type kept:
```bash
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<the product>" --keep palette,type --out .taste-skill/app
```
and build every fragment the page shows (the hero's live screen, a feature's crop, a chart) as a real
piece of that app: `assets/app.css` for its panels, tables and controls, `assets/charts.js` for its
charts, real data that adds up, one finding stated, cropped and scaled the way the page's blueprint
treats a picture. A fragment that looks like a working product is the strongest picture a software page
has; grey bars and lorem numbers are the weakest. When a request mixes both (a landing page and the
dashboard behind it), build each with its own run and one shared palette and typeface.
## The run
Do these in order. Steps 1–5 take a few minutes and decide whether the page is any good.
@@ -102,9 +140,10 @@ The command prints a **direction sheet**. It is the art direction for this build
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | section:<role> | direction`) only for a
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
exist. Never redraw to get something more comfortable. Record every redraw in your report.
- 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
@@ -153,6 +192,12 @@ right now and how long until the last ferry; choose a size and see it against th
fallback without script, it never blocks reading, and it is one thing, not a playground. It is
built in step 6 as part of the page, not added as motion in step 9.
**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
Decide what each one shows from your inventory before building: the fly-through passes through this
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
to anyone is a screensaver.
### 5. Write the words
Write the whole page as a copy deck (plain text) before markup, following `references/copy.md`:
@@ -160,8 +205,10 @@ Write the whole page as a copy deck (plain text) before markup, following `refer
- **Every headline fits in two lines or fewer at every width.** Do the arithmetic in copy.md §2 before
committing to a sentence: about 32 characters is safe everywhere at a confident size. The studied
gallery heroes have a median of 5 words and 32 characters.
- One supporting sentence. One primary action, named for what it does, as a real button on the first
screen.
- One supporting sentence, directly under the headline (32–48px below it) and shown with it, never
docked at the foot of the first screen or held back by an animation. When the headline is a name or
a figure of speech, this sentence says plainly what the thing is. One primary action, named for what
it does, as a real button on the first screen.
- **The first screen answers the visitor's first question in large type.** For an event: what it is,
when and where (the dates belong in the headline or at the second size, never only in small text).
For a product: what it is and what it costs or does. For a person: who and what they do. Every
@@ -211,6 +258,13 @@ not finished. Order of work:
sea house), never as a stand-in for the subject;
6. type and colour alone.
When the project has footage or a real picture of its own process or product, the first screen
shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
the pictures come from or what they are not ("none of these shows our bar"): a caption says what
the picture is, and the credit line sits small in the footer. A portfolio whose work is invented
for the brief draws each piece as a finished still of that work (its title card with its own type,
an end frame, a poster), with real detail, never as a pictogram of the client's product.
**The floor, which the inspector checks:** at least four real pictures on the page, usually six to
twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
cropped interface fragments count too). SVG drawings do not count toward the floor: draw what no
@@ -221,12 +275,31 @@ not finished. Order of work:
`references/imagery.md` covers searching, licences and credits, video, drawing, crops and treatments.
5. **The live thing** from step 4, built into the section where it proves the claim (often the hero
or the booking), with real content, a thumb-sized phone version and a plain fallback.
6. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
6. **The scroll scenes,** when the sheet deals them (section 9), each with the section it carries: the page's big
moments, the camera that flies through the work, the picture that opens to the whole screen, the
word that buckles under the scroll. Copy the shared stage and the scene's files from
`<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
page's tokens so it takes the page's look. A scene is composition: it decides how long the page is
and what each screen shows, so it is built now and seen in every look round, not added at the end.
`references/scenes.md` says how to put one on a page, fill it, fit it and check it;
`assets/scenes/demo/<id>.html` is a working example of each.
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
Running text is in the full ink at 16px or more; grey is for secondary notes.
7. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
its own composition.
8. **The frame.** The page has one pair of equal outer margins at every width, and every section uses
the width the page has set up: when its text sits in the left half, the right half holds its
picture, its second column, its list or its figure, never nothing. Asymmetry lives inside the grid,
never in the frame: a void is placed only when something closes it on the far side. No stretch of
the page taller than about half a screen holds nothing at all; sections are 120–240px apart on
desktop. Every section the visitor came for opens with a heading that names it, and the first
screen carries the page's title in an `h1`. These are what readers name first when a page looks
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
inspector measures each of them.
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
its own composition. 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.
@@ -236,16 +309,27 @@ behind a decision; the laws below are the short form.
```bash
node <skill>/scripts/lint.mjs <source files or folder>
node <skill>/scripts/inspect.mjs <url or path/to/index.html>
node <skill>/scripts/scenes.mjs <url or path/to/index.html> # when the page has scroll scenes
```
`scenes.mjs` photographs every scroll scene at 0, 10 … 100% of its hold, on desktop and on a phone,
and the reduced-motion page; with scenes on the sheet, `inspect.mjs` also films the whole scroll
(`desktop-film-NN.jpg`). A scene that never started or rests on desktop is a `FAIL`.
`lint.mjs` scans source for banned patterns and stock phrases. `inspect.mjs` opens the page in a real
browser at 1440, 768 and 390 pixels, measures it (headline line counts, type sizes, fonts actually
loaded, colours, rules, shadows, gradients, contrast, overflow) and saves screenshots to
`.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
loaded, colours, rules, shadows, gradients, contrast, overflow, and the frame: equal margins, sections
huddled on one side, blank stretches, the title, untitled sections, text against the screen edge) and
saves screenshots to `.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with the meter and do not
hide things from it. The inspector serves local files over a throwaway local server; if the page must
also open straight from the file system, open `index.html` that way once before you finish.
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
@@ -260,9 +344,9 @@ Judge only the pictures. If a problem is visible in a screenshot, it is real, wh
Now build the motion layer. It is required, and it is more than one fade: the inspector counts the
elements that really move while the page loads and is scrolled, and fails a page under eight (it warns
under sixteen). The layer has four parts:
under sixteen). Any scroll scenes are already built (step 6); around them the layer has four parts:
1. **The signature move** named on the sheet: one, done well.
1. **The signature move** named on the sheet: one, done well (a dealt scene can be it).
2. **An entrance for the parts of every section:** the heading, then the text, then the picture,
staggered by 60–90ms, rising 16–32px with opacity, triggered once as the section enters. Not one
fade for the whole block, and never content that stays hidden if the script fails.
@@ -278,6 +362,16 @@ every screenshot for content that failed to appear.
### 10. Report
Do not report until all of these are true; each one was skipped in a test run that came out badly:
- `inspect.mjs` shows **0 FAIL** at 1440, 768 and 390, on the final version of the page.
- You opened every screenshot it listed after the last change, not only the first screen.
- With scroll scenes: `scenes.mjs` ran on the final page with no problems, and you read every sheet
(desktop, phone, reduced motion).
- A stranger reading the first screen for five seconds can say what this is and what to do.
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
- The critique of your last round is written down in the shape of `references/critique.md` §4.
Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
sentence and the live thing; the final scores from your last critique round and what still falls short;
every found picture and clip with its title, creator, licence and source page (`assets.mjs --credits` prints
@@ -292,6 +386,9 @@ These hold on every build, whatever the direction. `inspect.mjs` measures them.
| Rule | Instead |
| --- | --- |
| **Headlines: two lines or fewer, at every width.** Every `h1`–`h6`, and the main line of the first screen whatever its tag. | Cut words. Then widen the measure. Size last. |
| **A clear title on the first screen**, in the page's one `h1`, and a heading on every section. | Say what this is: the name with what it does, the product with what it is for, the event with its date and place. |
| **Equal outer margins; no section huddled on one side** with the other half of the screen empty. | Close every void with something on its far side: the section's picture, a second column, a list, a figure. |
| **No blank stretches** taller than about half a screen. | The normal section gap. A deliberate rest is a placed picture or a field of colour, once per page. |
| **Statements are paragraphs.** An intro sentence or a quotation set large is marked up as `<p>` or `<blockquote>`, stays at statement size (36px or less on desktop, 24px on a phone) and runs to about 25 words, three lines. | Larger than that it is a headline and the two-line rule applies. In blind tests jurors read a 42px sentence over four lines as a four-line headline. |
| **No eyebrows.** No small label above a heading, no pill badge above the headline, no "New ·" chip. | Put the information in the heading or the first sentence, or drop it. |
| **No monospace** outside real code blocks. | The text face. Tabular figures where numbers align. |
@@ -341,7 +438,9 @@ Short form. Numbers are from the study unless marked as house style.
**Space**
- One grid, one pair of tight margins (16–32px), used everywhere; large things run to the edges. Text
blocks share two or three left edges on the whole page.
- The gap between sections is clearly larger than any gap inside one: 120–220px on desktop.
- Asymmetry inside the grid, never in the frame: the two outer margins are equal, and an off-centre
heading is answered by something at the far side of the grid.
- The gap between sections is clearly larger than any gap inside one: 120–240px on desktop (the study's median is about 240).
- Each section has a different composition from its neighbours; none is a card grid.
**Colour**
@@ -367,6 +466,11 @@ Short form. Numbers are from the study unless marked as house style.
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.
- 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.
## Special cases
@@ -376,9 +480,28 @@ onto the roles, and take only composition from the draw. The house rules still a
itself requires a banned device (its logo sits in a gradient, its accent is violet), keep the brand
asset, say so in the report, and pass `--allow <rule>` to the inspector for that rule only.
**Redesign or review of an existing page.** Run `lint.mjs` and `inspect.mjs` on it first and report
what they find, then critique the screenshots with `references/critique.md`. For a redesign, draw a
direction and proceed from step 2, keeping the page's real content and working behaviour.
**Redesign, restyle or review of an existing page or app screen.**
1. **Record the before.** Run the project as it is and inspect it into its own folder
(`inspect.mjs <url> --out .taste-skill/before`, with `--app` for an app screen) and lint its
source. Read the code: the framework, the styling method (Tailwind v3 or v4, CSS modules,
styled-components, plain CSS), the components, and what must survive: routes, forms, data,
behaviour, the brand's own assets.
2. **A review stops here.** Critique the before screenshots with `references/critique.md`, list the
findings with the inspector's measurements, and name the three changes that would move the page
most.
3. **Keep the stack, the content and what works.** No framework or styling-library migration; check the
dependency file before adding anything. A restyle keeps the layout and takes type, colour and
components from a dealt direction (`--keep` whatever the brand fixes); a redesign deals a full
direction and rebuilds the sections from its blueprints inside the existing components. Either way
proceed from step 2 of the run with the page's real content.
4. **Order of work, biggest effect for least risk:** type (faces and scale), colour (one ground, one
ink, one accent), the frame (margins, section rhythm, a title on the first screen and a heading on
every section), states (hover, focus, loading, empty, error), then the stock components (icon
cards, pill badges, gradient blobs, card grids) replaced by the dealt blueprints.
5. **Prove it.** Inspect again into `.taste-skill/after`, compare before and after at every width,
click through every link, form and menu that worked before, and report the before and after
measurements side by side.
**A component or a single section, not a page.** Skip the draw unless there is no direction to follow.
Match the surrounding design, apply the house rules and the laws, and still inspect the result.
@@ -398,17 +521,22 @@ open collections could not be searched.
| File | Read it when |
| --- | --- |
| `references/app-screens.md` | Any dashboard, admin, settings or other app screen: its own run, rules, tokens and checks. |
| `references/copy.md` | Writing the argument and the copy deck (steps 2 and 5). The two-line arithmetic. |
| `references/type.md` | Setting type: scales, tracking, measure, loading fonts, pairing. |
| `references/colour.md` | Applying the palette: roles, proportions, tone steps, dark pages, contrast. |
| `references/layout.md` | Grid, margins, rhythm, section variety, navigation, footer, the phone composition. |
| `references/imagery.md` | Choosing, finding, crediting, cropping and treating pictures; what to do with none. |
| `references/motion.md` | Step 9. Budget, tokens, recipes, reduced motion. |
| `references/scenes.md` | Step 6, when the sheet deals scroll scenes: putting one on a page, filling, fitting, the phone, checking, the catalogue. |
| `references/critique.md` | Step 8. How to look, how to score, what to fix first. |
| `references/tells.md` | The dated registry of generated-page tells and what each is replaced by. |
| `references/research.md` | The study: method, numbers, what the best and worst sites do. Cite it, do not guess. |
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references. |
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references; `--app` deals an app style instead. |
| `scripts/assets.mjs` | Step 6: searches open collections for real pictures of the subject's world; `--get` downloads with the credit, `--credits` prints them. |
| `scripts/lint.mjs` · `scripts/inspect.mjs` | The gate. |
| `scripts/scenes.mjs` | The gate for scroll scenes: each scene at 0-100% of its hold, desktop, phone, reduced motion. |
| `assets/base.css` · `assets/base.js` | An optional neutral foundation to build on. |
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions. Do not read it whole; use the script. |
| `assets/scenes/` | The scroll scenes: the shared stage (`stage.js`, `stage.css`) and, for each scene in the library, its `.js`, `.css` and a working demo page in `demo/`. |
| `assets/app.css` · `assets/charts.js` | For app screens: a neutral shell, panels, tables and controls, and small SVG charts drawn to the app rules. |
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions, scroll scenes (`scenes.json`), and the app styles, shells and screen patterns (`app.json`). Do not read it whole; use the script. |
+216
View File
@@ -0,0 +1,216 @@
/* tasteskill / app.css
A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults
measured in the study of real product screens (references/app-screens.md). Copy it into the project,
replace the token values with the ones on the app sheet (draw.mjs --app), and build on top.
Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */
/* ---------- tokens (replace with the dealt style) ---------- */
:root {
/* names match the app sheet printed by draw.mjs --app */
--ground: #f9fafb; /* the app chrome and page */
--surface: #ffffff; /* panels and the work sheet */
--raised: #f4f5f6; /* a filled inner well, hover rows */
--active: #eeeeee; /* the active nav pill */
--border: #e8e8e8; /* panel edges: about 1.2:1 on white */
--rule: #f1f2f3; /* row rules inside tables */
--control: #d1d5db; /* inputs and outlined buttons: one step stronger */
--text: #111111;
--muted: #5f6368; /* secondary text, labels (4.5:1 or more on its ground) */
--faint: #6b6b6b; /* meta and placeholders: the lightest grey that holds 4.5:1 on white */
--accent: #1f6f5c; /* one hue: data, focus, the selected thing */
--primary: #1d1f20; /* the primary button is near-black unless the style says otherwise */
--primary-ink: #ffffff;
--positive: #247b50; --negative: #c4242a; --warning: #92400e; /* references/app-screens.md §9.5; a green accent is also --positive */
--pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e;
--pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e;
--pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e;
--pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d;
--chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6;
--chart-mark: var(--text); /* a marked period in Charts.diverging: the second tone */
--chart-span: 0.06; /* opacity of a shaded span (a selling window, an outage) */
--grid: var(--rule); /* chart gridlines (charts.js) */
--axis-ink: var(--muted); /* chart tick labels (charts.js) */
--ink: var(--text);
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px;
--sidebar-width: 232px;
--pad: 24px; /* canvas padding */
--gap: 16px; /* one gutter, horizontal and vertical */
--card-pad: 20px;
--radius-card: 8px; /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */
--radius-control: 6px;
--control-height: 32px; /* every control in the title and filter rows */
--row-height: 48px; /* single-line table row */
--nav-row: 32px; /* sidebar item height, on a 34px pitch */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms;
}
/* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */
:root[data-theme="dark"] {
--ground: #000000; --surface: #111111; --raised: #222222; --active: #222222;
--border: #1a1a1a; --rule: #161616; --control: #444444;
--text: #ffffff; --muted: #b4b4b4; --faint: #949494;
--primary: #ffffff; --primary-ink: #000000;
--positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b; /* a green or yellow-green accent is also --positive */
--pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a;
--pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f;
--pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b;
--pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff;
--chart-muted: #4a4a4a; --grid: #2b2b2b;
--chart-span: 0.12;
}
/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; }
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
ul[class], ol[class] { padding: 0; list-style: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.num, table, .kpi-value { font-variant-numeric: tabular-nums; }
/* ---------- shell: sidebar + canvas ---------- */
.app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; }
.sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; }
.sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); }
.nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; }
.nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; }
.nav-item:hover { background: var(--raised); color: var(--text); }
.nav-item[aria-current="page"] { background: var(--active); color: var(--text); }
.sidebar .bottom { margin-top: auto; }
.canvas { min-width: 0; padding: var(--pad); }
/* ---------- title block and filters ---------- */
.title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.title-row .sub { margin-top: 4px; color: var(--muted); }
.filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; }
.filters .spacer { flex: 1; }
.stamp { color: var(--faint); font-size: var(--fs-small); }
/* ---------- panels and grid ---------- */
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; }
.panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; }
.panel .meta { color: var(--faint); font-size: var(--fs-small); }
/* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps
its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */
.grid.top { align-items: start; }
/* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */
.chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; }
.chart { position: relative; min-width: 0; }
/* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover,
and no inline padding, so its text sits on the card's content edge */
.card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; }
.card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; }
.card-link:hover { text-decoration: underline; }
:root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
/* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row
that carries a rule has square corners */
.rows { display: grid; }
.row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--rule); border-radius: 0; }
/* KPIs as one divided strip: a label above a number, no icons, no tiles */
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); }
.kpi { padding: 16px var(--card-pad); }
.kpi + .kpi { border-left: 1px solid var(--border); }
.kpi-label { color: var(--muted); }
.kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; }
.kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; }
.delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); }
.delta .up { color: var(--positive); } .delta .down { color: var(--negative); }
/* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and
their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity
breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */
.kpis[role="tablist"] .kpi { position: relative; cursor: pointer; }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); }
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; }
/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
/* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */
td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; }
tbody tr:last-child td { border-bottom: 0; }
/* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */
tbody tr[aria-selected="true"] td { background: var(--active); }
th.n, td.n { text-align: right; }
td .sub { display: block; font-size: var(--fs-small); color: var(--muted); }
.truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* status pill: a pale tint with dark ink of the same hue, one per row */
.pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; }
.pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
.pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); }
.pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); }
/* ---------- controls ---------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); }
.btn:hover { background: var(--raised); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; }
.btn.ghost { border-color: transparent; background: transparent; }
.field { display: grid; gap: 8px; }
.field label { font-weight: 500; }
.field .hint { color: var(--muted); font-size: var(--fs-small); }
.input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); }
.input::placeholder { color: var(--faint); }
.tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); }
.tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; }
.tabs a[aria-selected="true"] { color: var(--text); }
.tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
/* as wide as its options; full width only on a narrow phone */
.segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); }
.segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
.switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); }
.switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); }
/* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */
.empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; }
.skeleton { background: var(--raised); border-radius: 4px; }
/* ---------- phone: the navigation folds away and the canvas takes the width ---------- */
.menu-toggle { display: none; }
@media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); }
/* the menu opens as a full-screen sheet */
.sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); }
.sidebar.open { transform: none; }
.menu-toggle { display: inline-flex; }
.canvas { padding: 16px; }
.grid > * { grid-column: 1 / -1 !important; }
.kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi + .kpi { border-left: 0; }
.kpi:nth-child(n + 3) { border-top: 1px solid var(--border); }
.kpi:nth-child(2n) { border-left: 1px solid var(--border); }
/* an odd count leaves no half-empty row: the last cell takes the full width */
.kpi:last-child:nth-child(odd) { grid-column: 1 / -1; }
/* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */
.kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); }
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; }
.title-row { flex-wrap: wrap; align-items: flex-start; }
}
@media (max-width: 480px) {
.segmented { display: flex; width: 100%; }
.segmented button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } }
/* an empty message spans the block it stands in, never one squeezed column */
.rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; }
+8 -3
View File
@@ -26,13 +26,18 @@
--section: clamp(5rem, 12vw, 13rem); /* space between sections */
--radius: 0px; /* one radius for the whole page: 0, or large, decided once */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals of 800ms and longer, line rises, openings */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */
--t-press: 140ms;
--t-hover: 180ms;
--t-ui: 240ms;
--t-reveal: 800ms;
--t-open: 1200ms; /* picture openings and clip wipes */
--stagger-line: 90ms;
--stagger-item: 70ms;
}
/* ---------- reset ---------- */
+619
View File
@@ -0,0 +1,619 @@
// 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,28 @@
/* tasteskill / scenes / stage.css — the held stage under every scroll scene (references/scenes.md).
Without .is-live (no script, a failed CDN, reduced motion, a phone set to 'rest') a scene is an ordinary
section showing its finished composition. With it, the section is (hold + 1) screens tall and the
stage sticks to the viewport for the hold. */
.scene { position: relative; }
.scene > .scene-stage { position: relative; }
html.js .scene.is-live { height: calc((var(--hold, 2) + 1) * 100svh); }
html.js .scene.is-live > .scene-stage {
position: sticky;
top: 0;
height: 100svh;
overflow: clip; /* clip, not hidden: hidden would make the stage a scroll container */
contain: layout paint;
}
/* Everything a scene moves is composited; nothing it moves is laid out again. */
.scene.is-live [data-move] { will-change: transform, opacity; }
/* Lenis */
html.is-smooth { scroll-behavior: auto; }
html.is-smooth body { overscroll-behavior: none; }
@media (prefers-reduced-motion: reduce) {
html.js .scene.is-live { height: auto; }
html.js .scene.is-live > .scene-stage { position: relative; height: auto; }
}
+134
View File
@@ -0,0 +1,134 @@
// tasteskill / scenes / stage.js — the shared stage every scroll scene runs on (references/scenes.md).
//
// A scene is a tall section whose stage sticks to the viewport while the page scrolls past it; a GSAP
// timeline is scrubbed by that scroll. The page always scrolls natively (no wheel capture), the
// scrollbar always tells the truth, and the finished composition is what reduced motion, no script and
// a failed CDN all show.
//
// Load at the end of <body>, in this order (versions pinned):
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script> (only if a scene splits text)
// <script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script> (optional smoothing)
// <script src="assets/scenes/stage.js"></script>
// <script src="assets/scenes/<scene>.js"></script>
// and keep the one-line <script>document.documentElement.classList.add('js')</script> in <head>.
//
// Stage.smooth() weighted wheel scrolling (Lenis), off for touch and reduced motion
// Stage.scene(root, { hold, scrub, build, phone, rest })
// Stage.split(el, 'lines' | 'words' | 'chars') masked split text, SplitText when loaded
// Stage.ready() resolves when fonts and the scenes' pictures are ready
// Stage.init() starts every <section class="scene" data-scene="<id>"> on the page
//
// A page: Stage.smooth(); Stage.init();
(() => {
const html = document.documentElement;
const mq = (q) => window.matchMedia(q).matches;
const live = !!(window.gsap && window.ScrollTrigger);
if (live) gsap.registerPlugin(ScrollTrigger);
if (live && window.SplitText) gsap.registerPlugin(SplitText);
const Stage = {
live,
get reduced() { return mq('(prefers-reduced-motion: reduce)'); },
get phone() { return mq('(max-width: 767px)'); },
get touch() { return mq('(pointer: coarse)'); },
lenis: null,
scenes: [],
};
// Weighted wheel scrolling. lerp 0.1-0.14 feels heavy without lagging behind the hand; touch keeps the
// phone's own scrolling, and reduced motion keeps the browser's.
Stage.smooth = ({ lerp = 0.12 } = {}) => {
if (!live || !window.Lenis || Stage.reduced || Stage.touch || Stage.lenis) return Stage.lenis;
const lenis = new Lenis({ lerp, smoothWheel: true, anchors: true, autoRaf: false });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
html.classList.add('is-smooth');
Stage.lenis = lenis;
return lenis;
};
// One scroll scene.
// root <section class="scene"> holding one <div class="scene-stage">
// hold screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall
// scrub true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight)
// build(tl, ctx) add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q }
// phone 'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build }
// rest(ctx) optional: anything extra the finished composition needs (most scenes need nothing)
// Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too.
Stage.scene = (root, opts = {}) => {
if (!root) return null;
const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts;
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
const q = (sel) => [...root.querySelectorAll(sel)];
const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); };
if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; }
const mm = gsap.matchMedia();
mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => {
const isPhone = c.conditions.phone;
const ctx = { root, stage, phone: isPhone, q };
const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build };
if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); }
root.classList.remove('is-rest');
root.classList.add('is-live');
root.style.setProperty('--hold', plan.hold);
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true,
onUpdate: (self) => {
root.style.setProperty('--p', self.progress.toFixed(4));
if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats)));
},
},
});
plan.build(tl, ctx);
// pad the timeline to exactly 1 so positions in build() read as fractions of the hold
if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration());
return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); };
});
Stage.scenes.push(mm);
return mm;
};
// Masked split text. Lines rise out of their own mask; words and characters are for short headings.
Stage.split = (el, type = 'lines') => {
if (!el) return [];
if (window.SplitText && live) {
const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false });
return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars;
}
// fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim());
const words = el.textContent.trim().split(/\s+/).map((w) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.style.display = 'inline-block'; span.textContent = w; return span; });
el.replaceChildren(...words.flatMap((span, i) => (i ? [' ', span] : [span])));
el.dataset.split = 'words';
}
return [...el.children];
};
// Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in.
Stage.ready = () => {
const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean);
return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); });
};
// Every <section class="scene" data-scene="<id>" data-options='{"hold":3}'> on the page, started by
// the scene file of that id (assets/scenes/<id>.js registers Scenes['<id>']).
Stage.init = () => {
for (const el of document.querySelectorAll('.scene[data-scene]')) {
if (el.__scene) continue;
const make = window.Scenes[el.dataset.scene];
if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; }
let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {}
el.__scene = make(el, o) || true;
}
};
window.Scenes = window.Scenes || {};
window.Stage = Stage;
if (live) { window.addEventListener('load', () => Stage.ready()); }
})();
+29 -1
View File
@@ -1,6 +1,6 @@
{
"skill_name": "design-taste-frontend",
"how_to_run": "For each eval, give the prompt to a fresh agent that has this skill, and to a second fresh agent without it (the baseline). Render both with scripts/inspect.mjs and have a judge who does not know which is which score the screenshots with references/critique.md. Record inspector FAIL counts for both.",
"how_to_run": "For each eval, give the prompt to a fresh agent that has this skill, and to a second fresh agent without it (the baseline). Render both with scripts/inspect.mjs and have a judge who does not know which is which score the screenshots with references/critique.md. Record inspector FAIL counts for both. Evals 7 to 10 are app screens: inspect them with --app (a sheet dealt by draw.mjs --app switches it on by itself) and judge them with the app-screen passes in references/critique.md.",
"evals": [
{
"id": 1,
@@ -43,6 +43,34 @@
"prompt": "Landing page for Format, a two-day design conference in Rotterdam on 14 and 15 May 2027. Twelve speakers (invent plausible names and talk titles), 400 seats, tickets at 390 euros, venue: Maassilo. Sections: what it is, the line-up, the schedule in brief, tickets, and a short FAQ. No brand exists yet beyond the name. Static HTML/CSS/JS.",
"expected_output": "An event page with a strong typographic or colour idea, the line-up and schedule set as structured text without divider lines or index numbers, a ticket section that is not three cards, and all house rules met.",
"files": []
},
{
"id": 7,
"name": "app-budget-overview",
"prompt": "Build the main screen of Ledgerly, a budgeting app for couples who share money: the October overview with their accounts and balances, the spending plan (planned against spent by category), bills due in the next 14 days and recent transactions. Two people use it, Mia and Tom. Desktop first, and it must work on a phone. Static HTML/CSS/JS.",
"expected_output": "An app screen, not a landing page: a quiet shell (sidebar or rail), a clear screen title, numbers that add up and are right-aligned with tabular figures, one chart per question, colour spent on data and status only, no icons in tinted squares, no gradients or glows, no placeholder names, and a phone layout where the navigation folds away. inspect.mjs --app passes.",
"files": []
},
{
"id": 8,
"name": "app-deploy-dashboard",
"prompt": "Dashboard for Relay, an edge deployment platform for web teams: one project's overview with recent deployments (status, branch, commit message, author, duration, region), requests and p95 latency over the last 24 hours, the error rate, and usage against the plan's limits. Dark theme. Static HTML/CSS/JS.",
"expected_output": "A dense, readable dark screen on a neutral (not navy) ground: one surface step or one hairline between regions, a deployments table with honest statuses and aligned numbers, charts with few gridlines and one series each, IDs in monospace at most, no rainbow series, no glow, and app inspector checks passing.",
"files": []
},
{
"id": 9,
"name": "app-settings-billing",
"prompt": "Workspace settings for Proofline, a design-review tool: general settings, members and roles (a members table with an invite form), billing (current plan, seats, invoices) and notifications. Static HTML/CSS/JS, desktop and phone.",
"expected_output": "A settings screen with a label column beside its fields (or stacked fields on a phone), labels above inputs, one control height, quiet borders, honest disabled states, a members table with right-aligned figures and one status pill per row, and no decorative icons or gradients.",
"files": []
},
{
"id": 10,
"name": "app-support-queue",
"prompt": "The queue screen of Hearth, a support inbox for a six-person SaaS team: the list of conversations with assignee, priority, channel and time to the SLA, the open conversation with the customer's details beside it, plus filters and saved views. Static HTML/CSS/JS.",
"expected_output": "A three-region work screen (navigation, list, conversation) that stays readable at 1440 and folds to one region at a time on a phone, with status shown once per row, times in words, no tinted icon squares or emoji, and app inspector checks passing.",
"files": []
}
]
}
File diff suppressed because it is too large. Load diff
+193 -1
View File
@@ -890,5 +890,197 @@
"aww-px-push",
"opl-lorenzo-bocchi-photography"
]
},
{
"id": "window-opens-to-the-edges",
"name": "Window that opens to the edges",
"archetype": "pinned-stage",
"directions": [
"pinned-poster",
"place-carries-it",
"hero-object"
],
"needs": [],
"blueprint": "A flat paper or pale ground, and one picture seen through a small window that becomes the whole screen as the page scrolls. The hero is a wrapper 200svh tall holding a stage with `position: sticky; top: 0; height: 100svh`. On the first frame the window is a 4:5 portrait opening 36% of the viewport wide (518×648px), centred, its top and bottom 126px from the edges, square corners; give it a shape from the subject if it has one (an arch, a rounded diamond, a lens). Build it as `clip-path: inset(126px 461px)` on a layer that is itself 100vw by 100svh, so the photograph or muted film inside is laid out full-bleed and never moves: only the opening grows. The headline sits left of the window on columns 1–3: two lines at clamp(44px, 4.4vw, 64px), leading 0.95, flush to the 32px margin, its last baseline level with the window's lower edge. Right of the window on columns 10–12, bottom-aligned to the same line: one supporting sentence at 18px, max 26ch, and the single action as a solid rectangular button. Wordmark top-left and three text links top-right at 15px on the same margin; nothing else sits on the ground. Over the first 100svh of scroll the inset runs to zero on a scroll-linked timeline while the text, which sits above the picture layer, rises and fades out before the opening reaches it; the picture then holds full-bleed for 50svh before the next section slides over it. Without scroll timelines, or under reduced motion, show the open picture with the headline over a neutral scrim in its bottom-left corner.",
"mobile": "The stage stays sticky. The window starts 78vw wide at 4:5, centred, its top at 30% of the height, leaving about a quarter of the screen below it. The headline sits above it at 10vw on two lines inside 20px margins, 72px down; the supporting sentence at 16px and a full-width action sit under the window. The opening reaches the edges within 70svh of scroll. The links fold behind a menu word.",
"headline": "Two short lines of up to about 9 characters each: the name with what it is, or a 2–4 word claim. It scrolls away as the picture takes the screen.",
"motion": "pinned-story",
"seenIn": [
"aww-decathlon-yestalgia",
"aww-leo-parpeix-portfolio-2026",
"aww-revelatio-studio",
"aww-vero-new-york"
]
},
{
"id": "ring-of-frames-around-the-headline",
"name": "Ring of frames around the headline",
"archetype": "mosaic",
"directions": [
"contact-sheet",
"dark-room",
"name-as-monument"
],
"needs": [],
"blueprint": "One near-black or deep flat field. Eighteen to twenty-four photographs or film stills sit on an ellipse centred on the viewport, with radii of 560px across and 310px down, so their centres run from x 160 to 1280 and from y 140 to 760. Frames have square corners and no border, mix 4:3 and 3:4, and grow towards the viewer: about 100px wide at the top of the ring and 150px at the bottom and at both ends, so the ring reads as tilted. Each frame is turned so its base lies along the curve (never more than 30 degrees), and neighbours may overlap by up to a fifth. Inside the ring an empty core about 600×340px holds the text, centred: the headline on two lines at clamp(56px, 5.4vw, 80px), leading 0.95, in the light ink; one supporting sentence at 18px, max 40ch, 24px under it; then the single action as a solid light rectangle. Wordmark top-left and three text links top-right at 15px on 32px margins, outside the ring. Every frame is a link to its project: pointing at one, or focusing it, brings it to full brightness, drops the others to 45% and replaces the supporting sentence with that project's name and year. Place the frames with a few lines of trigonometry (left and top in percentages) and keep them still; the wheel scrolls the page as usual.",
"mobile": "The ellipse becomes a circle 132vw across, centred at half height, so the frames at its left and right ends run off both edges and the ring reads as two arcs, one above and one below the text. Keep twelve frames at 64–88px. The headline sits in the core at 10.5vw on two lines inside 28px margins, with the sentence at 16px and the action at the full width of the core beneath it. Wordmark top-left and a menu word top-right sit above the top arc. A tap on a frame opens its project.",
"headline": "A 3–6 word line on two lines, at most about 14 characters per line, so it fits the empty core.",
"motion": "masked-headline",
"seenIn": [
"aww-cipher",
"aww-squarespace-foundations",
"aww-studio-k95-3",
"aww-kononenko-architectural-bureau"
]
},
{
"id": "navigation-on-the-midline",
"name": "Navigation on the midline",
"archetype": "fullbleed-media",
"directions": [
"place-carries-it",
"quiet-instrument",
"dark-room"
],
"needs": [],
"blueprint": "One photograph or muted film fills the viewport at 100vw by 100svh, `object-fit: cover`, framed so the middle of the screen cuts the subject: a face or a skyline in the upper half, hands or ground in the lower. There is no headline block and nothing in the top or bottom bands. One rail 64px tall runs across the exact vertical centre (its top at 418px) and carries everything on one baseline in the paper colour: the `h1` on the 24px left margin, the name with what it is in two to five words at clamp(32px, 2.6vw, 40px) in medium weight, doing the job of the wordmark; from column 6, one supporting sentence at 18px, max 44ch; at the right margin, two text links at 15px and the single action as a small solid button in the paper colour. If the frame is light behind the rail, give the rail alone a neutral dark scrim at 35%. The rail is `position: sticky; top: 0` in the hero's flow, so as the page scrolls it rises with the content and docks as the header bar without changing size or layout, taking the page ground once the pictures have passed. A second full-bleed photograph, a closer detail of the same subject, follows directly beneath, so the rail is seen crossing two pictures before it reaches the top. On a black ground with one photographed object instead of a film, stand the object on the rail at about 17% of the width, between the sentence and the links, so the line seems to pass behind it.",
"mobile": "A portrait crop keeps the subject's middle on the screen's middle. The rail stays on the vertical centre, as tall as the `h1` needs at 28px on up to two lines, with the `h1` on the left and Menu on the right. The sentence leaves the rail and sits at 17px in the bottom-left on a 20px margin with the action as a full-width button under it, over a neutral scrim limited to that corner. The rail docks at the top as on desktop.",
"headline": "The `h1` on the rail: the name with what it is, two to five words at 32–40px on one line, and one sentence of up to 50 characters beside it; the picture does the rest.",
"motion": "slow-crossfade",
"seenIn": [
"aww-odyssee",
"aww-milledollars",
"aww-twks-1"
]
},
{
"id": "title-in-the-sky-line-on-the-horizon",
"name": "Title in the sky, line on the horizon",
"archetype": "fullbleed-media",
"directions": [
"place-carries-it",
"hero-object",
"sentence-as-layout"
],
"needs": [],
"blueprint": "One wide photograph or render fills the viewport, chosen with the subject in the lower 55% and open sky, fog or a plain wall above it. The headline is centred in that empty upper zone: two lines at clamp(72px, 7.2vw, 112px), leading 0.9, its cap line 72px from the top, the first line short and the second longer so the block widens towards the subject. On the horizon, with its baseline at 52% of the height, one supporting sentence is split into two halves at 22px: the first half right-aligned to end at column 5, the second left-aligned from column 8, so the subject shows through the 260px gap between them. Wordmark top-left on a 32px margin; up to three text links from column 9; the single action top-right as a solid rectangle in the paper colour. All type is in one ink chosen against the sky; on a pale sky use the photograph's darkest tone rather than black. Nothing else crosses the lower half.",
"mobile": "The picture is re-cropped to portrait with the subject in the lower 45%. The headline sits centred in the sky at 11vw on two lines, 96px down, and the two halves of the sentence become two centred lines at 17px directly under it. The action is a full-width button 24px above the bottom edge on a neutral scrim band 96px tall. Wordmark top-left and a menu word top-right.",
"headline": "Two lines, the second the longer, at most about 14 characters per line: the name of the place, or a 3–5 word claim about it.",
"motion": "shutter-image",
"seenIn": [
"aww-era-residence",
"aww-vectr"
]
},
{
"id": "name-across-the-middle",
"name": "Name across the middle",
"archetype": "giant-wordmark",
"directions": [
"name-as-monument",
"type-only-statement",
"loud-flat-poster"
],
"needs": [],
"blueprint": "Paper and ink only; no picture on the first screen. The name runs on one line across the vertical centre, sized to span from the 18px left margin to the 18px right margin (97.5% of the width). Set it as inline SVG text with a `viewBox` fitted to the word and `width: 100%`, or tune its vw size to the letter count, so it never wraps. In a heavy grotesk the letters stand about 280px tall, from about 310px to 590px; nothing touches or overlaps them. The four corners carry everything else on 18px margins. Top-left: one supporting sentence at 18px on two lines, max 36ch, and 16px below it the single action as a solid ink rectangle. Top-right: a menu word or three text links at 15px. Bottom-left, on a baseline 24px above the edge: the city or the kind of work at 16px. Bottom-right on the same baseline: two or three text links at 15px. The ground between the corners and the name, about 230px above and below it, stays empty and is closed on both sides by the corner texts. The name returns in the footer at the same width with ground and ink swapped and a two-line invitation above it, so the page closes like a bracket.",
"mobile": "The name stays on one line across the width inside 16px margins and moves up to 22% of the height. The supporting sentence follows on the left margin at 17px, then the action at full width. The lower half holds the first picture of the work as a 4:5 frame cropped by the bottom edge, so the screen does not end in empty paper. A menu word sits top-right.",
"headline": "The name itself on one line, up to about 12 characters; the 18px sentence in the top-left corner says what it is.",
"motion": "scaling-wordmark",
"seenIn": [
"aww-nothin",
"aww-produx-design"
]
},
{
"id": "square-over-a-moving-line",
"name": "Square over a moving line",
"archetype": "type-poster",
"directions": [
"name-as-monument",
"place-carries-it",
"loud-flat-poster"
],
"needs": [],
"blueprint": "A white or pale flat ground. One line of display type at clamp(150px, 15vw, 230px), weight 500, tracking -0.02em, runs edge to edge with its cap band centred 360px from the top, moving left at a slow, constant 40px a second: the name, or a claim of two or three words, repeated with a solid round dot one third of the cap height between copies and duplicated for a seamless loop. One square photograph 272px across (19vw), square corners, `object-fit: cover`, sits exactly on the band's centre behind the type, so the words slide across it; the moving line is set twice, the second copy in the paper colour and clipped to the square with `clip-path`, so the letters turn light as they cross the photograph and every word reads whole. It shows the subject's best detail. In the study the photograph sits on top of the words; here nothing lies on top of a headline. 150px under the photograph, centred: one supporting sentence at 22px on two lines, max 36ch, and 24px below it the key fact (a price, a date, an address) at 17px. Top row on 32px margins: one text link on the left, the wordmark centred, one text link on the right. Bottom row on the same margins, 40px above the edge: a location line at 16px on the left and the single action as a solid rectangle on the right. The `h1` holds the moving words once as plain text and the moving copies are `aria-hidden`. Under reduced motion the line stops with one copy centred and both ends cropped.",
"mobile": "The line drops to 34vw with its band at 26% of the height and keeps moving; the photograph shrinks to a 46vw square and stays centred on it. The sentence sits centred at 18px about 64px below the photograph, then the fact at 16px. The action becomes a full-width button 20px above the bottom edge. The wordmark stays centred at the top and the two links fold into a menu word on the right.",
"headline": "The name or a 2–3 word claim of at most 14 characters, set once in the `h1` and repeated in the moving line.",
"motion": "edge-marquee",
"seenIn": [
"aww-realevate"
]
},
{
"id": "two-doors-on-a-dark-wall",
"name": "Two doors on a dark wall",
"archetype": "split",
"directions": [
"dark-room",
"quiet-instrument"
],
"needs": [],
"blueprint": "For a subject with two halves a visitor must choose between: two exhibitions, two audiences, two places, two collections. One near-black flat field. Two portrait photographs at 3:4, each 280×373px, stand side by side 80px apart, so the pair spans x 400–1040 (44% of the width) with their tops at 300px; square corners, no frame, the pictures supplying every colour on the screen. Each photograph is the link to its half, and directly beneath it, left-aligned to the frame, sit its name at 22px in the light ink and one line at 16px in the soft ink. The headline is centred above the pair: two lines at clamp(36px, 3.4vw, 52px), leading 1.05, its first line 104px from the top, at most 26 characters per line. Wordmark top-left and one text link top-right at 15px on 32px margins. About 150px of dark ground stays below the pair and 400px either side of it, closed by the two doors at the centre. Pointing at or focusing one door keeps it at full strength and dims the other to 50%. The page continues below with the two halves in order, so each door is also an anchor to its half.",
"mobile": "The pair stays side by side: each photograph 168px wide at 3:4 with a 14px gap, centred at 46% of the height, its name at 17px and its line at 16px beneath it. The headline sits above at 8vw on two lines, centred, 64px down. The wordmark and the link stay in the top corners.",
"headline": "A 4–8 word sentence on two lines that names the choice, at most 26 characters per line.",
"motion": "stillness",
"seenIn": [
"aww-hearst-exhibit-2026"
]
},
{
"id": "title-from-top-to-bottom-edge",
"name": "Title from the top edge to the bottom edge",
"archetype": "bottom-anchored-type",
"directions": [
"place-carries-it",
"name-as-monument",
"dark-room"
],
"needs": [],
"blueprint": "One full-bleed photograph or film at 100vw by 100svh, with a horizon near half height and empty sky or darkness above. The headline is two lines in a heavy face at clamp(140px, 13vw, 200px), leading 0.85, pulled as far apart as the screen allows: line one flush to the 32px left margin with its cap line 72px from the top; line two indented 28vw (403px) and anchored to the bottom, its baseline 24px above the lower edge, running towards the right margin. Lay the hero out as a grid of three rows (`grid-template-rows: auto 1fr auto`): line one in the first row, line two in the third, both spans of one `h1` with `display: contents`. The middle row leaves the picture clear apart from one supporting sentence at 18px, max 44ch, crossing the horizon at 48% of the height from 30vw, and the single action directly beneath it as a solid rectangular button with its label at 18px. The wordmark and two text links sit top-right on the 32px margin at 15px; the top-left belongs to line one. Choose a picture whose upper-left and lower-right are calm enough for the type, or lay a neutral scrim under those two corners only.",
"mobile": "Keep the two lines at 19vw: line one flush left on a 16px margin 88px down, line two right-aligned to the 16px margin with its baseline 20px above the bottom. The sentence at 17px and the action button sit between them on the left margin, at 46% of the height. The picture is re-cropped to portrait around the horizon.",
"headline": "Two words or short phrases, one per line, at most about 8 characters each.",
"motion": "masked-headline",
"seenIn": [
"aww-the-state-of-the-gallery"
]
},
{
"id": "line-up-on-an-arc",
"name": "Line-up on an arc",
"archetype": "object-centre",
"directions": [
"specimen-catalogue",
"hero-object",
"dark-room"
],
"needs": [
"product-photo"
],
"blueprint": "For a range: flavours, colourways, editions, sizes. One deep flat ground, which may take the current item's own colour. The current item, a cut-out photograph, stands at the exact centre about 240px wide and 450px tall (y 210–660), tilted no more than 8 degrees. Its siblings stand behind it on a shallow arc, alternately a little higher and lower: each step out is 30% smaller, a quarter darker (`filter: brightness()`) and about 260px further from the centre, three on each side, so the outermost pair is cut by the left and right edges. Under the current item, centred: its name at clamp(32px, 2.8vw, 44px) and one fact at 17px (what it tastes of, what it is made of, the price). The headline sits top-left on columns 1–4 at clamp(36px, 3.2vw, 48px) on two lines with the supporting sentence at 17px under it; the single action, a solid rectangle, sits top-right on a 32px margin. The arc is a horizontal scroller with `scroll-snap-type: x mandatory`, each item snapping to the centre, its scale and brightness set from its distance to the centre, so a drag, a swipe, the arrow keys or a click on a sibling brings it forward; the ground colour follows with a 300ms transition. No arrows drawn on the screen and no counter.",
"mobile": "The current item takes 55% of the width and half the height, centred at 46% of the height; one sibling peeks in at each edge at 60% scale, half cut off. The headline sits at the top at 8vw on two lines with the supporting sentence beneath; the item's name and fact sit under the item at 24px and 16px. The action becomes a full-width button 20px above the bottom edge.",
"headline": "A plain 3–6 word line on two lines, at most 20 characters per line; the item's own name under the object changes with the selection.",
"motion": "drag-row",
"seenIn": [
"aww-ciao-energy-launch-website"
]
},
{
"id": "view-with-two-cards-in-the-corner",
"name": "View with two cards in the corner",
"archetype": "fullbleed-media",
"directions": [
"place-carries-it",
"hero-object",
"editorial-report"
],
"needs": [],
"blueprint": "One photograph or muted film fills the viewport, `object-fit: cover`, with the subject in the right half. The headline sits on the left at mid-height: two lines at clamp(44px, 3.9vw, 60px), bold, leading 1.08, flush to the 32px margin on columns 1–5, the block vertically centred; 20px below it, one supporting sentence at 18px, max 40ch, in the paper colour. In the bottom-right corner, 32px from the right and bottom edges, two cards sit side by side with a 16px gap, each two columns wide (about 210px) at 3:2: a photograph of the newest thing (a project, an edition, an article) with a paper-colour bar 40px tall fused to its lower edge, holding its title at 16px on the left and a small arrow on the right. They are the next steps after the single action, which is a solid rectangle top-right beside a menu word. One short fact at 16px in the paper colour may be pinned onto the subject itself, beside a solid 8px dot on the point it describes, with no line joining them. Wordmark top-left on the 32px margin. If the left half is light, lay a neutral dark scrim under columns 1–6 only.",
"mobile": "The picture is re-cropped to portrait with the subject in the upper half. The headline sits at 9vw on two lines on the 16px margin at 44% of the height, with the sentence at 16px beneath it. The two cards sit side by side across the full width at the bottom, 16px from the edges, each about 171px wide at 3:2 with its title bar. Wordmark top-left; the action and a menu word top-right.",
"headline": "A plain two-line claim of 4–7 words, at most about 17 characters per line at 3.9vw.",
"motion": "slow-crossfade",
"seenIn": [
"aww-cominvi",
"aww-no-art"
]
}
]
]
+38 -3
View File
@@ -17,12 +17,12 @@
{
"id": "pinned-story",
"name": "Pinned story",
"description": "One element holds its place with `position: sticky` (a title, a frame, a product fragment) while the items that belong to it scroll past or swap inside it. The page's ordinary scrolling does the work; nothing is hijacked. Keep the pinned run under six screens. Recipe: 'Pinned story' in references/motion.md."
"description": "One element holds its place with `position: sticky` (a title, a frame, a product fragment) while the items that belong to it scroll past or swap inside it. The page's ordinary scrolling does the work; nothing is hijacked. Hold it for about one screen per step and three screens at most, two held stages per page at most, with a line of text on every held screen. Recipe: 'Pinned story' in references/motion.md."
},
{
"id": "index-preview",
"name": "Index with preview",
"description": "Rows of text are the navigation through the work; pointing at a row brings up its image in one fixed frame and dims the other rows. On touch screens each row shows its image inline instead. Fast and exact (about 240ms crossfade), no lag behind the pointer. Recipe: 'List with image preview' in references/motion.md."
"description": "Rows of text are the navigation through the work; pointing at a row brings up its image in one fixed frame and dims the other rows. On touch screens each row shows its image inline instead. Fast and exact (about 240ms crossfade), no lag behind the pointer. Recipe: 'Index with preview, anchored to the row' in references/motion.md."
},
{
"id": "curtain-footer",
@@ -53,5 +53,40 @@
"id": "stillness",
"name": "Stillness",
"description": "No showpiece. The page is simply there, instantly, and the discipline is the statement. Motion is limited to exact feedback: a 140ms press on the button, a 180ms colour change on links, focus rings that appear without delay. Do not add a load animation or scroll reveals to this direction."
},
{
"id": "opening-window",
"name": "Opening window",
"description": "A type screen hands over to the one great photograph: the picture first shows as a window about 45% of the width in the middle of a held stage and opens to full-bleed over about one screen of scroll, while the image inside settles from 1.15 scale. Scroll-linked and linear, so the reader sets the pace; it is fully open after three quarters of the hold and rests a moment before the page moves on. Use it once, for the picture that carries the subject. Where scroll timelines are unsupported, or under reduced motion, the picture is simply a full-bleed screen. Recipe: 'Opening window' in references/motion.md."
},
{
"id": "ink-fill",
"name": "Ink-fill statement",
"description": "One statement of three to five lines at 32px or more starts at 45% ink and fills to full ink line by line as it rises from three quarters of the way down the screen to just past half way, so the reading pace follows the scroll. Every word is readable before it fills; the fill only sets the rhythm. Once per page, never on body copy, never on a headline the visitor needs at once. Full ink without scroll timelines or under reduced motion. Recipe: 'Ink fill' in references/motion.md."
},
{
"id": "sheet-over",
"name": "Sheet over a held picture",
"description": "A full-bleed photograph holds its place for one screen and the next chapter, an opaque sheet in the page ground carrying its heading at the top edge, slides up over it like a page being turned. The chapter change is read, not just seen, and nothing is hijacked: the photograph is a 100svh figure with position: sticky; top: 0, followed by a section with its own background and a higher stacking order. Hold for one screen at most, use it at one or two chapter changes, and give each held photograph a caption so the held screen carries a line of text."
},
{
"id": "sideways-gallery",
"name": "Sideways gallery",
"description": "A short set of pictures (eight or fewer: rooms, kitchens, pieces) moves sideways while the vertical scroll holds the stage, one pixel of scroll for one pixel of travel, and the hold ends the moment the last picture reaches the margin. Only on wide screens with motion allowed and only when the row is under three screens long; phones, reduced motion and longer rows get the native drag row with snapping. Focusing a link scrolls its picture into view, and the only copy of a call to action never sits inside the moving row. Recipe: 'Sideways gallery' in references/motion.md."
},
{
"id": "pushed-line",
"name": "Pushed line",
"description": "One line of very large type, the name or the claim, is pushed sideways by the scroll rather than by a clock: it travels from 12% right to 12% left of its place while it crosses the screen, linear and locked to the scrollbar, and reads whole at the middle of its run. One per page, never across running text, and the words must never be cropped at the moment the line sits in the middle of the screen. Build with animation-timeline: view() on the line and a translateX keyframe; static where scroll timelines are unsupported and under reduced motion."
},
{
"id": "rolling-figures",
"name": "Rolling figures",
"description": "For a page whose case is a handful of numbers (a report, a fund, a practice with a founding year): three or four figures at display size roll each digit up to its value once as they come into view, about 1.1 seconds on a strong ease-out, the columns 70ms apart. Tabular figures, the real number kept in the text for screen readers and search, never in running text and never for a value that changes after load. Set at its value under reduced motion. Recipe: 'Rolling figures' in references/motion.md."
},
{
"id": "carried-picture",
"name": "Carried picture",
"description": "Between the index and a case page, the picture the visitor clicked travels into place as the new page's hero while the old page fades out in 200ms and the new one rises in, all within 480ms, using the browser's own cross-document view transitions with no library and no full-screen curtain. Give the card picture and the hero the same view-transition-name; each name appears once per page. Browsers without view transitions simply navigate; reduced motion turns it off. Recipe: 'Page to page' in references/motion.md."
}
]
]
+294 -1
View File
@@ -4132,5 +4132,298 @@
"opl-near",
"opl-midnight-2"
]
},
{
"id": "work-names-pass-a-held-square",
"role": "work",
"name": "Names passing a held square",
"shape": "index",
"tags": [
"type-led",
"photo-led",
"poster"
],
"directions": [
"name-as-monument",
"loud-flat-poster",
"pinned-poster",
"specimen-catalogue"
],
"needs": [],
"blueprint": "A flat ground and one centred column of very large names: eight to fourteen project or product names in condensed capitals at clamp(96px, 14vw, 200px), leading 0.85, one per line and each line centred, so long names run nearly to the margins and short ones leave wide ground either side. A square frame 450px across (31vw), square corners, holds at the centre of the viewport behind the list: a sticky element (`position: sticky; top: calc(50svh - 225px)`) at the head of the list with a negative bottom margin equal to its height, centred, below the names in the stacking order, so the passing names cross the picture and are never hidden by it. Choose pictures with a calm centre, or lay a flat 30% scrim inside the frame, so the crossing name keeps 3:1. In the study the picture sits on top of the names; here nothing lies on top of large type. It shows the picture of whichever name is crossing its centre (an IntersectionObserver with a thin root margin at the viewport's middle sets the current name) and swaps with a 200ms crossfade. Level with the frame's middle, 48px to its left and right, two facts about the current item sit at 16px, the kind of work on the left and the year or price on the right, changing with it. The heading opens the list as its first line, at the same size in the soft ink, two words at most. Every name is a link. The section runs about three screens, so each name holds the centre for about a quarter of a screen.",
"mobile": "Names drop to 15vw and may wrap to two lines, still centred. The frame shrinks to a 60vw square and stays sticky at the centre; the two facts move into one row at 16px directly under it. The heading line stays first.",
"seenIn": [
"aww-decathlon-yestalgia"
]
},
{
"id": "work-title-torn-at-the-seam",
"role": "work",
"name": "Title torn at the seam",
"shape": "pinned",
"tags": [
"photo-led",
"poster"
],
"directions": [
"split-stage",
"pinned-poster",
"dark-room",
"name-as-monument"
],
"needs": [],
"blueprint": "A wrapper about 95svh tall per project holds a sticky stage of 100svh, split at exactly 50vw into two portrait panes of 720×900px, `object-fit: cover`, square corners, no gap: two different pictures or loops of the same project (the whole and a detail, before and after). The project's title sits on the seam, centred on x 720 at the optical middle: capitals in a heavy face at clamp(96px, 11vw, 160px), leading 0.8, tracking -0.04em, at most two lines, in one light colour chosen per project. It is set twice inside the one heading element, the second copy `aria-hidden`, each copy clipped to its own half with `clip-path: inset(0 50% 0 0)` and `inset(0 0 0 50%)`. Directly under it, centred, the discipline and year at 16px and a text link with an arrow at 16px. While a project crosses the stage, the left pane and left half-title travel up and the right pane and right half-title travel down (panes ±20svh, title ±30svh, on a scroll-linked timeline), so the name tears at the seam and lines up exactly when the project is centred. The section's two-word heading sits at clamp(28px, 2.4vw, 36px) in the stage's top-left corner, 32px in. Four to eight projects; the stage releases after the last. Under reduced motion the halves stay aligned and the projects change by crossfade.",
"mobile": "Keep the split: two strips 195px wide running the full height of the screen with the same opposing travel. The title drops to 12.5vw on the seam, at most two lines, with the discipline line and the link under it at 16px; give the link a 44px hit area.",
"seenIn": [
"aww-glitch-grit"
]
},
{
"id": "work-one-slide-two-neighbours",
"role": "work",
"name": "One slide, two neighbours",
"shape": "strip",
"tags": [
"photo-led",
"quiet"
],
"directions": [
"editorial-report",
"contact-sheet",
"place-carries-it",
"product-performs"
],
"needs": [],
"blueprint": "Each project is one band the full width of the viewport. In its middle stands one slide 73% of the width (1046×600px, x 197–1243), square corners, `object-fit: cover`. The previous and next pictures of the same project peek in at the edges, 177px of each visible, with 20px gaps, so the row reads as a filmstrip cut by the viewport. The strip scrolls sideways with `overflow-x: auto` and `scroll-snap-type: x mandatory`, each picture snapping to the centre, the scrollbar hidden. 26px under the strip runs one row at 16px on the 12-column grid with 20px margins, every item on a fixed column start: the project's name in the full ink at column 1 with its category in the soft ink after it, the recognition it received at column 4, the year at column 7, the team or client at column 10. A text link with an arrow sits 24px under the name. 240px of ground separates one project from the next, and all projects share one photographic treatment so the run reads as a set. The section's heading, two or three words at clamp(40px, 4vw, 60px), sits on the left margin 96px above the first strip.",
"mobile": "The slide is 78% of the width at 4:5, with 32px of each neighbour showing and 10px gaps, still snapping. The row of facts becomes two columns, name and category on the left, year and team from 50% of the width; the link drops under the name. 64px between projects.",
"seenIn": [
"aww-leo-parpeix-portfolio-2026"
]
},
{
"id": "showcase-cards-cross-a-held-photograph",
"role": "showcase",
"name": "Cards crossing a held photograph",
"shape": "carousel",
"tags": [
"photo-led",
"colour-field"
],
"directions": [
"place-carries-it",
"pinned-poster",
"colour-chapters",
"dark-room"
],
"needs": [],
"blueprint": "A wrapper about 350svh tall holds a sticky stage of 100svh. On the stage one full-bleed photograph of the subject's ground (a landscape, a material, an aerial texture) stays still, darkened evenly with `filter: brightness(0.6)`. Over it a track of four to six cards travels from right to left as the page scrolls (a `translateX` tied to the wrapper's scroll progress), so one card is always near the centre and the next shows about 300px at the right edge. Each card is 720×560px, square corners, vertically centred with 170px of photograph above and below it: its top 68% is a photograph of one item (a room, a building, a stop on the route), its lower 32% a flat panel in one earth tone from the palette, a different tone per card at a similar lightness, holding the item's name at clamp(32px, 3vw, 48px) bottom-left with 28px padding and a two-line description at 16px right-aligned beside it. Cards are 40px apart. The section heading, two to four words at clamp(40px, 4.4vw, 68px) in the paper colour, sits top-left on the stage over the photograph before the first card arrives and moves out ahead of it. Keep the run under three screens; at its end the stage releases into the next section.",
"mobile": "No pin and no sideways travel. The photograph runs full-bleed as a 4:5 band under the heading at 9vw, and the cards follow it at full width minus 16px margins, 24px apart, each a 4:3 photograph over its colour panel 140px tall, with the name at 26px and the description at 16px.",
"seenIn": [
"aww-lpas",
"aww-white-desert"
]
},
{
"id": "list-object-rides-the-schedule",
"role": "list",
"name": "Object riding down the schedule",
"shape": "table",
"tags": [
"object-led",
"type-led",
"dense"
],
"directions": [
"hero-object",
"borrowed-category",
"loud-flat-poster",
"dark-room",
"specimen-catalogue"
],
"needs": [
"product-photo"
],
"blueprint": "A schedule of dated events (races, concerts, tastings, departures) with the subject's own object travelling down it. On a dark flat ground, on the page's own margins, the heading is one centred line of condensed capitals at clamp(40px, 3.6vw, 56px), split into its two words with a gap between them, and in the gap stands a cut-out photograph of the object seen from above or in profile, about 320px wide (22% of the width) and up to 64% of the viewport tall. Below come full-width rows 84px tall, each a two-part grid: the event's name at 26px in condensed capitals on the left with a one-line note at 16px in the soft ink under it, and the date and place right-aligned at 16px on the note's baseline. Rows alternate between the ground and a tone one step lighter instead of rules. As the page scrolls, the object leaves the heading and moves down the centre line over the rows (`position: sticky` in a centred column that spans the list, `pointer-events: none`), where it covers only the empty middle of each row and marks how far the reader has come. The row for the next event takes the accent colour across its whole band, with its name and date in the accent's ink. About ten rows fit on one screen; the list ends 120px under the last row.",
"mobile": "Rows stack at full width on 20px margins: the name at 22px on up to two lines, the note at 16px, the date under the note. The object shrinks to 40% of the width and stays in the heading; it does not travel, because on a phone it would cover the rows. The accent band stays on the next event.",
"seenIn": [
"aww-nick-ho"
]
},
{
"id": "gallery-rising-triptych",
"role": "gallery",
"name": "Rising triptych",
"shape": "pinned",
"tags": [
"photo-led",
"quiet"
],
"directions": [
"place-carries-it",
"uneven-photographs",
"pinned-poster",
"editorial-report"
],
"needs": [],
"blueprint": "A wrapper about 200svh tall holds a sticky stage of 100svh on the plain page ground. The heading, two lines at clamp(32px, 2.8vw, 44px), is centred in the top fifth of the stage with one sentence at 17px under it. From the bottom edge three photographs rise as the page scrolls, each at its own speed. The centre one, a 4:5 portrait, starts 620px wide with only its top quarter showing and grows (scale and travel on a scroll-linked timeline) until it is about 760px wide with its top 40px below the sentence, cropped by the bottom edge, so it never reaches the heading. In the study it slides over the heading; here nothing lies on top of a heading. The two outer ones, each 360×530px, stand beside it with 10px gaps, their tops always 55px lower than the centre's, and by the end they are cut by the left and right viewport edges. Square corners, no captions, one consistent light across all three (warm interiors with warm, cold coast with cold). Under reduced motion show the end state with the heading above the three photographs.",
"mobile": "The centre portrait starts at 60% of the width and grows to nearly the full height; the outer two show as 40px slivers at the screen edges. The heading and sentence sit in the top third above it.",
"seenIn": [
"aww-sobha-privy-collection"
]
},
{
"id": "showcase-portrait-on-its-own-ground",
"role": "showcase",
"name": "Portrait on its own ground",
"shape": "full-bleed",
"tags": [
"photo-led",
"colour-field"
],
"directions": [
"colour-chapters",
"dark-room",
"hero-object",
"editorial-report"
],
"needs": [],
"blueprint": "One chapter per screen, three to six chapters. Each is a full-bleed field 100svh tall whose flat colour is taken from its own photograph: the darkest large tone in the picture (a deep brown for a wooden interior, a slate for a winter coast), so picture and page seem to be one surface. In the centre stands one portrait photograph at 3:4, 506×675px (75% of the height), top 112px, square corners, no frame. Type sits in the corners on 32px margins in the light ink: the chapter's title at clamp(28px, 2.4vw, 36px) top-left as its heading; one sentence at 17px, max 32ch, bottom-left; a text link to the chapter's page bottom-right. As each chapter arrives its field starts as a panel inset 20px from the viewport edges and grows to full-bleed, while the portrait scales from 0.92 to 1 (background colour over 600ms, scale over 800ms, both easing out), so the change reads as the room changing, not as a new slide. A chapter may hold a short muted loop or a single enlarged letterform in place of the portrait, on the same kind of field. The first chapter's title doubles as the section heading.",
"mobile": "The portrait becomes 80% of the width at 3:4, centred at 45% of the height. The title sits top-left at 24px, and the sentence and link stack bottom-left at 16px under the portrait. The field still takes the picture's darkest tone.",
"seenIn": [
"aww-squarespace-foundations"
]
},
{
"id": "cta-question-at-both-edges",
"role": "cta",
"name": "Question at both edges",
"shape": "statement",
"tags": [
"type-led",
"poster"
],
"directions": [
"type-only-statement",
"sentence-as-layout",
"dark-room",
"loud-flat-poster"
],
"needs": [],
"blueprint": "For a closing choice between two answers: book or ask, this date or that, the tasting or the tour. A flat field exactly 100svh tall in the darkest tone. The question is a two-line heading at clamp(110px, 11vw, 168px), leading 1.0, centred, about 54% of the width: line one with its cap line 32px under the field's top edge, line two with its baseline 24px above the bottom edge. The band of about 560px between them is empty except for one row at its optical centre: two solid rectangular buttons, each about 200×56px with its label at 18px in medium weight, their inner edges 120px either side of the centre axis, and between them, exactly on the axis, the word 'or' at 18px in the soft ink. 16px under the row, centred, one line at 16px in the soft ink says what happens after either choice. The type above and below closes the empty band, so it reads as held breath rather than a gap.",
"mobile": "The question drops to 18vw, still two centred lines, line one 32px from the top and line two 24px above the bottom. The buttons stack at the centre at 72% of the width, 12px apart, with 'or' between them and the line of explanation under the second.",
"seenIn": []
},
{
"id": "intro-headline-crossing-onto-a-photograph",
"role": "intro",
"name": "Headline crossing onto a photograph",
"shape": "split",
"tags": [
"type-led",
"photo-led"
],
"directions": [
"sentence-as-layout",
"editorial-report",
"place-carries-it",
"uneven-photographs"
],
"needs": [],
"blueprint": "A white or pale ground. A photograph taller than it is wide, 720px wide (columns 7–12), sits flush against the right viewport edge with its top at 46% of the section's first screen and runs below the fold. The heading starts on the 32px left margin across columns 1–8: two lines of light-weight serif capitals at clamp(64px, 5.6vw, 88px), leading 1.04, the first line starting 34% down the screen and the second placed so that its right half runs onto the photograph's upper-left corner. Choose a picture whose upper-left quarter is pale enough for the ink, or set the second line twice, in the ink and in the paper colour, each copy clipped to its side of the photograph's left edge. Under the heading on columns 1–5, one paragraph of up to 25 words at 22px, leading 1.4. At the bottom-left a second, smaller photograph of 360×400px enters, cropped by the screen's lower edge, and on the next screen becomes the left half of a staggered pair with the large one. Nothing else is on the screen.",
"mobile": "The heading sits at 10vw on two lines on the 20px margin. The photograph becomes 75% of the width, flush right, and starts under the heading's first line so the second line crosses its top-left corner. The paragraph follows at 17px, then the small photograph at 50% of the width on the left margin.",
"seenIn": [
"aww-sobha-privy-collection"
]
},
{
"id": "features-four-cards-dealt-into-a-fan",
"role": "features",
"name": "Four cards dealt into a fan",
"shape": "pinned",
"tags": [
"colour-field",
"poster"
],
"directions": [
"pinned-poster",
"loud-flat-poster",
"borrowed-category",
"colour-chapters",
"specimen-catalogue"
],
"needs": [],
"blueprint": "Four values, rules or features, each on a card dealt onto the table as the page scrolls. A wrapper about 400svh tall holds a sticky stage of 100svh on the page's darkest flat ground. Each card is a portrait panel of 340×400px with a 20px radius, filled with its own palette colour (four colours that differ clearly in lightness), no border, no shadow. On a card, with 28px padding: its name at 32px in heavy capitals on one or two lines at the top, a photograph or a single picture of the thing at 60% of the card's width in the middle, and one sentence at 16px at the foot. Card one lands alone at the centre; each further quarter of the wrapper deals the next from below the bottom edge, landing 250px to the right of the one before, rotated between -4 and 4 degrees and overlapping only its right 90px, until the four form a fan about 1,090px wide centred on the stage. Keep each card's name, picture and sentence inside its left 240px, so no card ever covers another's words. In the study the cards overlap by two thirds and bury the earlier titles; here nothing lies on top of text. The heading, two to four words at clamp(40px, 4vw, 60px), sits top-left on the stage on the 32px margin with one sentence at 17px under it. The stage releases after the fourth card has settled; under reduced motion the fan is simply there.",
"mobile": "No pin. The cards sit in one row that scrolls sideways with scroll-snap, each 82% of the width with the next peeking in at the right edge, all upright. The heading and sentence sit above the row.",
"seenIn": [
"aww-made-with-gsap-1"
]
},
{
"id": "work-small-index-large-picture",
"role": "work",
"name": "Small index, large picture",
"shape": "pinned",
"tags": [
"photo-led",
"type-led"
],
"directions": [
"split-stage",
"pinned-poster",
"editorial-report",
"product-performs"
],
"needs": [],
"blueprint": "A sticky stage of 100svh split 38/62. The right 62%, from x 560 to the right viewport edge, is one photograph the full height of the stage, bleeding off the right, top and bottom edges, square corners: a staged picture of the current project in use, in a real place. The left column, from the 20px margin to x 520, holds the heading at the top, two lines at clamp(80px, 8.2vw, 120px), leading 0.95, 40px under the navigation; then empty ground; then, anchored to the bottom with a 32px margin, the index: three to six rows 44px tall, each with the project's name at 28px on the left and two or three disciplines at 16px right-aligned in the soft ink. The current row is in the full ink and the others at 40% opacity; rows are told apart by space only. Each project gets one viewport of scroll: as it arrives, its photograph wipes in from the bottom edge (`clip-path: inset(100% 0 0 0)` to `inset(0)` over 900ms) and the current row moves down the list. The empty middle of the left column is closed by the heading above it and the index below.",
"mobile": "The heading sits at the top at 14vw on two lines, then the photograph at full width at 4:5, sticky under the heading while the index rows below it change the picture; or let each project simply stack as photograph and row.",
"seenIn": [
"aww-neutomni"
]
},
{
"id": "list-cards-fold-into-spines",
"role": "list",
"name": "Cards that fold into spines",
"shape": "pinned",
"tags": [
"type-led",
"colour-field",
"dense"
],
"directions": [
"editorial-report",
"colour-chapters",
"page-is-the-object",
"dense-tiles"
],
"needs": [],
"blueprint": "For a journal, a run of notes or a list of chapters without pictures. A wrapper about 400svh tall holds a sticky stage of 100svh. The heading, two to four words at clamp(48px, 4.2vw, 64px), sits top-left on the 32px margin, 120px from the top. Under it a band 360px tall spans the content width and holds a horizontal deck of text cards, each about 620px wide, in three neighbouring flat tones of one hue taken in turn. On a card, with 32px padding: its title at 30px on up to two lines, leading 1.1; an excerpt of two lines at 16px; and along the bottom edge the date on the left and a text link to read it on the right, both at 16px. As the page scrolls, each step slides the next card in from the right edge and squeezes the earlier ones into slivers 120px wide stacked edge to edge at the left, like book spines, where only the first letters of their titles show, clipped by the sliver. Clicking a sliver brings its card back. 48px under the band, the number of entries and a link to the full list sit at 17px on the left margin, so the stage does not end on an empty floor.",
"mobile": "No pin and no slivers. The cards stack at full width with 12px gaps, each in its tone, the title at 24px, the excerpt at 16px, the date and link at the foot. The heading sits above them at 10vw.",
"seenIn": [
"aww-noho"
]
},
{
"id": "gallery-photographs-around-a-heading",
"role": "gallery",
"name": "Photographs gathered round a heading",
"shape": "mosaic",
"tags": [
"photo-led",
"quiet"
],
"directions": [
"uneven-photographs",
"place-carries-it",
"quiet-instrument",
"dark-room"
],
"needs": [],
"blueprint": "A band about one screen tall on the plain page ground, used as the page's one deliberate rest. At its visual centre sits the heading, two lines at clamp(32px, 2.6vw, 40px) in a light weight, centred, with one sentence at 17px under it and a text link beneath that. Around it six to eight small photographs are scattered irregularly across the middle columns (x 320–1120; the outer 320px on each side stay empty, equally), each 160–200px wide at about 9:10, square corners, at different heights with 60–200px of air between them. Two of them come within 40px of the heading's block, which their drift never reaches, so the cluster reads as gathered round it rather than laid on a grid. No captions and no frames. Each photograph drifts at its own rate as the band passes (4–12% of its own height on a scroll-linked timeline), so the cluster loosens and tightens; under reduced motion it stays put. The pictures come from one place or one day and share one treatment.",
"mobile": "Five photographs at 30% of the width sit offset left and right around the centred heading, two above it and three below, none cut by an edge. The heading drops to 7vw on two lines.",
"seenIn": [
"aww-tengile-malamala-collection",
"aww-moto-finance"
]
}
]
]
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -1486,7 +1486,7 @@
],
"italic": false,
"settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Erode",
@@ -1498,7 +1498,7 @@
],
"italic": true,
"settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Noe Text",
@@ -1512,7 +1512,7 @@
"aww-colonia-zacamil"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap"
"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1534,7 +1534,7 @@
],
"italic": true,
"settings": "weight 400 · tracking -0.02em · leading 0.95 · roman capitals for the main words with lowercase italic for the connecting words at the same size, or sentence case throughout; centred lines work",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Boska",
@@ -1546,7 +1546,7 @@
],
"italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Louize Display",
@@ -1559,7 +1559,7 @@
"opl-sarah-matt-wedding"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap"
"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1676,7 +1676,7 @@
],
"italic": false,
"settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Sentient",
@@ -1688,7 +1688,7 @@
],
"italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Season Mix",
@@ -1702,7 +1702,7 @@
"opl-le-petit-bleu"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap"
"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
File diff suppressed because it is too large. Load diff
+16 -5
View File
@@ -18,8 +18,10 @@ the code, so you will read your intentions into it. The pictures only show what
Open `desktop-fold.jpg`, then the `desktop-page-*.jpg` sheets, then `mobile-fold.jpg` and the mobile
sheets. Make each pass on its own; do not blend them.
1. **One second.** Glance and look away. What did you see? There should be exactly one answer: a
word, an image, an object. If the answer is "a website", there is no focal point.
1. **One second, then five.** Glance and look away. What did you see? There should be exactly one
answer: a word, an image, an object. If the answer is "a website", there is no focal point. Then
give the first screen five seconds as a stranger: can you say what this is, who it is for and
what to do next? If the title only makes sense after scrolling, it is not a title.
2. **Squint.** Blur your attention until text becomes grey bars. The page should reduce to two or
three large masses with clear size differences. Many small similar masses means everything is
medium and nothing leads.
@@ -29,9 +31,13 @@ sheets. Make each pass on its own; do not blend them.
the pricing, the FAQ and the footer, or does the page turn into stock blocks after the first screen?
4. **Edges.** Follow the left edge of every text block down the page. Count the distinct x positions.
A composed page has two or three, reused on purpose. Six means things were placed one at a time.
5. **Space.** Are related things close and unrelated things far? Is the gap between sections clearly
larger than any gap inside a section? Is any region empty by accident (a hole) rather than by
decision (a rest)? A hole usually sits beside a short column or under a removed decoration.
5. **Space and frame.** Are related things close and unrelated things far? Is the gap between sections
clearly larger than any gap inside a section? Is any region empty by accident (a hole) rather than
by decision (a rest)? A hole usually sits beside a short column or under a removed decoration.
Then look at the frame: are the left and right margins of the page the same? Does any section keep
its text in one half and leave the other half empty? Is there a stretch of nothing taller than half
a screen? Readers name these first ("it is pushed to the left", "random empty space"), before any
detail; in the study of 100 Sites of the Day about 1 section in 100 left half the width empty.
6. **Type.** Is the jump from body text to headline decisive (three times or more)? Do you see more
than five sizes? Any headline with one orphan word on its last line? Any paragraph wider than about
75 characters or centred over more than three lines?
@@ -122,6 +128,11 @@ These override the arithmetic.
grey: design cannot exceed **6.5**.
- A section the brief asked for that is missing or reduced to a few words: content cannot exceed **6**.
- No real button for the primary action on the first screen: usability cannot exceed **7**.
- The five-second pass fails (no clear title saying what this is on the first screen): usability
cannot exceed **6**.
- Unequal page margins, a section huddled on one side with the other half empty, or a blank stretch
taller than half a screen: design cannot exceed **6.5**.
- A section of running text with no heading naming it: usability cannot exceed **7**.
- Anything covering a headline at any scroll position: usability cannot exceed **6**.
- A blank band where a reveal did not fire, or content clipped off-screen: usability cannot exceed **5**.
+423 -9
View File
@@ -17,6 +17,7 @@ how to crop and place them, and what to do when there are none.
9. Type and colour alone
10. Markup and credits
11. Video
12. Where pictures and footage go
## 1. What the study measured
@@ -32,6 +33,11 @@ how to crop and place them, and what to do when there are none.
3%.
- Among the 54 sites the panel scored under 6.8, legibility (text lost over a busy image, or too
little contrast) was named as a weakness in 13.
- In the October 2026 study of 91 Awwwards Sites of the Day (section 12), the median page carries 7
pictures (top sites 8, the lowest 2) and footage appears on 42% of sites (half of the top ones, one
of the 16 lowest).
- Pictures come in whole screens: 42% of scrolled screens hold none and 19% are 90% picture or more.
- 94% of pictures have square corners, and on a typical site two ratios hold three-quarters of them.
## 2. Decide what imagery exists
@@ -198,6 +204,7 @@ real footage with `--video` (section 11).
- **No frames:** no borders, no shadows, no device mock-ups, no browser chrome around pictures.
- **Captions** are one short line in the small size and soft ink under the image, left-aligned to its
edge. Most images need none.
- Where each picture goes on the page, how large, and what the phone does with it: section 12.
## 6. Treatment
@@ -339,12 +346,17 @@ shorter clip. Licences, credits and honesty work exactly as for pictures (sectio
- **Short:** 5 to 20 seconds that loop without a visible jump: water, smoke, a turning drum, traffic,
leaves, a crowd from far away. Watch it to the end before choosing.
- **Light:** under about 5 MB for a section and 10 MB for a hero. 720p is plenty behind type.
- **Light:** under about 5 MB for a section and 10 MB for a hero. In the study sources are 1920×1080
(the commonest) or 1280×720, median length 10 s; 720p is plenty behind type. For a full-bleed hero,
cut a tall 720×1280 version for the phone, or set `object-position` on the subject.
- **Clean:** no slate, title card, logo, watermark or burned-in caption. NASA and archive films often
open with a slate, and the source's poster frame may be that slate. No recognisable person who could
be taken for the client's staff or customers.
- **Never the client's own:** a found clip of a roastery is not this roastery.
- **One per page, two at most.** A page of moving tiles is noise.
- **Found clips: one per page, two at most.** A found clip shows the world around the client; a
third makes a theme of it. The project's own footage can carry more: in the study a site with
footage carries a median of three or four clips. Either way, **one moving picture per screen**;
two loops running side by side are noise.
**On the page.**
@@ -356,22 +368,424 @@ shorter clip. Licences, credits and honesty work exactly as for pictures (sectio
- The poster shows before the clip plays, on slow connections and under reduced motion, so it must
work as a picture on its own. If the source's still is a slate or a blur, take a frame from the clip
yourself or use a found picture.
- Under `prefers-reduced-motion: reduce`, stop it and show the poster:
- In the study only 31% of clips had a poster, and the panel recorded the blank and grey boxes
before they played as faults. Here every clip has one.
- Play a clip only while it is in view, and none under `prefers-reduced-motion: reduce`, where the
poster shows instead:
```html
<script>
const calm = matchMedia('(prefers-reduced-motion: reduce)');
for (const v of document.querySelectorAll('video[autoplay]')) {
const sync = () => { if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); } else { v.play().catch(() => {}); } };
sync(); calm.addEventListener('change', sync);
}
const clips = [...document.querySelectorAll('video[autoplay]')];
const inView = new Set();
const sync = (v) => {
if (calm.matches) return;
if (inView.has(v)) v.play().catch(() => {}); else v.pause();
};
const settle = () => {
for (const v of clips) {
if (calm.matches) { v.removeAttribute('autoplay'); v.pause(); v.load(); }
else if (inView.has(v)) v.play().catch(() => {});
}
};
const watch = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) inView.add(e.target); else inView.delete(e.target);
sync(e.target);
}
}, { threshold: 0.2 });
clips.forEach((v) => watch.observe(v));
settle();
calm.addEventListener('change', settle);
</script>
```
- Crop and place it like a photograph (sections 5 and 6): `object-fit: cover`, one ratio, square
corners, no frame, a ground-coloured `background-color` while it loads.
- Never under body text. A headline over a clip gets the same neutral scrim as text on a photograph,
and nothing lies on top of a headline or a statement.
- Never under body text. On a clip put a headline, one line and one action at most, and check the
contrast where they sit across the whole loop, not only on the poster: footage that is dark at
one second can be bright at the next. If any frame drops below 4.5:1 (3:1 for 24px and up), use the
scrim in section 6 or move the text off the clip. Nothing lies on top of a headline or a statement.
- It is motion: let it be the page's one moving thing, or sit in a section where nothing else moves.
- Credit it with the pictures: `--credits` prints one footer line for both ("Pictures and footage:")
and the list for the report, with each clip's length.
- Where footage goes (hero background, inline loop, the frame that grows, a docked card), and what
the study measured about it: section 12. Nothing that matters plays on hover only, and a full film
with sound sits behind a play button that opens a player with controls.
## 12. Where pictures and footage go
What strong pages do with their pictures, measured on the latest 100 Awwwards Sites of the Day
(10 July to 17 October 2026, captured at 1440×900 and 390×844). 91 were usable. A panel described where
every picture sat and scored each site the Awwwards way, and a script measured how much of each screen
is picture. "Top" means a panel score of 7.5 or more (36 sites), "lowest" under 7.3 (16 sites). The
scores sit close together (6.7 to 7.9) and a single score carries about half a point of noise, so read
the links between a placement and the score as leanings, not laws.
### How much of each screen is picture
- **All or nothing.** Counting photographs, footage and picture backgrounds (not canvases), the median
scrolled screen in the study is 16% picture, but few screens sit near it: 42% hold no picture and
19% are 90% picture or more. Only 19% fall between 20% and 60%. A picture either leads its screen or
stays out of it.
- **About a third, with rests.** On the 67 sites that put pictures in the page, the average screen is
36% picture (middle half 18–69%). Top sites sit at 30% and leave 28% of their screens with no picture
at all: screens of type are the rests between picture screens.
- **The first screen.** A picture covers at least half of it on 41% of the sites and nearly all of it
on 32%. A full-bleed photograph or film is the whole first screen on 29%; footage is the main hero
picture on about a quarter.
- **A scene is not a picture.** 66 of the 91 keep a WebGL canvas over at least 30% of the screen, so
counting canvases the median screen is entirely covered. On the lowest-scored sites that canvas is
often all there is: the five lowest usable sites (6.7 to 7.0) draw everything in it and put two
pictures at most in the page.
- **Text is small in area.** The median screen is 8% text. Type is set large and sparse around the
pictures, not packed beside them.
- **Off-centre.** Measured per section across the crawled sites, pictures sit to the left or right
939 times, centred 273 times, and full-bleed or behind text 269 times.
- **The phone carries more picture,** not less: 19% of a phone screen at the median, and 27% of the
first phone screen against 17% on desktop. Full-bleed pictures re-crop to portrait and fill the
screen; splits put the picture first at full width.
### How many
- **Seven pictures per page** at the median (middle half 1–25). Top sites 8 (middle half 4–27), lowest
2 (0–4). The floor in SKILL.md step 6, four and usually six to twelve, is the strong half of the
study.
- **Footage on 42% of sites:** on half of the top sites and half of the middle band, and on one of the
16 lowest. A site with footage carries a median of three or four clips.
- **Three to five placements.** Sites that use two or fewer of the placements below average 7.31;
sites that use three or more average about 7.5. The median top site uses four, the median lowest
site one. Where the pictures go changes from section to section on a designed page.
### The placements, ranked
| Placement | Sites (of 91) | Top (of 36) | Lowest (of 16) | Mean score with / without |
| --- | --- | --- | --- | --- |
| Split: picture in a half or two-thirds | 32 | 17 | 2 | 7.50 / 7.38 |
| Horizontal gallery | 32 | 13 | 4 | 7.44 / 7.41 |
| Full-bleed band between sections | 31 | 14 | 3 | 7.48 / 7.40 |
| Full-bleed picture or film as the first screen | 26 | 10 | 3 | 7.45 / 7.41 |
| Uneven mosaic | 25 | 11 | 1 | 7.49 / 7.40 |
| Picture held while text scrolls | 23 | 12 | 1 | 7.52 / 7.39 |
| Footage behind text | 22 | 8 | 2 | 7.46 / 7.41 |
| Even grid | 20 | 11 | 2 | 7.48 / 7.41 |
| Scattered collage | 19 | 6 | 3 | 7.47 / 7.41 |
| Panel sliding over a held picture | 19 | 8 | 2 | 7.48 / 7.41 |
| Inset frame that grows | 17 | 7 | 1 | 7.52 / 7.40 |
| One picture centred on a field | 14 | 5 | 3 | 7.38 / 7.43 |
| Cut-out object on a flat field | 13 | 7 | 1 | 7.57 / 7.40 |
| Index with hover preview | 12 | 6 | 1 | 7.49 / 7.41 |
| 3D gallery | 12 | 4 | 4 | 7.30 / 7.44 |
| Small card docked in a corner | 11 | 5 | 1 | 7.47 / 7.42 |
| Picture inside or in front of display type | 11 | 7 | 1 | 7.50 / 7.41 |
| One small picture beside a statement | 8 | 5 | 0 | 7.56 / 7.41 |
| Small pictures between words | 6 | 4 | 0 | 7.52 / 7.42 |
- Found on top pages more than their share and almost never on the lowest: the split, the picture held
while text scrolls, the uneven mosaic, the inset frame that grows, the cut-out on a flat field, and
pictures set with the type (the last three rows together: 24 sites, 16 of them top, 1 lowest).
- The horizontal gallery is common in every band and lifts nothing. Use it where browsing is the point.
- The 3D gallery is the one placement that goes with lower scores, and with the lowest usability in the
table (5.9 against 6.35 for all sites).
### Each placement
Each entry gives when to use it, the geometry at 1440×900, the phone version and how it fails.
**Split: picture in a half or two-thirds**
- *When.* A claim and the picture that proves it. The commonest way strong pages pair words and a
picture.
- *Geometry.* The picture takes 42% of the width at the median (middle half 33–49%), up to two-thirds,
at the full height of its section: 680×850 and 720×900 are typical. 38% of these pictures are
portrait, mostly 4:5 or 3:4. The text starts on the 50% line (x = 720) or on the line where the
picture ends, top-aligned or docked low, not centred in its half. Let the picture touch the viewport
on its outer side: only about 30% do in the study, and the ones with margin on every side read as
placed by default (section 5). Alternate sides only across three or more rows.
- *Phone.* Picture first at full width (4:5, or 4:3 for a landscape), text under it.
- *Fails.* The same split in three sections running; a picture floating in the middle of its half; a
text column narrower than about 30 characters.
**Full-bleed band**
- *When.* One picture carries a whole screen between type screens: the place, the work, the object at
full size. One every three or four screens at most.
- *Geometry.* 100% wide and one to one and a half screens tall (median 900px at 1440×900, middle half
890–1,170px; ratios 1.4 to 1.9). For a slow parallax the picture is oversized in its frame by 4–10%
(32–72px a side in the study) and travels no further than that. Text is a headline and one line, or
nothing, docked 32–64px in from a corner over the calm part of the picture.
- *Phone.* A portrait re-crop that fills the screen, with `object-position` set on the subject; the
text moves to the bottom quarter.
- *Fails.* 11–14px white text over a bright area; three bands with nothing between them; a band that
only decorates.
**Picture held while text scrolls**
- *When.* A list, steps or a long paragraph that all belong to one picture. If the picture changes for
each item, it is the index with a preview, below.
- *Geometry.* A 100vh sticky stage inside a section two to six screens long (runways of 1,800–5,400px
are typical in the study). The picture holds one half (685×880 is a typical portrait), two-thirds, or
the whole screen with the text passing over it on a solid panel. Text rows about 150px apart, so two
or three are in view at once.
- *Phone.* Release the stick: picture first, text under it. If the picture swaps per item, keep it as
a band in the top 40% of the screen.
- *Fails.* Long runways where the text has faded out and the held picture is all that is left (the
panel recorded several empty stages on phones); body copy set on the held picture.
**Inset frame that grows**
- *When.* Once per page, for the film or picture the page is about, as the move from type into
picture.
- *Geometry.* The frame starts centred at 35–50% of the width (549×366, 710×480 and 864×540 in the
study) or as a 16px inset round a full screen, and grows to the full width and height over
720–2,700px of scroll. Any corner radius goes to 0 as it reaches the edges. A headline sits above it
before it starts; nothing else on the screen moves while it grows.
- *Phone.* Start at full width inside the 16px margins and grow to a full-bleed portrait, or skip the
growth.
- *Fails.* The growth is the only thing on screen for three scrolls; a loading spinner or a blank box
in the first frame (give it a poster, start loading before it enters); used twice.
**Uneven mosaic**
- *When.* A body of work, or one place seen several ways: five to twelve pictures.
- *Geometry.* Two columns, or rows of three on a 24-column grid. Two or three picture widths (for
example 32%, 48% and 65% of the width, or 5, 7 and 12 columns), neighbours offset vertically by
120–190px, 130–450px between rows, one or two ratios, a caption under each.
- *Phone.* One column alternating full width and about 70% width, or two columns of 120–180px tiles
that keep the offsets.
- *Fails.* Random ratios with random offsets, which read as a stock dump. Captions in 9–12px mono with
index numbers, the award-site habit: here the caption is one line in the text face at body size, with
no number.
**Horizontal gallery**
- *When.* A set people browse in any order: products, rooms, events, a team.
- *Geometry.* Cards 25–35% of the width (435×326 with 20px gaps at 1440: three in full and a fourth
cut by the edge), or a strip at one fixed height with mixed widths (576px tall in the study), or a
slider whose centre slide is about 73% of the width with 170–180px of each neighbour showing. Always
cut by the right edge so it reads as more. A marquee of pictures moves slowly: 60–120s per loop.
- *Phone.* One card at 75–80% of the width (290–300px of 390) with the next one showing; swipe.
- *Fails.* Arrows and no drag or swipe; a marquee of things people need to read; a carousel holding the
page's one important picture.
**Panel sliding over a held picture**
- *When.* A change of chapter: the picture holds, and the next section rises over it.
- *Geometry.* The picture is sticky at 100vh; the next section's ground slides up over it at full width,
its top edge straight or shaped from the subject (the study has the arch of a building's windows).
Here it is a solid panel with square corners: no rounded sheet tops, no frosted glass.
- *Phone.* Unchanged.
- *Fails.* Decks of five or six cards whose earlier cards linger as slivers; stacking for its own sake.
**Even grid**
- *When.* An archive only (layout.md §5): a shop range, the team, a menu, the full list of work.
- *Geometry.* Two, three or four across, equal cells, 8–16px gaps, one ratio (4:5 is the commonest),
the caption under each picture.
- *Phone.* Two across for products, one for work and people.
- *Fails.* Three equal cards as "features". Cards with a dark gradient at the foot so the label can sit
on the picture, common in the study: here the label goes under the picture.
**Index with hover preview**
- *When.* A list of eight or more projects or clients where the names matter as much as the pictures.
- *Geometry.* Rows on a 45–80px pitch, names at 30–60px. One picture at a time, about a third of the
width at 16:9 or a portrait 300–450px tall, at a fixed place (the right half from the 50% line, or
the centre), swapped within 0.3s. A version that needs no mouse: the names scroll across a picture
fixed at the centre and the picture shows whichever name is crossing it. In the study the picture
sits on top of the names; here nothing lies on top of large type, so the names pass in front of it.
- *Phone.* No hover. Give each row a thumbnail about 100px wide, or use the scroll version.
- *Fails.* Pictures that never appear on a touch screen. Rows divided by hairlines, the award-site
habit: here rows are separated by space, and the active row by ink or a change of ground.
**One picture on a field, or one small picture beside a statement**
- *When.* A pause in the page: a single portrait or object. Or a small picture that gives a statement
a face.
- *Geometry.* A single centred picture at 3:4 and 75% of the viewport height, on a ground taken from
the picture. Or a small picture 7–26% of the width (108–374px) beside, above or between the lines
of a 48–136px statement, aligned to one of its lines.
- *Phone.* The picture above the statement at 60–100% of the width.
- *Fails.* Used as the default for every picture: a picture with equal margins on all sides reads as
placed by default (section 5).
**Small pictures between words**
- *When.* A statement about the things themselves (materials, people, products), on a screen of its
own.
- *Geometry.* Two to four chips in the statement, from the cap height to three-quarters of the type
size tall and one to one and a half times as wide (50×46px in a 60px line; 113×75px between the two
words of a 150px name), all from one shoot, square corners. Give them a width and height so the lines
do not jump while they load.
- *Phone.* Keep them inline at the height of the text.
- *Fails.* More than four; chips from different shoots; a chip that carries information the words do
not.
**Picture inside or behind display type**
- *When.* The object is the subject: a product, a building, a person.
- *Geometry.* A small picture window sits inside a block of 150–200px display type and later opens
into the next screen; or the object stands behind the type, showing between and around the letters;
or it touches the edge of the word without covering a letter. In the study the object more often
stands in front and covers one or two letters, or a line of type runs behind a square picture; here
nothing lies on top of a headline (the inspector fails large text a fifth or more hidden), so the
type stays in front.
- *Phone.* Keep it, with the type at about 15vw, and check the word still reads.
- *Fails.* An object over the letters; type displaced or glitched by the picture.
**Cut-out object on a flat field**
- *When.* Products and objects. The highest mean score in the table, on 7 of the 36 top sites.
- *Geometry.* The object at 35–60% of the width on a flat ground taken from its own colours, standing
on the bottom edge or running off one side, with the type behind or beside it.
- *Phone.* The object at 80–90% of the width under the headline.
- *Fails.* A render floating in a void with no ground; a drop shadow added to a cut-out (no resting
shadows here; keep a contact shadow only if it was photographed).
**Scattered collage**
- *When.* Archives, ephemera, a festival of many small things. Sparingly: it is about as common on the
lowest pages as on the top ones.
- *Geometry.* Six to sixteen pictures of 100–300px at a few sizes around a centred heading, never over
it.
- *Phone.* Re-stack as overlapping pairs in one column.
- *Fails.* Tilted prints and stickers with offset shadows, common in the study and not a treatment
here (section 6); pictures crossing the text.
**Small card docked in a corner**
- *When.* A standing pointer to the reel or to the next thing.
- *Geometry.* About 216×144 to 240×140, 30–43px from the corner; one per page.
- *Phone.* Drop it, or move it into the flow.
- *Fails.* Content scrolling under it; a second one.
**3D gallery**
- *What.* Pictures on a ring, sphere, helix or tunnel in a WebGL scene, turned by the wheel or by
dragging.
- *In the study.* 12 sites, 4 of them among the 16 lowest; mean score 7.30 against 7.44, usability 5.9
against 6.35.
- *Here.* Do not build one. Show the same pictures as an index with previews or a horizontal gallery.
### Footage
**What the study measured.**
- Footage on 38 of 91 sites (42%): 18 of the 36 top sites, 19 of the 39 in the middle, 1 of the 16
lowest. A site with footage carries a median of three or four clips (middle half 1–8).
- Footage covers at least half of the first screen on about a quarter of the sites, and is full-bleed
on nearly all of them.
- Clips are short: median 10s, middle half 5–14s; 61% run 10s or less and 88% 20s or less.
- Every decorative clip is muted, loops, plays inline and has no controls. The autoplay attribute is on
44% of clips; the rest are started by script, mostly as they come into view.
- Only 31% of clips have a poster (13 of the 38 sites). The rest show a blank or grey box until they
play, and the panel recorded those as blank first paints. Here a poster is required (section 11).
- Sources: 1920×1080 is the commonest, then 1280×720; tall clips at 450×800 or 540×960 for phones and
portrait tiles. Median source width 1,080px.
- On the page: 16:9 41%, square 19%, portrait (4:5 to 9:16) 25%, 4:3 or 3:2 12%. Corners square on 75%
(the rest 8px or less, or 20–32px). `object-fit: cover` on 84%, contain on 16% (product strips, letterboxed
films).
**Where it goes.**
- **Hero background:** full-bleed, cover, often oversized by 1–25% so a drift or zoom never shows an
edge. Either one slow take or a montage cutting every second or two.
- **Inline, as a picture:** in splits, tiles and cards, at about a third of the width (median 33%,
middle half 24–45%); a third of these clips are under a quarter of the width and fewer than one in
ten is full width. Slow, near-still subjects (water, skin, light, a turning object) read as living
photographs and sit well among stills.
- **The frame that grows** (above), for a reel.
- **A docked card** (above): one small clip in a corner.
- **On hover:** only two or three sites play clips on hover, all of them in an index or list. Nothing
that matters should play on hover only; phones have no hover.
- **Full films:** about ten sites offer a full film behind a play button that opens a player with
controls. None autoplays it.
- **Sound:** six sites offer sound behind a toggle; no clip starts with sound.
**How text sits on it.**
- Of about 30 sites with a full-bleed picture or film behind text on the first screen, about 14 set the
type straight on it and rely on the footage being graded dark where the type sits; about 10 dim it
(to half brightness, under a 30–40% dark layer, or with a dark fade along the bottom 180–590px);
about 6 keep the text off the picture, above it or on a solid card.
- What goes on footage in the study is the name or a headline at 47–136px and one short line, docked
bottom-left or centred. Where body text at 11–14px went on footage or a busy photograph, the panel
named it as a fault, on top-scored sites too.
- Here: one headline, one line and one action on footage at most, and never body text. Check contrast
where the type sits across the whole loop, not only on the poster; if any frame drops below 4.5:1
(3:1 for type of 24px and up), use the scrim in section 6 or a solid band.
**On the phone.** Footage re-crops to a full-screen portrait (a separate tall source, or
`object-position` on the subject); inset frames go full-bleed. One site in the study adds a 40% scrim
on the phone only, where the type covers more of the picture.
### Crops and treatment
- **Ratios.** Of 1,356 pictures and clips measured: 4:5 21%, 16:9 19%, 1:1 16%, 16:10 10%, 3:2 7%, 2:3
5%, 4:3 5%, 5:4 4%, 3:4 3%, wider than 2:1 6%, 9:16 or taller 3%. Landscape 52%, portrait 33%,
square 15%.
- **Two ratios do most of the work.** On sites with four pictures or more, the two commonest ratios
hold 76% of the pictures. The others (five ratios in all at the median) are exceptions with a reason:
one tall portrait in a run of landscapes, one wide band.
- **Square corners** on 94% of pictures; 62 of the 68 sites with pictures in the page keep at least 80%
of them square. Where corners are rounded, it is 5px or less on two-thirds of them, or 20–32px on
card interfaces, one value per site.
- **Cover.** `object-fit: cover` on 78%, contain on 10% (a product on a tile, a letterboxed film).
- **Untouched.** CSS filters touch pictures on only 9 sites, mostly for hover states. A handful use
black-and-white photography as a set; two multiply scanned paper onto the ground. Several keep the
interface black, white and grey and let the photographs carry all the colour.
- **Common in the study, not treatments here** (section 6): a fixed grain layer over the whole page
(about a third of the sites), duotone or tinted photographs, blurred copies behind a picture as a
glow, tilted prints and stickers with offset shadows.
- **Oversize only by the distance it moves.** 22 sites move pictures with the scroll, oversizing them by
1–25%. Oversize by the travel and no more, on one picture per screen.
- **Resolution.** The study serves pictures at a median 1.9 times their CSS width, but 17% are served
smaller than they are shown and full-width pictures at a median of only 1,440px. Here: twice the CSS
width, capped at about 2,400px (section 4).
### Text over pictures
- In the study, text sits on pictures often and is checked rarely: about 28 notes record text set
straight on a picture with no scrim, and the faults of 16 sites include text lost over pictures: 8–14px
labels and body over photographs, and navigation in a difference blend or with no backing passing
over them.
- Where it works, the text is display size and sits over the calm part (sky, a dark wall, the shadow
side), docked in a corner or along one edge. Two other forms in the study: a solid card in the
ground colour inset over the photograph and carrying the heading, and text on the white of a picture
that fades into the page.
- Here: on a picture put only display text, one short line and one action. Body text, captions and
labels go beside or under it. Measure contrast at the text's own position (4.5:1, or 3:1 for 24px and
up). If it fails, move the text to a calmer part, put it on a solid card in the ground colour, or use
the scrim in section 6. A fixed navigation that passes over pictures gets a solid band, or hides
while scrolling down.
### Rules for a generator
1. **About a third of the page is picture.** Across the scrolled screens, pictures fill 30–40% of the
area, and at least one screen in four carries no picture (top sites: 30% and 28%).
2. **A picture leads its screen or stays small.** A leading picture takes at least 60% of its screen;
an accent stays under 10% of it. Avoid screen after screen of pictures at 20–40%: in the study 42% of
screens are 0% and 19% are 90% or more.
3. **Three to five placements per page,** and no two neighbouring sections with the same one. Sites
with two or fewer average 7.31, sites with three or more about 7.5.
4. **Six to twelve pictures, never fewer than four.** Study median 7; top sites 4–27; lowest 2.
5. **Two ratios for at least 75% of the pictures** (76% in the study), a third only for a deliberate
exception. Defaults: 4:5 for portraits, 16:9 or 3:2 for landscapes.
6. **Square corners on every picture** (94% in the study). If the direction rounds them, one radius of
4px for every picture and clip.
7. **Split pictures take 42–50% of the width, or two-thirds,** at the full height of the section and
touching the viewport on their outer side; the text starts on the 50% line. On the phone the
picture goes first, full width.
8. **Full-bleed bands are 100% wide and 100–150vh tall,** one every three or four screens, oversized by
10% at most for parallax, with text only in one corner at least 32px in. On the phone, re-crop to a
full-screen portrait.
9. **One inset frame that grows per page,** from 35–50% of the width to the full screen over
720–2,700px of scroll, with nothing else moving on that screen.
10. **Horizontal galleries show three cards and a cut fourth:** cards 25–35% of the width with 16–20px
gaps; on the phone one card at 75–80% of the width with the next one showing.
11. **One moving picture per screen.** Clips of 5–15s (median 10s), muted, looped, inline, with a
poster, paused out of view; a hero source of 1920×1080 under 10MB, inline clips at 1280×720 or
smaller under 5MB.
12. **Only display text on a picture:** a headline, one line and one action at most, with contrast
measured at the text's position across the whole loop. Body text, 16px or more, goes beside or
under the picture, never on it.
+132 -18
View File
@@ -11,11 +11,12 @@ below are hand tallies from those notes, so read them as "about").
2. Margins and the grid
3. Where text sits
4. Empty space
5. Sections: carry the idea, vary the arrangement
6. Navigation
7. Endings
8. The phone is a second composition
9. How pages fail
5. The frame: margins, balance and titles
6. Sections: carry the idea, vary the arrangement
7. Navigation
8. Endings
9. The phone is a second composition
10. How pages fail
## 1. What strong pages do
@@ -77,19 +78,132 @@ below are hand tallies from those notes, so read them as "about").
## 4. Empty space
Empty space is placed, not left over: an empty left half, a blank right third, 300px of nothing above
the work.
Empty space is placed, not left over. In the study of 97 Sites of the Day captured in October 2026,
readers judged every large empty area either a rest, which something closes, or a hole, which nothing
closes. Strong sites have plenty of rests. About half of them still leave at least one hole, and the
holes are what visitors name ("big random blank spaces").
- Decide where the void is and keep it clean: no stray caption, no decorative shape, no faint
watermark.
- The gap between sections is clearly larger than any gap inside one (120–220px on desktop,
72–112px on a phone).
- Related things sit close; unrelated things sit far. If a heading is equidistant from the paragraph
above and the one below, fix it: at least twice as much space above as below.
**A void is placed when something closes it.** In the readers' notes the closer is, most often:
- the picture itself (sky, ground, the object, the film) with the type docked at its edge;
- a picture across the void: the photograph at the far side of an empty band, the full-width row
rising under a statement, the opposite picture in a stagger;
- the type: a headline large enough to span the space, or two short texts at the two ends of one
baseline;
- on a one-screen page, the wordmark, the navigation and one fact in the corners, like a mount around
a print.
About one closer in five in the study is a hairline or grid line. That device is banned here; close the
void with the picture, the type, a step in ground tone or a field of flat colour.
**Where holes come from.** In order of frequency in the study:
1. A gap between sections stretched too far: bands of 250–760px of plain ground.
2. Text in one half of a section and nothing in the other.
3. Content that arrives late: a reveal not yet fired, so the visitor scrolls onto bare ground while the
words fade in.
4. A short block stretched to a full screen: a footer padded to the viewport height, a section title
alone on a whole screen.
5. A pinned scene that holds almost nothing for several screens.
6. Pictures of different heights top-aligned in a row, leaving a hole under the short one.
**Rules**
- Decide where the void is and name what closes it. If nothing does, it is a hole: move the section's
picture into it, make the main element bigger, or take the space out.
- Keep the void clean: no stray caption, no decorative shape, no faint watermark, no grid lines.
- Sections sit 120–240px apart on desktop, counted from the last line of one to the first line of the
next (the median in the study is about 230–250px), and 72–112px on a phone. A change of ground
colour lets the gap be a little smaller. More than about 40% of a screen of plain ground between two
sections is a hole.
- The gap between sections is clearly larger than any gap inside one. Related things sit close and
unrelated things far; a heading has at least twice as much space above it as below.
- No stretch of the page taller than about half a screen holds nothing at all. In the study, runs of a
screen or more with nothing in the page were almost always scroll distance for a pinned scene; the
pinned stage must then hold something at every scroll position.
- Content is in place on the first paint. An entrance moves what is already there (see `motion.md`);
it never leaves a screen of bare ground while the words arrive.
- Size a section to its content. `min-height: 100vh` belongs to a section that fills the screen, not
to a footer or a lone heading.
- In a row of pictures of different heights, align the bottoms or put something under the short one,
or crop the row to one ratio.
- A rest is one thing on a screen: a single line, one object, one picture or a field of colour. Once
per page at most. A screen of nothing is never a rest, and the inspector fails two near-empty screens.
- When a section looks empty, the answer is almost never "add something". Make the main element
bigger, or cut the section.
## 5. Sections: carry the idea, vary the arrangement
## 5. The frame: margins, balance and titles
The frame is what readers judge before any detail: "no good margin", "everything is shifted to one
side", "no clear title", "random unclear sections". The inspector measures each of these. The numbers
are from the study of 97 Sites of the Day (October 2026).
**One pair of equal margins.**
- The left and right margins are the same, at every width. On the studied sites with scrolling
sections the two sides match to a few pixels: 16 and 16, 20 and 21, 32 and 32, 140 and 137. No
strong page in the study was pushed to one side.
- The usual margin is tight: median about 28px at 1440, half the sites between about 18 and 45px, a
few at 80px or more. Here it is 16–32px (section 2), and never under 16px at any width, on the phone
included. About one award site in seven sets text 8–15px from the edge; that studio habit is
excluded here.
- The navigation sits on the same margins as the content. The commonest small fault in the study is a
header on one inset and the page on another (40px against 20px, or 34px on the left against 10px on
the right).
- A bleed is deliberate and starts from the margin: a row of pictures that runs off the right edge
begins on the left margin; a display line that bleeds starts or ends on one. A bleed never moves the
margin.
- Asymmetry belongs inside the frame: an off-centre anchor line (a third across, 42%, the centre line)
on symmetric margins.
**Use the width the page has set up.**
- In the study about one text section in a hundred leaves half the width empty. Text that sits in one
half is common, in roughly one site in three, but the other half then holds the section's own
picture (most often), a full-bleed picture behind the text, a second column, a list or a figure.
- When the text takes one side, put the picture, the second column, the list or the large number on
the other. Never leave the other half to plain ground.
- The middle counts too: a card at the left edge and a lone button at the right with the middle third
empty is the same hole.
- The first screen counts too. When the sentence and the button take the left third above a name
docked at the foot, the upper right holds something (the picture or film, one fact, the action) or
the name grows up to meet them; in testing, 60% of a first screen left as plain ground under the
navigation read as a hole, and the inspector now warns when one empty block takes 28% of the first
screen or more.
- A display line fitted to the width is fitted by measurement (a small script that sets its size from
the content box, or SVG text with `textLength`), never by a size guessed for one face: a name that
starts on the margin and runs a few pixels off the other edge reads as a mistake.
- A centred composition has one axis, equal flanks and one thing on it, not a stack of heading,
paragraph and two buttons.
**A title that says what this is, on the first screen.**
- Asked whether a stranger could say what the page is within five seconds, readers found that 40 of
the 97 first screens say it in words, 42 say it only through pictures, small print or a bare name,
and 15 do not say it at all. The last group lost usability and content points, not design points.
- Size alone does not do it: 66 of the 97 first screens carry a display-size line, but 26 of those are
a name, a slogan or a place with nothing saying what is offered.
- The first screen has one `h1`, at display size (32px or more on desktop; on the clear first
screens in the study mostly 50–150px), two lines at most, that says what this is: the name with
what it does, the product with what it is for, the event with its date and place. If the `h1` is
the name, the line under it, at 18px or more, says what the thing is.
- It is there on the first paint. About a third of the studied pages still showed a loader or nothing readable 4.5
seconds.
**A heading on every section.**
- 82% of the text sections on the studied sites open with a heading: median 57px (40–96px for the
middle half), about three and a half times the body size, one or two lines.
- Headings start on the outer margin or on the page's inner anchor line (in the study about a third
each) and in the same place for every section. About a quarter of the headings in the study are
centred; centre one moment per page here.
- Where a studied section has no heading, the usual stand-in is a small label: caps, mono or a number
in brackets. All three are banned here. Put the label's words in a heading in the heading face at the
section size ("The rooms", "How it works", "Prices"), set them on the anchor line, and let the first
sentence carry the rest.
- A statement can open a section as its heading only when it names what the section holds.
## 6. Sections: carry the idea, vary the arrangement
The most common failure in every score band is a distinctive first screen followed by stock blocks:
icon cards, a three-up row, an accordion, a link-column footer. About 25 of the studied sites lose
@@ -110,7 +224,7 @@ points this way, including several above 7.5.
- **Short pages are fine.** A quarter of the studied sites are one or two screens. Do not add sections
to seem complete; each one must carry a claim (see `copy.md` §1).
## 6. Navigation
## 7. Navigation
- The plain form: the wordmark at one end, three to five text links, one action, all at text size on
the page ground with no bar, border or background. Median link count in the study is 3.
@@ -127,7 +241,7 @@ points this way, including several above 7.5.
the phone so the headline, the supporting sentence and the action stay in the first screen. In
blind tests a nav that ate the hero cost a whole point of usability.
## 7. Endings
## 8. Endings
- The page should end on a designed scene. In the study: the name at full width (about 18 of 114), the
hero repeated in inverted colours (about 12), a closing scene of its own such as the one contact
@@ -138,7 +252,7 @@ points this way, including several above 7.5.
- Put the practical facts (address, hours, email, legal line) in the text face at a readable size, on
the same anchor lines as the rest of the page.
## 8. The phone is a second composition
## 9. The phone is a second composition
Design the 390px screen as its own page, not the desktop page squeezed. Strong sites that neglect this
lose usability points; weak ones are often just broken.
@@ -153,7 +267,7 @@ lose usability points; weak ones are often just broken.
- Check 390px in the inspector's screenshots for every round: sideways scroll and three-line headlines
usually appear here first.
## 9. How pages fail
## 10. How pages fail
From the panel's notes on the 54 sites scoring under 6.8 (counts out of 54):
+734 -46
View File
@@ -6,17 +6,26 @@ Read this before adding any animation. The static page must already be finished
## Contents
1. The budget
2. Four questions before any animation
3. Tokens
4. Recipes (copy, then adapt)
5. Signature moves
6. Reduced motion and performance
7. What gives generated motion away
2. What strong pages do with motion
3. Four questions before any animation
4. Tokens
5. Scroll behaviours, ranked
6. Recipes (copy, then adapt)
7. Signature moves
8. Reduced motion and performance
9. What gives generated motion away
10. Rules for motion
## 1. The budget
A page that does not move reads as a mock-up; the inspector counts what moves while the page loads and
scrolls, and fails it under eight elements. The motion layer has four parts:
scrolls, and fails it under eight elements. The motion layer has five parts:
- **Two or three scroll scenes:** the big, scroll-driven moments award sites are remembered for (a
camera flying through a field of pictures, a picture that grows from a window to the whole screen,
type that crosses a held stage, cards that deal out and stack). The direction sheet deals them from
`library/scenes.json` when the library has scenes for the brief; `references/scenes.md` says how to
fill and fit them. They are where the page is bold; everything else stays quiet so they land.
- **One signature moment:** the thing a visitor would describe an hour later: the headline that rises
out of a mask, the image that opens like a shutter, the wordmark that scales with the scroll. The
@@ -37,7 +46,46 @@ cited motion tell of generated pages; motion that hides content until a script r
bounce and elastic easing; anything that animates layout properties. Uniform motion says nobody decided
anything; choreographed motion with one protected moment says somebody did.
## 2. Four questions before any animation
## 2. What strong pages do with motion
Measured on the latest 99 Awwwards Sites of the Day (July to October 2026), at 1440 and 390, with
the transitions, tweens and scroll triggers recorded while each page loaded and was scrolled. Where
the gallery does something the house rules ban, the house version follows.
- **The stack.** Two in three use a smooth-scroll library and three in five ship Three.js; GSAP is
named on 56. The quiet layer below (reveals, line rises, the ink fill) needs none of it: CSS scroll
timelines and view transitions do that work, though only 3 and 6 of the sites use them. The big
scroll scenes are built on GSAP ScrollTrigger, as on the award sites, through the shared stage in
`assets/scenes/` (`references/scenes.md`).
- **Curves.** The curves that really ran are strong ease-outs: expo out (`.16,1,.3,1` and
`.19,1,.22,1`) ran on 14 sites, cubic out on 13, quint out on 7, and by number of transitions the
quint and expo outs lead every chosen curve. In-out curves are kept for wipes and things that
travel (cubic and quart in-out, about 15 sites each). Ease-in appears only for exits, short, before
a slow arrival. Colour changes use plain `ease` (46 sites). Overshoot sits in 19 stylesheets and
ran on 2.
- **Durations.** Hover colour 200-300 ms; small interface moves 300-450 ms; reveals and line rises
600-1,000 ms; picture openings and wipes 1,100-1,600 ms. The median of everything that ran on a
site is 420 ms.
- **Staggers.** 20-40 ms between characters, 75-120 ms between lines, 50-100 ms between list items.
- **Scroll-linked motion is locked to the scrollbar.** Of the sites that scrub, 14 of 18 lock it with
no smoothing; the other four smooth it by 0.1-3 s. Reveals fire when the element's top is 80-95% of the way
down the viewport, once.
- **How much moves.** A median of 130 elements are moved by script on a page, but they come from a
few gestures: one split heading is 20-60 elements. The inspector's floor (8, warning under 16) is a
floor, not a target.
- **Waiting is the most common cost.** 60 of 99 open on a loading screen, 26 of them with a counter.
Half the usable pages were still unreadable 2.5 s after the browser said the page had loaded, a
third at 4.5 s. The preloaders were not covering slow pages (their documents loaded faster than the
rest) and the readers' design marks were the same whatever the wait; only usability fell. House
version: no preloader, the message readable on the first frame, the load sequence over by 1.2 s.
- **Pins cost length.** Two in three scrolling pages hold something sticky or pinned. Those pages
are twice as long (a median 16.6 screens against 7.5) for fewer words per screen (27 against 46).
House version: at most two held stages, about one screen per step, three screens per stage.
- **Reduced motion is mostly missing.** 35 of 99 have any `prefers-reduced-motion` rule, 16 have
three or more; 5 of 26 scroll-scrubbed scenes have one. Every recipe here keeps the finished state
as the reduced state.
## 3. Four questions before any animation
1. **How often will someone see it?** Once per visit (hero load, a section reveal): it can take its
time. Many times per visit (hover, menu, accordion): fast and small. Hundreds of times (keyboard
@@ -52,24 +100,85 @@ anything; choreographed motion with one protected moment says somebody did.
4. **How long?** Press feedback 100–160ms. Hover 150–200ms. Menus and popovers 150–250ms. Dialogs and
drawers 250–450ms. Section and hero reveals 500–900ms. Leaving is quicker than arriving (about 70%).
## 3. Tokens
## 4. Tokens
```css
:root {
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* enters, exits, reveals */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* long quiet reveals, image scale */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* new: reveals of 800ms and longer, line rises, openings */
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* new: something leaving for good, short */
--t-press: 140ms;
--t-hover: 180ms;
--t-ui: 240ms;
--t-reveal: 800ms;
--t-open: 1200ms; /* new: picture openings and clip wipes */
--stagger-line: 90ms; /* new */
--stagger-item: 70ms; /* new */
}
```
Name the properties you animate. `transition: all` animates things you did not intend (and costs
layout); write `transition: transform var(--t-press) var(--ease-out)`.
How the study bears on each:
## 4. Recipes
- `--ease-out` (quint out) is in 28 stylesheets with its near twin and is the single most frequent
chosen curve among transitions that ran. Keep it.
- `--ease-expo` is new. Expo out (`.16,1,.3,1` and `.19,1,.22,1`) is in 28 stylesheets and ran on 14
sites, more than any other chosen family, and it is the curve the long reveals use: line rises,
picture openings, card entrances of 0.9-1.6 s. Use it for anything at `--t-reveal` or longer; it
replaces `--ease-out` in the masked headline and the reveal-on-scroll recipes.
- `--ease-in-out` (quart in-out) matches its family (14 stylesheets, 7 ran). Keep it for wipes,
shutters, menus and page transitions.
- `--ease-soft` (cubic out) matches the cubic-out family (21 stylesheets, 13 ran), but the study uses it
for interface moves of 0.3-0.8 s, where `--ease-out` already serves. Narrow it to slow scale and
drift on photographs.
- `--ease-exit` is new. Ease-in is right for exactly one job: something that leaves for good (an
outgoing caption, a replaced slide, a closing panel), short, at about 60-70% of the matching
entrance. Never for anything the visitor is waiting to see.
- Not adopted: `.4,0,.2,1` (38 stylesheets) is a framework default, and overshoot curves (19) stay
banned on interface elements.
- `--t-hover` stays at 180ms. The study's colour hovers run 200-300ms; a little quicker reads as
more exact. A hover that moves something (a label roll, an arrow) takes `--t-ui` to 400ms.
## 5. Scroll behaviours, ranked
| Behaviour | Pages of 99 | What it serves | House version |
| --- | --- | --- | --- |
| Smooth-scroll library | 67 | A feeling of weight; makes scrubbed scenes easier to build | Optional, through `Stage.smooth()`: Lenis at lerp 0.1-0.14, off for touch and reduced motion |
| Held (sticky or pinned) stage | 44 | One picture or object carries several claims; chapters | Yes: the scroll scenes (`references/scenes.md`), two or three per page, one to four screens each; other sticky stages three screens at most |
| Split text | 40 | Lines and words arriving as they are read; hover rolls | Lines for headings and statements; words only for the ink fill; characters only for a heading of one to three words |
| Colour by chapter | 34 | Tells chapters apart without padding bands | Yes, as hard cuts at section edges; the header ink follows in 300ms |
| Scroll-scrubbed scene (camera, object, image sequence) | 26 | Explains a product by moving it | Yes, from the scene library, built on the shared stage with a finished composition for reduced motion |
| Parallax depth | 24 | Depth in collages; pictures drifting inside oversized frames | Travel under 8% of the element, pictures held at 106-110% in their frames |
| Marquee | 21 | Life on a static band | Existing "Edge-to-edge marquee": one per page, pausable |
| Masked line or word rise | 18 | Headings that arrive like set type | Yes: "Masked line reveal" on load, "Lines that rise on scroll" below the fold |
| Wheel drives a scene, no page scroll | 16 | Browsing one object | Not used (see 2.6) |
| Blur-in | 15 | Soft arrival | At most 8px of blur, 600ms, gone once settled; never on body text |
| Picture from inset frame to full-bleed | 14 | The hand-over from a type screen to the big picture | Yes: "Opening window", about one screen of scroll |
| Grey-to-ink text fill | 14 | Paces one statement | Yes, once per page, starting at 45% ink: "Ink fill" |
| Sheet slides over a held layer | 11 | Page-turn rhythm between chapters | Yes: new move "Sheet over a held picture" |
| Scramble or decode | 11 | A "code" voice | Not used (see 2.6) |
| Header ink by chapter | 10 | The fixed header stays legible | Yes, with colour by chapter |
| Horizontal track pinned to vertical scroll | 8 | Walking through rooms or a collection | Native drag row first; pinned only when the row is under three screens long, with keyboard support: "Sideways gallery" |
| Curtain footer | 8 | The page ends as a scene | Existing move |
| Rolling digits | 6 | Figures that arrive | Yes, for pages whose case is a handful of numbers: "Rolling figures" |
| Index with hover preview | 5 | Navigating work by name | Existing move, upgraded recipe |
Text, split by unit: characters on about 20 pages, lines on about 14, words on about 11. Character
splits serve short headings and button rolls; on long statements they leave fragments mid-scroll.
## 6. Recipes
All recipes keep the content visible when JavaScript is off, put every hidden state inside
`prefers-reduced-motion: no-preference`, and use the tokens above. Two notes that apply to several:
- **Scroll timelines need a scroller to watch.** `animation-timeline: view()` takes the nearest scroll
container, and `overflow: hidden` makes one. A picture inside an `overflow: hidden` frame therefore
never animates. Use `overflow: clip` on frames, or name the timeline on the section
(`view-timeline-name: --hero`) and point the children at that name. This also applies to the
existing "Scroll-linked progress" recipe.
- **Scroll-linked means linear.** The scroll is the easing. 14 of the 18 scrubbing sites in the study
lock motion to the scrollbar, which is what CSS scroll timelines do.
All recipes keep content visible when JavaScript is off or slow: the hidden state only applies under
`html.js`, which a one-line inline script sets.
@@ -78,28 +187,45 @@ All recipes keep content visible when JavaScript is off or slow: the hidden stat
<script>document.documentElement.classList.add('js')</script>
```
### Hero load sequence (CSS only)
### Load sequence in 1.2 seconds
Stagger at most four things. With a headline of two lines this is short and reads as one gesture.
```css
@media (prefers-reduced-motion: no-preference) {
.js [data-rise] {
opacity: 0;
transform: translateY(18px);
animation: rise var(--t-reveal) var(--ease-out) forwards;
animation-delay: calc(var(--i, 0) * 90ms + 120ms);
}
}
@keyframes rise { to { opacity: 1; transform: none; } }
```
Everything is in the HTML and painted on the first frame; the sequence only adds arrival. Nothing
waits for the load event, and nothing in the navigation animates.
```html
<h1 data-rise style="--i:0">…</h1>
<p data-rise style="--i:1">…</p>
<a data-rise style="--i:2" class="button">…</a>
<header class="hero">
<h1 class="display lines">
<span class="line"><span data-line style="--i:0">Stools for</span></span>
<span class="line"><span data-line style="--i:1">small kitchens</span></span>
</h1>
<p class="lead" data-load style="--d:420ms">…</p>
<a class="button" data-load style="--d:480ms" href="#stools">See the stools</a>
<figure class="hero__picture" data-open><img src="stool.jpg" alt="…" fetchpriority="high"></figure>
</header>
```
```css
.hero__picture { overflow: clip; margin: 0; }
@media (prefers-reduced-motion: no-preference) {
.js .hero [data-line] { transform: translateY(105%); animation: load-line 800ms var(--ease-expo) forwards; animation-delay: calc(100ms + var(--i) * var(--stagger-line)); }
.js .hero [data-load] { opacity: 0; transform: translateY(12px); animation: load-rise 500ms var(--ease-out) forwards; animation-delay: var(--d); }
.js .hero [data-open] { clip-path: inset(0 0 100% 0); animation: load-open 1000ms var(--ease-in-out) 150ms forwards; }
.js .hero [data-open] img { transform: scale(1.08); animation: load-settle 1100ms var(--ease-expo) 100ms forwards; }
}
@keyframes load-line { to { transform: none; } }
@keyframes load-rise { to { opacity: 1; transform: none; } }
@keyframes load-open { to { clip-path: inset(0); } }
@keyframes load-settle { to { transform: none; } }
```
The timeline: the headline's lines rise from 100 to 990 ms, the picture opens from 150 to 1,150 ms,
the sentence and the button arrive from 420 to 980 ms. All settled by 1.2 s, four things at most.
Preload the display font (`<link rel="preload" as="font" type="font/woff2" crossorigin>`) so the lines
rise in the real face, and give the picture `fetchpriority="high"` and a ground colour sampled from it,
so a slow image opens onto its own colour rather than a blank box. Study: 22 of 91 pages were readable
at the first intro frame and 11 more by 1.2 s; the intros the readers called respectful finished in
about 1-1.2 s; a third of the pages were still unreadable at 4.5 s.
### Masked line reveal (the classic gallery headline)
Each line slides up from behind its own baseline. Because headlines are capped at two lines, you set
@@ -209,20 +335,486 @@ A section whose heading stays put while items pass. Use `position: sticky`; do n
@media (max-width: 800px) { .story { grid-template-columns: 1fr; } .story__title { position: static; } }
```
### List with image preview (work index, menu, team)
### Index with preview, anchored to the row
Rows of large text; hovering a row shows its image in a fixed frame beside or behind the list.
Rows of names; pointing at a row or tabbing to it opens one frame level with that row. The frame
follows the row, never the pointer.
```html
<section class="index">
<ul class="index__list">
<li><a class="index__row" href="/work/harbour-house" data-preview="/img/harbour-house.jpg">
<span class="index__name">Harbour House</span><span class="index__year">2025</span>
<img class="index__thumb" src="/img/harbour-house-small.jpg" alt="" loading="lazy" width="640" height="400">
</a></li>
<!-- … -->
</ul>
<div class="index__frame" aria-hidden="true"><img alt=""></div>
</section>
```
```css
.index { position: relative; }
.index__row { display: flex; justify-content: space-between; align-items: baseline; padding-block: 0.35em; font-size: var(--step-4); }
.index__frame { position: absolute; inset-block: 0; right: 0; width: 34%; pointer-events: none; }
.index__frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity var(--t-ui) ease; }
.index__frame img.is-on { opacity: 1; }
@media (hover: hover) and (pointer: fine) { .index:hover .index__row:not(:hover) { opacity: 0.35; } .index__row { transition: opacity var(--t-hover) ease; } }
.index__list { list-style: none; margin: 0; padding: 0; }
.index__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0 var(--gutter); padding-block: 0.3em; font-size: var(--step-head); color: inherit; text-decoration: none; transition: opacity var(--t-hover) ease; }
.index__thumb { order: -1; flex-basis: 100%; width: 100%; height: auto; margin-bottom: 0.4rem; }
.index__frame { display: none; }
@media (hover: hover) and (pointer: fine) {
.index__list { max-width: 62%; }
.index__thumb { display: none; }
.index__list:has(.index__row:hover, .index__row:focus-visible) .index__row:not(:hover, :focus-visible) { opacity: 0.4; }
.index__frame { display: block; position: absolute; top: 0; right: var(--margin); width: 30%; aspect-ratio: 4 / 3; z-index: 1; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path 320ms var(--ease-out), transform 360ms var(--ease-out); }
.index__frame.is-open { clip-path: inset(0); }
.index__frame img { width: 100%; height: 100%; object-fit: cover; }
}
@media (prefers-reduced-motion: reduce) { .index__frame { transition: none; } }
```
On touch there is no hover, so each row shows its image inline above the title instead.
```js
(() => {
const root = document.querySelector('.index');
if (!root) return;
const frame = root.querySelector('.index__frame');
const picture = frame.querySelector('img');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
let warmed = false;
const show = (row) => {
if (!fine.matches) return;
if (!warmed) { warmed = true; root.querySelectorAll('[data-preview]').forEach((a) => { new Image().src = a.dataset.preview; }); }
picture.src = row.dataset.preview;
const y = row.offsetTop + row.offsetHeight / 2 - frame.offsetHeight / 2;
const max = root.offsetHeight - frame.offsetHeight;
const opening = !frame.classList.contains('is-open');
if (opening) frame.style.transitionProperty = 'clip-path'; // open in place, do not slide in from the top
frame.style.transform = `translateY(${Math.min(max, Math.max(0, y))}px)`;
if (opening) { frame.getBoundingClientRect(); frame.style.transitionProperty = ''; }
frame.classList.add('is-open');
};
const hide = () => frame.classList.remove('is-open');
root.querySelectorAll('.index__row').forEach((row) => {
row.addEventListener('pointerenter', () => show(row));
row.addEventListener('focus', () => show(row));
});
root.addEventListener('pointerleave', hide);
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) hide(); });
})();
```
On touch screens each row shows its own picture above the name. Study: 5 pages navigate work this way;
frames from about 470x270 to large plates behind the list; the other rows dimmed to about 0.3 (0.4 here, the floor for dimmed text). The
image trails and cursor-borne previews seen elsewhere in the study are the gimmick version.
### Lines that rise on scroll
For a section heading or a statement whose line breaks you do not control. Splits after the fonts
load, reveals once, then gives the plain text back so it rewraps normally. Plain text only (no links or
emphasis inside).
```html
<h2 class="head" data-lines>Turned by hand in one small workshop</h2>
```
```css
[data-lines] .line { display: block; overflow: clip; padding-bottom: 0.12em; margin-bottom: -0.12em; }
[data-lines] .line > span { display: block; }
@media (prefers-reduced-motion: no-preference) {
.is-split .line > span { transform: translateY(110%); }
.is-split.is-in .line > span { transform: none; transition: transform 900ms var(--ease-expo) calc(var(--i) * var(--stagger-line)); }
}
```
```js
(() => {
const els = [...document.querySelectorAll('[data-lines]')];
if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const restore = (el) => { el.textContent = el.dataset.text; el.classList.remove('is-split', 'is-in'); };
const split = (el) => {
const text = el.textContent.trim().replace(/\s+/g, ' ');
el.dataset.text = text;
el.textContent = '';
const words = text.split(' ').map((word) => {
const s = document.createElement('span');
s.style.display = 'inline-block';
s.textContent = word;
el.append(s, ' ');
return s;
});
const lines = []; // group the words by the line they landed on
for (const w of words) {
const last = lines.at(-1);
if (last && Math.abs(last.top - w.offsetTop) < 4) last.words.push(w.textContent);
else lines.push({ top: w.offsetTop, words: [w.textContent] });
}
el.textContent = '';
lines.forEach((line, i) => {
const mask = document.createElement('span');
const inner = document.createElement('span');
mask.className = 'line';
inner.style.setProperty('--i', i);
inner.textContent = line.words.join(' ');
mask.append(inner);
el.append(mask, ' ');
});
el.classList.add('is-split');
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target);
e.target.classList.add('is-in');
e.target.querySelector('.line:last-of-type > span')
.addEventListener('transitionend', () => restore(e.target), { once: true });
}
}, { rootMargin: '0px 0px -12% 0px' });
document.fonts.ready.then(() => els.forEach((el) => { split(el); io.observe(el); }));
let width = innerWidth; // a width change before the reveal: give the text back
addEventListener('resize', () => {
if (innerWidth === width) return;
width = innerWidth;
els.filter((el) => el.matches('.is-split:not(.is-in)')).forEach((el) => { io.unobserve(el); restore(el); });
});
})();
```
Study: masked rises from 100-120% below the line, 0.6-1.2 s, 75-120 ms between lines, triggered when
the block's top is 80-95% down the viewport. Reduced motion: the script does not run; the text is plain.
### Opening window
The hand-over from a type screen to the one big picture: the picture first shows as a window in the
middle of a held stage and opens to full-bleed over about one screen of scroll.
```html
<section class="opening">
<div class="opening__stage">
<img src="workshop-wide.jpg" alt="The workshop at first light, benches cleared" width="2400" height="1500">
</div>
</section>
```
```css
.opening__stage { height: 100svh; overflow: clip; }
.opening__stage img { display: block; width: 100%; height: 100%; object-fit: cover; }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.opening { height: 200svh; view-timeline-name: --opening; --inset-y: 20%; --inset-x: 28%; }
.opening__stage { position: sticky; top: 0; animation: opening-window linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
.opening__stage img { animation: opening-settle linear both; animation-timeline: --opening; animation-range: contain 0% contain 75%; }
}
}
@media (max-width: 700px) { .opening { --inset-y: 18%; --inset-x: 8%; } }
@keyframes opening-window { from { clip-path: inset(var(--inset-y) var(--inset-x)); } to { clip-path: inset(0); } }
@keyframes opening-settle { from { transform: scale(1.15); } to { transform: none; } }
```
The section is two screens tall; the stage is held for one, and the window is fully open after three
quarters of it, so the full-bleed picture rests for a moment before the page moves on. Study: 14 pages;
start windows 38-60% of the width; runways 600-1,800 px, and the readers asked for one to two screens.
Without scroll timelines, and under reduced motion, the picture is a full-bleed screen.
### Held picture, passing captions
The pinned story built for photographs: the picture holds on one side while short captions pass on
the other; the caption at the middle of the screen is lit and its picture is shown.
```html
<section class="steps">
<figure class="steps__media">
<div class="steps__pictures">
<img src="seasoning.jpg" alt="Ash boards stacked to season" class="is-on">
<img src="turning.jpg" alt="A leg being turned on the lathe">
<img src="finish.jpg" alt="Oil rubbed into a finished seat">
</div>
<figcaption class="steps__count" aria-hidden="true"></figcaption>
</figure>
<ol class="steps__list">
<li class="steps__item"><h3>Seasoned for a year</h3><p>…</p></li>
<li class="steps__item"><h3>Turned by hand</h3><p>…</p></li>
<li class="steps__item"><h3>Finished with oil</h3><p>…</p></li>
</ol>
</section>
```
```css
.steps { display: grid; grid-template-columns: 7fr 5fr; column-gap: var(--gutter); }
.steps__media { margin: 0; align-self: start; } /* a stretched grid item cannot stick */
.steps__pictures img { display: block; width: 100%; height: auto; }
.steps__list { list-style: none; margin: 0; padding: 0; }
.steps__item { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; }
.js .steps__media { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 0.75rem; }
.js .steps__pictures { position: relative; aspect-ratio: 4 / 5; max-height: 80svh; }
.js .steps__pictures img { position: absolute; inset: 0; height: 100%; object-fit: cover; opacity: 0; transition: opacity 500ms ease; }
.js .steps__pictures img.is-on { opacity: 1; }
.js .steps__item { opacity: 0.4; transition: opacity var(--t-ui) ease; }
.js .steps__item.is-active { opacity: 1; }
@media (max-width: 800px) {
.steps { display: block; } /* the section, not a grid area, contains the sticky picture */
.js .steps__media { height: auto; z-index: 1; background: var(--bg); padding-block: 0.5rem; }
.js .steps__pictures { aspect-ratio: 3 / 2; max-height: 42svh; }
.steps__item { min-height: 60svh; }
}
@media (prefers-reduced-motion: reduce) { .js .steps__pictures img, .js .steps__item { transition: none; } }
```
```js
(() => {
const root = document.querySelector('.steps');
if (!root || !('IntersectionObserver' in window)) return;
const items = [...root.querySelectorAll('.steps__item')];
const pictures = [...root.querySelectorAll('.steps__pictures img')];
const count = root.querySelector('.steps__count');
const show = (i) => {
items.forEach((el, k) => el.classList.toggle('is-active', k === i));
pictures.forEach((el, k) => el.classList.toggle('is-on', k === i));
if (count) count.textContent = `${i + 1} of ${items.length}`;
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) show(items.indexOf(e.target));
}, { rootMargin: '-45% 0px -45% 0px' }); // a band across the middle of the screen
items.forEach((el) => io.observe(el));
show(0);
})();
```
Without JavaScript the pictures and captions are two plain columns. Study: inactive items at 20-40%
(dims of 10-15% read as missing text, so the floor here is 40%), crossfades of 400-600 ms, about one
screen per step; the readers asked for a visible count on any held sequence of more than two steps.
The count is a plain sentence in the body face, not a zero-padded numeral.
### Sideways gallery
A row of pictures that the vertical scroll moves sideways while the stage is held, for a short
collection (eight items or fewer). The native drag row is the base; the pinned version is added only
on wide screens with motion allowed, and only when the row is under three screens long.
```html
<section class="gallery">
<div class="gallery__stage">
<h2 class="head">Eight kitchens, one stool</h2>
<ul class="gallery__track">
<li class="gallery__item"><a href="/kitchens/leith"><img src="leith.jpg" alt="A narrow galley kitchen in Leith" width="1200" height="1500"><span>Leith</span></a></li>
<!-- … -->
</ul>
</div>
</section>
```
```css
.gallery__track { display: flex; gap: var(--gutter); list-style: none; margin: 0; padding: 0 var(--margin); overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--margin); }
.gallery__item { flex: 0 0 min(72vw, 28rem); scroll-snap-align: start; }
.gallery__item img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; }
.gallery.is-pinned .gallery__stage { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center; gap: 2rem; overflow: clip; }
.gallery.is-pinned .gallery__track { overflow: visible; scroll-snap-type: none; }
.gallery.is-pinned .gallery__item { flex-basis: calc(62svh * 0.8); }
```
```js
(() => {
const root = document.querySelector('.gallery');
if (!root) return;
const track = root.querySelector('.gallery__track');
const wide = matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
let dx = 0, queued = false;
const place = () => {
queued = false;
if (!root.classList.contains('is-pinned')) return;
const p = Math.min(1, Math.max(0, -root.getBoundingClientRect().top / dx));
track.style.transform = `translate3d(${(-p * dx).toFixed(1)}px, 0, 0)`;
};
const measure = () => {
root.classList.add('is-pinned'); // measure the row as it would be when pinned
track.style.transform = '';
dx = track.scrollWidth - track.clientWidth;
const pin = wide.matches && dx > 0 && dx < innerHeight * 3;
root.classList.toggle('is-pinned', pin);
root.style.height = pin ? `${innerHeight + dx}px` : ''; // one pixel of scroll moves the row one pixel
place();
};
track.addEventListener('focusin', (e) => { // keyboard: bring the focused item into view
if (!root.classList.contains('is-pinned')) return;
const item = e.target.closest('.gallery__item');
const x = Math.min(dx, Math.max(0, item.offsetLeft - parseFloat(getComputedStyle(track).paddingLeft)));
scrollTo({ top: root.getBoundingClientRect().top + scrollY + x, behavior: 'instant' });
});
addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(place); } }, { passive: true });
addEventListener('resize', measure);
addEventListener('load', measure);
wide.addEventListener('change', measure);
measure();
})();
```
Study: 8 pages pinned a horizontal track; their pages ran a median 25 screens, and the readers' worst
cases spent 9 screens on three cards and 10,000-23,000 px of wheel on one track. Here the speed is 1:1
and the hold ends when the last picture reaches the margin. Phones, narrow windows, reduced motion and
long rows all get the native row with snapping, which also works with touch, trackpads and the
keyboard. Never put the only copy of a call to action inside the moving row.
### Receding hero
As the first screen leaves, its picture sinks a little and darkens while the words lift away a little
faster, so the hero recedes instead of simply scrolling off.
```html
<header class="hero">
<img class="hero__picture" src="kitchen.jpg" alt="…" width="2400" height="1600" fetchpriority="high">
<div class="hero__copy"><h1 class="display">…</h1><p class="lead">…</p></div>
</header>
```
```css
.hero { position: relative; min-height: 100svh; overflow: clip; display: grid; align-items: end; view-timeline-name: --hero; }
.hero__picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hero::after { content: ''; position: absolute; inset: 0; background: var(--ink); opacity: 0; pointer-events: none; }
.hero__copy { position: relative; z-index: 1; padding: var(--margin); }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.hero__picture { animation: hero-sink linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
.hero::after { animation: hero-dim linear both; animation-timeline: --hero; animation-range: exit 0% exit 100%; }
.hero__copy { animation: hero-lift linear both; animation-timeline: --hero; animation-range: exit 0% exit 70%; }
}
}
@keyframes hero-sink { to { transform: translateY(8%) scale(1.06); } }
@keyframes hero-dim { to { opacity: 0.35; } }
@keyframes hero-lift { to { transform: translateY(-6%); opacity: 0; } }
```
The `exit` range runs from the moment the hero's top meets the top of the screen until its bottom
does, which for a first screen is the first screen of scroll. Study: hero pictures that sink and dim as
they leave, a heading scrubbed from 140 to 60 px, a title card that scales down on the phone; the
readers called it quiet and the page "one sheet". Travel stays under 8%; the overlay is a flat ink at
up to 35%, not a gradient. Static where unsupported or reduced.
### Page to page
Navigation between pages of the site with the browser's own view transitions: the old page fades
quickly, the new one rises in, and the picture the visitor clicked travels into place as the new
page's hero. No library, no full-screen curtain.
```css
/* on every page */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
::view-transition-old(root) { animation: vt-leave 200ms var(--ease-exit) both; }
::view-transition-new(root) { animation: vt-arrive 380ms var(--ease-out) 60ms both; }
::view-transition-group(*) { animation-duration: 480ms; animation-timing-function: var(--ease-in-out); }
::view-transition-image-pair(*.picture) { overflow: clip; }
::view-transition-old(*.picture), ::view-transition-new(*.picture) { height: 100%; object-fit: cover; }
@keyframes vt-leave { to { opacity: 0; } }
@keyframes vt-arrive { from { opacity: 0; transform: translateY(16px); } }
```
```html
<!-- on the index: the card picture of one project -->
<img src="/img/leith.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
<!-- on that project's page: its hero picture -->
<img src="/img/leith-wide.jpg" alt="…" style="view-transition-name: kitchen-leith; view-transition-class: picture">
```
Each name may appear once per page. Browsers without cross-document view transitions simply navigate.
Study: 16 pages load a page-transition library and run full-screen curtains of 0.7-2.4 s; 6 use view
transitions. The new page should be readable within half a second.
### Ink fill
One statement that fills from part-ink to full ink as it rises through the screen, line by line. It is
readable before it fills; the fill only sets the pace.
```html
<p class="statement head" data-fill>Nine people, one workshop, no catalogue: every stool is made for the kitchen it goes to.</p>
```
```css
[data-fill] { --fill: var(--ink); color: var(--fill); }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.is-filling > span { display: inline-block; color: color-mix(in oklab, var(--fill) 45%, transparent); animation: ink-fill linear both; animation-timeline: view(); animation-range: cover 20% cover 45%; }
}
}
@keyframes ink-fill { to { color: var(--fill); } }
```
```js
(() => {
if (!CSS.supports('animation-timeline: view()') || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-fill]').forEach((el) => {
const words = el.textContent.trim().split(/\s+/);
el.textContent = '';
words.forEach((word, i) => {
const s = document.createElement('span');
s.textContent = word;
el.append(s, i < words.length - 1 ? ' ' : '');
});
el.classList.add('is-filling');
});
})();
```
Each word watches its own position, so the words of a line fill together as the line moves from three
quarters of the way down the screen to just past half way. Set `--fill` on a dark chapter to its ink.
Study: 14 pages scrub a statement from grey to ink; unread words started at about 25-30% and settled
frames still caught statements half pale, which reads as disabled text. Here: one statement per page,
three to five lines, 32px or larger, starting at 45% (about 3:1 against the ground, enough for large
text). Full ink without scroll timelines or under reduced motion.
### Rolling figures
For pages whose case is a handful of numbers: each digit rolls up to its value once, the columns a
beat apart. The real number stays in the text for screen readers and search.
```html
<p class="figure"><span class="display" data-roll>1987</span> the year the first stool was sold</p>
```
```css
.roll__digits { display: inline-flex; font-variant-numeric: tabular-nums; line-height: 1; }
.roll__col { display: inline-block; height: 1em; overflow: clip; }
.roll__strip { display: flex; flex-direction: column; transform: translateY(calc(var(--d) * -1em)); }
.roll__strip > span { height: 1em; }
@media (prefers-reduced-motion: no-preference) {
.roll:not(.is-in) .roll__strip { transform: none; }
.roll.is-in .roll__strip { transition: transform 1100ms var(--ease-expo) calc(var(--i) * var(--stagger-item)); }
}
```
```js
(() => {
const els = [...document.querySelectorAll('[data-roll]')];
if (!els.length) return;
els.forEach((el) => {
const value = el.textContent.trim();
const said = Object.assign(document.createElement('span'), { className: 'sr-only', textContent: value });
const shown = Object.assign(document.createElement('span'), { className: 'roll__digits' });
shown.setAttribute('aria-hidden', 'true');
let i = 0;
for (const ch of value) {
if (!/\d/.test(ch)) { shown.append(ch); continue; }
const col = Object.assign(document.createElement('span'), { className: 'roll__col' });
const strip = Object.assign(document.createElement('span'), { className: 'roll__strip' });
strip.style.setProperty('--d', ch);
strip.style.setProperty('--i', i++);
for (let n = 0; n < 10; n++) strip.append(Object.assign(document.createElement('span'), { textContent: n }));
col.append(strip);
shown.append(col);
}
el.classList.add('roll');
el.replaceChildren(said, shown);
});
if (!('IntersectionObserver' in window)) { els.forEach((el) => el.classList.add('is-in')); return; }
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}, { rootMargin: '0px 0px -15% 0px' });
els.forEach((el) => io.observe(el));
})();
```
Study: 6 pages roll digit columns (stats, a year, coordinates), about 0.9-1.1 s per column on an
expo-out, staggered by column. Use it for three or four figures at display size, never in running text
and never for a value that changes after load. Reduced motion: the figure is set at its value.
### Accordion
@@ -245,7 +837,7 @@ it in JavaScript.
Things open from where they were triggered (`transform-origin` at the trigger). Nothing enters from
`scale(0)`; start at 0.95–0.98. A centred dialog is the one case that scales from its centre.
## 5. Signature moves
## 7. Signature moves
The direction sheet names one. Each of these was seen repeatedly on the studied gallery sites.
@@ -255,18 +847,31 @@ The direction sheet names one. Each of these was seen repeatedly on the studied
| Shutter image | Hero or showcase image opens from an edge, settling from 1.12 scale | Image shutter recipe |
| Scaling wordmark | A giant wordmark shrinks or grows a few percent as the hero scrolls out | Scroll-linked recipe on `scale` |
| Pinned story | A heading holds while its items scroll past | Sticky recipe |
| Index with preview | Text rows; the hovered row shows its image | List with image preview recipe |
| Index with preview | Text rows; the hovered row shows its image | Index with preview, anchored to the row |
| Curtain footer | The last section is revealed from under the page as you reach the end | `position: sticky; bottom: 0` footer under a main with a background and `z-index` |
| Slow crossfade | Hero image crossfades through 3–4 photographs, 5–6s each | Stacked images, opacity keyframes, pause when the tab is hidden |
| Edge-to-edge marquee | One line of very large type travels sideways, slowly | Duplicate the content, `translateX(-50%)` linear loop, pause on hover and under reduced motion |
| Drag row | A horizontally scrollable row of large images with scroll snapping | `overflow-x: auto; scroll-snap-type: x mandatory`, no library |
| Colour by chapter | Each chapter is its own full-bleed colour field, so the ground changes at section edges; a fixed header takes the ink of the chapter beneath | Section backgrounds in CSS; `base.js` sets `--header-ink` from `data-ink`. Never recolour the whole page from an observer |
| Opening window | A type screen hands over to the big picture: a window in the middle of a held stage opens to full-bleed over about one screen | Opening window recipe |
| Ink-fill statement | One statement fills from 45% to full ink as it rises through the screen | Ink fill recipe |
| Sheet over a held picture | A full-bleed photograph holds for one screen; the next chapter slides up over it as an opaque sheet | A 100svh `position: sticky; top: 0` figure followed by a section with its own background and a higher `z-index`; no script |
| Sideways gallery | A short row of pictures moves sideways while the vertical scroll holds the stage | Sideways gallery recipe |
| Pushed line | One line of very large type is pushed sideways by the scroll, reading whole at the middle of its run | `animation-timeline: view()` on the line, `translateX` from 12% to -12%, linear; static where unsupported |
| Rolling figures | The few figures that make the case roll up to their values once | Rolling figures recipe |
| Carried picture | The picture clicked in a list travels into place as the next page's hero | Page to page recipe |
Pick by subject. A portfolio wants the index with preview; a restaurant wants the slow crossfade; a
product wants the shutter on its hero object. If the move says nothing about the subject, it is
decoration and the page is better without it.
## 6. Reduced motion and performance
Pick by subject, as before: the opening window for a product or a place with one great photograph; the
sheet for chapters that each own a picture; the sideways gallery for a short set of rooms, kitchens or
pieces; the pushed line for a brand whose claim is a word; rolling figures for a report, a fund or a
practice that leads with numbers; the carried picture for a portfolio whose index and case pages are
both built.
## 8. Reduced motion and performance
- `prefers-reduced-motion: reduce` means fewer and gentler, not none: keep opacity and colour changes,
drop travel, scale, parallax and autoplay. All recipes above are written inside
@@ -278,10 +883,11 @@ decoration and the page is better without it.
element that moves.
- `will-change` only on an element that is about to animate, removed afterwards.
- Pause loops when off screen or when the tab is hidden.
- No scroll hijacking, no smooth-scroll library by default, no custom cursor. They cost usability,
which is 30% of an Awwwards score and the line most winners score lowest on.
- No scroll hijacking and no custom cursor. They cost usability, which is 30% of an Awwwards score and
the line most winners score lowest on. Smoothing only through `Stage.smooth()`, which stays off for
touch and reduced motion.
## 7. What gives generated motion away
## 9. What gives generated motion away
- The same fade-up on every block.
- A dozen staggered entrances in the hero.
@@ -290,3 +896,85 @@ decoration and the page is better without it.
- Infinite loops that mean nothing: pulsing dots, floating shapes, shimmering text, rotating badges.
- A reveal that never fires and leaves a blank band in the page. Run `inspect.mjs` after the motion
pass and look at every screenshot for missing content.
### Seen in the latest Sites of the Day
Seen in the study, with how often, and what the house does instead.
- **Preloaders and counters (60 of 99; 26 with a counter).** Half the usable pages were unreadable
2.5 s after load, a third at 4.5 s; the preloaders sat mostly on pages that had already loaded, and
25 of the 29 that loaded in under 3 s still showed nothing readable at 4.5 s. Counters stalled,
outlasted the load or were tweens with nothing behind them. House: no preloader. If a scene must
load, the HTML first screen (headline, picture, action) is there at once and the scene fades in
behind it when ready.
- **Gates (10 of 99): enter, sound, age or gesture before any content.** House: none. Sound is a
control on the page, off by default.
- **Scroll hijacking (16 wheel-driven scenes, 9 of them endless, plus 14 single screens where the wheel
does nothing).** The visitor cannot tell where they are, how much is left or how to get out, and the
keyboard and screen readers get nothing. Usability 6.01 against 6.50 for pages that scroll. House:
native scroll; any scene is driven by scroll position on a sticky stage that ends, with a count.
- **Smooth-scroll libraries (67 of 99).** They make the scrubbed scenes feel weighted, and they also
put a delay between the hand and the page; with a long ease the page keeps sliding after the wheel
stops.
- **Custom cursors (28 of 99).** Followers, image trails, a lens that smears the headline it sits on,
labels that only exist on hover; 10 of the 28 have any reduced-motion rule. House: the system cursor;
labels are on the page.
- **Scramble and typewriter text (11 and 5).** The headline unreadable for four seconds, body copy
garbled mid-scroll, actions held back until the typing ends. House: not used. Real words arrive whole.
- **Long held runways.** Pages with pins average twice the length for fewer words per screen; 17 pages
are 20 screens or longer, some with whole screens that hold only a slow rotation. House: about one
screen per step, three per stage, every held screen carries a line of text.
- **Content held at zero opacity.** Reveals that keep text invisible until the scroll settles, inactive
items dimmed to 10-15%, statements left half pale. House: reveals only below the fold, once, never
longer than 900 ms in view; dims no lower than 40%; fills start at 45%.
- **Motion over words.** Marquees and drifting objects that cross statements, a sliding line that crops
its own words, blur left on text mid-move. House: nothing moves across text the visitor is reading;
a moving line must read whole at some point of its run.
- **Overshoot and bounce (19 stylesheets).** House: still banned on interface elements.
- **Endless loops with no off switch.** Grain, pulsing dots, equalisers, spinning badges, running with no
reduced-motion rule on most pages. House: loops pause off screen and stop under reduced motion.
Where the study's work does something the house bans, the house version: hairline progress bars and
tick rails on held stages become a plain count in the body face ("2 of 4"); zero-padded counters
("01/08") become a plain count or nothing; crossfading gradient grounds become flat chapter colours with
hard cuts; monospace HUD labels become small text in the text face.
## 10. Rules for motion
1. **First frame.** The headline, the sentence and the picture are in the HTML and painted on the first
frame. The load sequence starts without waiting for the load event and is settled by 1.2 s, with
four elements at most.
2. **No preloader, no counter, no gate, no custom cursor, no wheel capture.** Smoothing only through
`Stage.smooth()` (Lenis at lerp 0.1-0.14, off for touch and reduced motion): weight, never a page
that keeps sliding after the hand stops.
3. **Curves.** `--ease-out` for anything under 600ms; `--ease-expo` for reveals of 800ms or more;
`--ease-in-out` for wipes and things that travel; `--ease-exit` only for things leaving for good;
`linear` for scroll-linked motion and loops; plain `ease` for colour. No overshoot.
4. **Durations.** Press 140ms; hover colour 180-200ms; hover that moves 300-400ms; menus 240ms; reveals
700-900ms; picture openings 1,100-1,300ms; exits 60-70% of the matching entrance.
5. **Staggers.** Lines 80-110ms; list items 50-90ms; characters 20-40ms and only in a heading of one to
three words; four steps at most in any one sequence.
6. **Reveals.** Fire once, when the element's top is 85-90% of the way down the viewport; rise 12-32px
or from a line mask; nothing in view stays hidden for more than 900ms; nothing above the fold waits
for scroll.
7. **Scroll scenes are where the page is bold.** The big moves come from the scene library
(`references/scenes.md`): the direction sheet deals two or three per page, each built on
`assets/scenes/stage.js`, each held for one to four screens, each with a phone version and a
finished composition. Inside a scene the scene's own numbers apply (a picture may grow from a
window to the whole screen, type may cross the stage, a camera may fly through a field of
pictures); rules 8 and 10 still hold for the text a visitor is reading.
8. **Outside the scenes, scroll-linked travel stays quiet.** Linear and locked to the scrollbar;
parallax under 8% of the element; scale between 0.94 and 1.15; built with scroll timelines inside
`@supports`, static without them. Other held stages: sticky, about one screen per step, three
screens at most, a line of text on every held screen.
9. **One of each.** One signature move (a dealt scene can be it); one moving picture; one marquee at
most. A quiet section between two scenes, so each one lands.
10. **Text stays readable.** Fills start at 45% ink; dims no lower than 40%; blur at most 8px and gone
once settled; nothing moves across text being read.
11. **Colour by chapter** as hard cuts at section edges; the fixed header's ink follows in 300ms.
12. **Horizontal motion.** The native drag row first; the pinned sideways gallery only for rows under
three screens long, with keyboard support.
13. **Page to page.** View transitions, 480ms at most, off under reduced motion.
14. **Reduced motion.** Every hidden state lives inside `prefers-reduced-motion: no-preference`; the
reduced page is the finished page; loops stop. Two in three award pages skip this, which is a
reason to do it, not a licence to skip it.
+50
View File
@@ -16,6 +16,8 @@ others.
9. What the inspector says about the gallery itself
10. The 25 highest-scored sites and their ideas
11. How the skill was tested
12. The October 2026 study: 100 Sites of the Day
13. The app-screen study
## 1. Method and its limits
@@ -304,3 +306,51 @@ Each of these is now a rule in SKILL.md or a check in the inspector.
Limits: one juror per brief per round, so single scores carry about half a point of noise; the round-5
briefs had been seen in round 4; jurors are models, not people.
## 12. The October 2026 study: 100 Sites of the Day
A second study, made for v2.1, of the latest 100 Awwwards Sites of the Day (10 July to 17 October
2026), with a different aim from the first: where the pictures and footage sit, how pages move, and
the frame (margins, balance, empty space, titles). About three in four of these sites were also in
the first study.
- **Capture.** Headless Chrome at 1440×900 and 390×844 on 18 October 2026: four frames of the first
4.5 seconds after load, every screen while scrolling with real wheel input (one frame at rest per
step; the mid-step frames were cut from the top of the document instead of the viewport, so they
were not used), five phone screens, and a measurement pass in the page: every picture and
clip with its box, ratio, fit and corners; every section's content edges and pictures; blank runs;
the CSS that loaded (curves, durations, keyframes, breakpoints, feature counts); the animations,
transitions and script-driven moves that actually ran; GSAP tweens and scroll triggers where the
page exposed them. Nothing a site owns (code, copy, pictures) is reproduced in the skill; only
measurements and descriptions in our own words.
- **Panel.** One model reader per site studied its frames and measurements, described the idea, the
first screen, the frame, every section, where each picture sits, the motion and what not to copy,
and scored it the Awwwards way. 91 of 99 captures were usable (the rest showed a gate, a bot wall or
a firewall page). Scores ran 6.7 to 7.9 (median 7.45); "top" means 7.5 or more (36 sites), "lowest"
under 7.3 (16).
- **What it changed.** Where pictures and footage go: `imagery.md` §12. What strong pages do with
motion, new tokens, recipes and the gimmicks to avoid: `motion.md` §2, §4–6 and §9–10. Empty space
and the frame: `layout.md` §4–5. New compositions in `library/heroes.json` and `sections.json` and
notes on media and motion in `library/sites.json` (`draw.mjs --site <id>`).
- **Headline numbers.** Pictures fill a median 16% of a scrolled screen, but in whole screens: 42% of
screens hold none and 19% are 90% picture or more. The median page carries 7 pictures (top 8,
lowest 2) and 42% carry footage (half of the top sites, one of the 16 lowest). 94% of pictures have
square corners. Strong ease-outs are the curves that ran (expo and quint out); the median of all
that ran on a site is 420ms. 60 of 99 open on a loading screen, and a third of the usable pages were
still unreadable 4.5 seconds after load, with no gain in the design marks. About 1 text section in
100 leaves half the width empty; the outer margins of strong pages match to a few pixels; 82% of
text sections open with a heading; 15 of 97 first screens never say what the thing is, and those
lose usability points.
- **Limits.** One capture per site on one day; a WebGL scene can render blank in a headless browser;
the scroll-linked frames are partly an artefact of smooth-scroll libraries; one reader per site, so
single scores carry about half a point of noise.
## 13. The app-screen study
For dashboards and app screens (v2.1), a separate study of 23 real product screens chosen by the
owner (17 products, 17 light and 6 dark, at 1440–1512px): each screen measured from its pixels by a
reader with full-resolution crops, six cross-cutting reports (shell, data and charts, tables and
forms, visual language, taste, styles), and a synthesis into `references/app-screens.md` and
`library/app.json` (seven styles, five shells, screen patterns). The screenshots stay private; the
skill carries only the measurements and the rules. Blind test rounds of app screens built with and
without the skill then shaped the warnings at the top of that file.
+174
View File
@@ -0,0 +1,174 @@
# Scroll scenes
The big moments of a page: stretches of scroll where the page stops being a column of sections and
becomes a scene. A camera flies through a field of pictures, a picture grows from a window to the whole
screen, a word buckles under the scroll, a set of photographs orbits and lands in a grid. These are what
visitors remember from award sites, and what a page built from tidy sections alone never has.
The scene library is taken apart from recent Awwwards Sites of the Day (October 2026: 43 sites recorded
frame by frame while scrolling, 261 scenes described, 30 types), rebuilt from scratch in our own code,
tested frame by frame at desktop and phone width and under reduced motion, and reviewed against the
scenes it came from. Nothing a site owns is in them. Until `library/scenes.json` ships the draw deals
none; a page that needs a scene builds one on the stage (section 8), with every rule here.
## Contents
1. What a scene is
2. Putting a scene on a page
3. Filling it
4. Fitting it to the page
5. The phone, reduced motion, no script
6. Checking it
7. Rules for scenes
8. When no scene in the library fits
9. The catalogue
## 1. What a scene is
A tall section whose stage holds still while the page scrolls past it. The stage is
`position: sticky`; the section is (hold + 1) screens tall, so the stage is held for `hold` screens;
a GSAP timeline is scrubbed from 0 to 1 by that scroll. Nothing captures the wheel: the scrollbar always
tells the truth, the keyboard and the trackpad work as everywhere else, and the scene ends.
What a scene is not: a fade-up, a small parallax, a sticky footer, a marquee. Those are the quiet layer
(`references/motion.md`). A scene changes the whole screen.
The direction sheet deals two or three (section 9 of the sheet), from different families, each placed
on the section of the argument it can carry. Build them in step 6, with that section: a scene is
composition, not decoration added at the end.
## 2. Putting a scene on a page
Copy the shared stage and the dealt scene's files into the project (`assets/scenes/stage.css`,
`stage.js`, `<id>.css`, `<id>.js`). Each scene file's header comment lists the markup it expects and
its options; `assets/scenes/demo/<id>.html` is a complete working page to read beside it.
```html
<head>
<script>document.documentElement.classList.add('js')</script>
<link rel="stylesheet" href="scenes/stage.css">
<link rel="stylesheet" href="scenes/<id>.css">
</head>
<body>
…
<section class="scene" data-scene="<id>" data-options='{"hold": 3}' aria-label="…">
<div class="scene-stage">
<!-- the markup from the scene file's header, filled with this page's content -->
</div>
</section>
…
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script src="scenes/stage.js"></script>
<script src="scenes/<id>.js"></script>
<script>Stage.smooth(); Stage.init();</script>
</body>
```
- Load SplitText (`gsap@3.15.0/dist/SplitText.min.js`) only when a scene file says it needs it.
- `Stage.smooth()` adds weighted wheel scrolling (Lenis at lerp 0.12). It stays off for touch and for
reduced motion. Leave it out on a quiet page; the scenes work either way.
- `Stage.init()` starts every `[data-scene]` on the page. A page with two scenes loads both files.
- The versions are pinned. Do not change them to `@latest` or an unpinned major.
## 3. Filling it
A scene is only as strong as what goes in it. The scene file's header and the sheet's "Content" line
say how many pictures and words it takes; these hold for all of them:
- **Pictures of one family.** Same light, same grade, same kind of subject: a field of mismatched stock
photographs breaks the illusion of one space faster than any timing error. Real pictures of the
subject's world (`assets.mjs`), the project's own, or the dealt photographs, in that order
(`references/imagery.md`). Size each to its largest display size, set `width` and `height`.
- **Words that read in a breath.** A held statement is 4 to 12 words; captions are one to four words in
the text face, sentence case. One statement on screen at a time.
- **The scene says something about the subject.** A fly-through of the studio's work, the route a
shipment really takes, the four rooms of the house, the product's change of state. A scene filled
with unrelated pictures is a gimmick and reads as one.
- **Real content only.** If the scene needs eight pictures of the subject and there are three, take the
`--redraw scenes`, never pad it with stand-ins.
## 4. Fitting it to the page
- The scene files read the page's tokens: `--ground`, `--ink`, `--muted`, `--accent`,
`--font-display`, `--font-text` (with fallbacks). Set them on `:root` from the sheet and the scene
takes the page's colour and type. Check the demo against your page, not the other way round.
- Display type in a scene uses the dealt display face and the two-line headline rule; statements stay
at statement size.
- Leave a quiet section between two scenes (a text section, a list, a plain picture) so each one lands.
Two scenes back to back read as one long ride and the second loses.
- A scene on the first screen must be readable at progress 0: the title, the sentence and the action are
there on the first frame (motion rule 1), and the scene starts moving only when the visitor scrolls.
- Give the section a heading. If the design has no visible heading in the scene, the section still
carries one (`aria-label` on the section and a real heading in the finished composition).
## 5. The phone, reduced motion, no script
- **The phone.** Each scene has a phone plan in its file: the same scene with changed numbers, a
simpler version, or `phone: 'rest'`, where the phone shows the finished composition. Touch scrolling
is never smoothed. Look at the phone sheet as carefully as the desktop one.
- **Reduced motion and no script.** The section is an ordinary section showing the finished
composition: the same content, laid out to read without motion. Nothing is hidden waiting for a
script. The inspector's `reduced-hidden` check and the reduced sheet of `scenes.mjs` show it.
- **A failed CDN** falls back to the finished composition too (the stage adds `.is-rest`). The
inspector fails a scene that rests on desktop (`scene-not-live`), so a broken script cannot pass.
## 6. Checking it
```bash
node <skill>/scripts/scenes.mjs <page> # every scene at 0, 10 … 100%, desktop and phone, and reduced motion
node <skill>/scripts/inspect.mjs <page> # the gate; with scenes on the sheet it also films the scroll
```
`scenes.mjs` writes one contact sheet per scene and width to `.taste-skill/scenes/`. Read every one.
At every frame ask: is there one clear thing to look at; can the statement on screen be read; is
anything popping, blank, cut off or crossing the words; is it as bold as it should be? The inspector's
`desktop-film-NN.jpg` shows the whole page's scroll a third of a screen at a time: check that the
scenes and the quiet sections between them make a rhythm, and that nothing between them is empty.
## 7. Rules for scenes
1. **Two or three per page,** from different families, a quiet section between them. The inspector
warns over four (`many-scenes`) and when scenes hold more than 60% of the page (`scene-heavy`).
2. **Each holds one to four screens:** about one screen per beat of the story; a hold with nothing new
to show is cut, not padded.
3. **Native scroll only.** No wheel capture, no locking the page, no scenes that only run on a click.
Smoothing only through `Stage.smooth()`.
4. **Readable at its rest points.** The statement on screen reads whole; nothing passes over text while
it is being read; text on a picture has a flat scrim or a calm area under it.
5. **Inside a scene the scene's numbers apply** (motion rule 7): a picture may grow from a window to the
whole screen, type may cross the stage, a camera may fly through pictures. Outside scenes, the quiet
limits of `references/motion.md` hold.
6. **Transform, opacity, clip-path.** Nothing that forces layout on every frame. Pictures decoded before
they move.
7. **The house rules hold inside scenes:** no monospace captions, no 01/07 counters (a plain "3 of 7" if
a count is needed), no gradients (flat scrims), no shadows or glows, no hairline decoration, no
decorative particles, no violet.
8. **The finished composition is designed,** not an afterthought: it is what reduced motion, a failed
script and some phones see.
## 8. When no scene in the library fits
Redraw first (`--redraw scenes`). If the brief really needs a scene the library does not have, build it
on the same stage so it inherits the guards:
```js
Scenes['my-scene'] = (root, opts = {}) => Stage.scene(root, {
hold: opts.hold ?? 2, // screens the stage is held for
scrub: opts.scrub ?? true, // true when Lenis runs; 0.4-0.8 without it reads as weight
phone: 'same', // or 'rest', or { hold, build } for a phone plan
build(tl, { q, phone }) { // positions are fractions of the scene, 0-1
tl.fromTo(q('.picture'), { clipPath: 'inset(30% 30% 30% 30%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.6 }, 0)
.to(q('.title'), { yPercent: -40, autoAlpha: 0, duration: 0.25 }, 0.7);
},
});
```
and write its CSS finished composition first, then the live layout under `.scene.is-live`. Check it
with `scenes.mjs` like any other.
## 9. The catalogue
(Generated from `library/scenes.json`; the draw deals from it. `node <skill>/scripts/draw.mjs --list
scenes` prints it.)
+1 -1
View File
@@ -104,7 +104,7 @@ effortless, cutting-edge, all-in-one …); the correction ("not just X, it's Y")
## 7. Motion tells
Identical fade-up on every block; a dozen staggered hero entrances; hover lift on every card; bounce
easing; infinite loops that mean nothing; reveals that fail and leave a blank band. See `motion.md` §7.
easing; infinite loops that mean nothing; reveals that fail and leave a blank band. See `motion.md` §9.
## 8. Why removal is not enough
+73 -14
View File
@@ -13,9 +13,10 @@
// [--options 3] show three dealt directions to choose from, write nothing
// [--direction <id>] deal the full sheet for the chosen direction
// node draw.mjs --show reprint the saved direction sheet
// node draw.mjs --redraw hero|type|palette|photos|section:<role>|direction re-deal one slot
// node draw.mjs --list sectors|directions|roles|tones
// node draw.mjs --redraw hero|type|palette|photos|scenes|section:<role>|direction re-deal one slot
// node draw.mjs --list sectors|directions|roles|tones|scenes
// node draw.mjs --site <id> print one studied reference site in full
// node draw.mjs --app --kind <kind> ... deal a style for an app screen instead (see lib/app-draw.mjs)
//
// Zero dependencies. Node 18+.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
@@ -65,11 +66,16 @@ const DEFAULT_SECTIONS = {
// requested role names → the role vocabulary used by library/sections.json
const ROLE_ALIAS = { features: 'features', feature: 'features', benefits: 'features', services: 'features', showcase: 'showcase', product: 'showcase', demo: 'showcase', 'how-it-works': 'how-it-works', how: 'how-it-works', process: 'how-it-works', steps: 'how-it-works', testimonial: 'testimonial', testimonials: 'testimonial', quotes: 'testimonial', proof: 'proof', logos: 'proof', press: 'proof', stats: 'stats', numbers: 'stats', pricing: 'pricing', plans: 'pricing', faq: 'faq', cta: 'cta', footer: 'footer', intro: 'intro', manifesto: 'intro', about: 'about', team: 'about', work: 'work', projects: 'work', index: 'work', 'case-studies': 'work', gallery: 'gallery', images: 'gallery', list: 'list', menu: 'list', schedule: 'list', lineup: 'list', 'line-up': 'list', speakers: 'list', agenda: 'list', programme: 'list', program: 'list', timetable: 'list', rooms: 'list', tickets: 'pricing', price: 'pricing', contact: 'contact', booking: 'contact', newsletter: 'contact', signup: 'contact' };
// App screens (dashboards, admin, settings) have their own library and sheet: scripts/lib/app-draw.mjs.
const savedKind = (() => { try { return JSON.parse(readFileSync(sheetPath, 'utf8')).kind; } catch { return null; } })();
if (opt('app') || ((opt('show') || opt('redraw')) && savedKind === 'app')) await import('./lib/app-draw.mjs');
if (opt('list')) {
const what = opt('list');
if (what === 'sectors') console.log(SECTORS.join('\n'));
else if (what === 'tones') console.log(TONES.join('\n'));
else if (what === 'roles') console.log([...new Set(Object.values(ROLE_ALIAS))].join('\n'));
else if (what === 'scenes') { const all = existsSync(join(LIB, 'scenes.json')) ? load('scenes') : []; for (const sc of all) console.log(`${sc.id.padEnd(34)} ${sc.name} — ${sc.family}; ${sc.summary.split(/(?<=\.)\s/)[0]}`); if (!all.length) console.log('No scene library yet.'); }
else for (const d of load('directions')) console.log(`${d.id.padEnd(30)} ${d.name} — ${d.sectors.join(', ')}`);
process.exit(0);
}
@@ -113,7 +119,7 @@ if (opt('show')) {
console.log(render(saved)); process.exit(0);
}
const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions') };
const L = { directions: load('directions'), heroes: load('heroes'), sections: load('sections'), type: load('type'), palettes: load('palettes'), photos: load('photos'), sites: load('sites'), motions: load('motions'), scenes: existsSync(join(LIB, 'scenes.json')) ? load('scenes') : [] };
const hist = readHist();
const recent = (key, n) => hist.slice(0, n).map((h) => h[key]).filter(Boolean);
@@ -149,6 +155,7 @@ if (opt('redraw')) {
else if (what === 'type') sheet.type = dealType(dir, [sheet.type?.id]);
else if (what === 'palette') sheet.palette = dealPalette(dir, sheet.input, [sheet.palette?.id]);
else if (what === 'photos') sheet.photos = dealPhotos(dir, sheet.input);
else if (what === 'scenes') sheet.scenes = dealScenes(dir, sheet.input, (sheet.scenes || []).map((x) => x.id));
else if (what.startsWith('section:')) {
// section:<role> re-deals at random; section:<role>=<id> takes a named alternative (see --alternatives)
const [roleRaw, wanted] = what.slice(8).split('=');
@@ -162,7 +169,7 @@ if (opt('redraw')) {
if (!chosen) { console.error(`No blueprint with id ${wanted}. List them with --alternatives <role>.`); process.exit(2); }
sheet.sections[i] = chosen.role === role ? chosen : { ...chosen, role, name: `${chosen.name} (borrowed from ${chosen.role})` };
} else sheet.sections[i] = dealSection(dir, role, used, sheet.sections[i - 1]?.shape, sheet.sections[i + 1]?.shape, sheet.input) || sheet.sections[i];
} else { console.error('Unknown slot. Use hero, type, palette, photos, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
} else { console.error('Unknown slot. Use hero, type, palette, photos, scenes, section:<role>, section:<role>=<id> or direction.'); process.exit(2); }
} else {
const sector = String(opt('sector', ''));
if (!SECTORS.includes(sector)) {
@@ -198,7 +205,7 @@ if (opt('redraw')) {
mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector });
if (!opt('redraw') || String(opt('redraw')) === 'direction') writeHist({ at: sheet.at, direction: sheet.direction.id, type: sheet.type?.id, palette: sheet.palette?.id, hero: sheet.hero?.id, sector: sheet.input.sector, scenes: (sheet.scenes || []).map((x) => x.id) });
console.log(render(sheet));
// ---------- dealing ----------
@@ -252,11 +259,41 @@ function deal(input, redraws) {
if (sec) s.sections.push(sec); else s.sections.push({ id: null, role, shape: null, name: role, blueprint: null });
}
s.references = dealReferences(dir, input);
s.scenes = dealScenes(dir, input, []);
return s;
}
// The page's big scroll moments (references/scenes.md): two or three tested scenes from different
// families, each placed on a section of the argument it can carry. Expressive sectors and tones get three.
function dealScenes(dir, input, exclude) {
if (!L.scenes.length) return [];
const has = (need) => input.have.includes(need) || need === 'photography';
const fits = (sc) => !exclude.includes(sc.id) && (sc.sectors || ['*']).some((x) => x === '*' || x === input.sector) && (sc.needs || []).every(has);
let pool = L.scenes.filter(fits);
if (pool.length < 2) pool = L.scenes.filter((sc) => !exclude.includes(sc.id) && (sc.needs || []).every(has));
if (!pool.length) return [];
const loud = ['agency', 'studio-portfolio', 'personal-portfolio', 'fashion', 'culture', 'music', 'event', 'architecture', 'physical-product', 'real-estate', 'hospitality'].includes(input.sector) || ['bold', 'playful', 'luxury', 'editorial'].includes(input.tone);
const quiet = ['quiet', 'technical'].includes(input.tone);
const n = Math.min(pool.length, quiet ? 2 : loud ? 3 : 2);
const recentIds = hist.slice(0, 4).flatMap((h) => h.scenes || []);
let rest = fresh(pool, recentIds);
const out = []; const families = new Set(); const usedRoles = new Set();
while (out.length < n) {
let cands = rest.filter((sc) => !families.has(sc.family) && !(sc.position === 'opening' && out.some((o) => o.position === 'opening')) && !(sc.position === 'closing' && out.some((o) => o.position === 'closing')));
if (!cands.length) cands = pool.filter((sc) => !out.includes(sc) && !families.has(sc.family));
if (!cands.length) break;
const sc = pick(cands); out.push(sc); families.add(sc.family); rest = rest.filter((x) => x !== sc);
}
// the section each scene carries: the first role of the argument it suits that no other scene has taken
const roles = ['hero', ...input.sections];
return out.map((sc) => {
const role = sc.position === 'opening' ? 'hero' : roles.find((r) => (sc.roles || []).includes(r) && !usedRoles.has(r) && r !== 'hero') || null;
if (role) usedRoles.add(role);
return { ...sc, carries: role };
}).sort((a, b) => (a.carries ? roles.indexOf(a.carries) : 99) - (b.carries ? roles.indexOf(b.carries) : 99));
}
function dealHero(dir, input, exclude) {
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id));
if (!pool.length) pool = L.heroes.filter((h) => (h.directions || []).includes(dir.id));
// the direction's own list, and any hero written for this direction since (the October 2026 study added some)
let pool = L.heroes.filter((h) => (dir.heroes || []).includes(h.id) || (h.directions || []).includes(dir.id));
if (!pool.length) pool = L.heroes;
if (imageryMode(input) !== 'interface' && !input.have.includes('product-ui')) { const plain = pool.filter((h) => !softwareOnly(h)); if (plain.length) pool = plain; }
const ok = pool.filter((h) => !exclude.includes(h.id) && (h.needs || []).every((n) => n === 'photography' || input.have.includes(n)));
@@ -395,9 +432,11 @@ function dealSection(dir, role, usedIds, prevShape, nextShape, input) {
}
function dealReferences(dir, input) {
const named = (dir.seenIn || []).map((n) => L.sites.find((s) => s.id === n || s.name === n)).filter(Boolean);
const sameKind = L.sites.filter((s) => s.kind === input.sector && s.score >= 7.2 && !named.includes(s));
// the October 2026 panel scored on a lower scale (its top band starts at 7.5, the first panel's at about 7.6)
const good = (s, a, b) => s.score >= (s.panel === '2026-10' ? b : a);
const sameKind = L.sites.filter((s) => s.kind === input.sector && good(s, 7.2, 7.3) && !named.includes(s));
const out = [...shuffle(named).slice(0, 2), ...shuffle(sameKind).slice(0, 1)];
return out.length ? out : shuffle(L.sites.filter((s) => s.score >= 7.6)).slice(0, 2);
return out.length ? out : shuffle(L.sites.filter((s) => good(s, 7.6, 7.5))).slice(0, 2);
}
// ---------- rendering ----------
@@ -405,6 +444,8 @@ function siteCard(s, full) {
const lines = [`- **${s.name}** (${s.source}, studied score ${s.score}) \`${s.id}\``, ` Idea: ${s.idea}`, ` Hero: ${s.hero}`];
if (full) {
lines.push(` Type: ${s.type}`, ` Colour: ${s.colour}`, ` Imagery: ${s.imagery}`, ` Layout: ${s.layout}`);
if (s.media) lines.push(` Pictures and footage: ${s.media}`);
if (s.motion) lines.push(` Motion: ${s.motion}`);
for (const sec of s.sections || []) lines.push(` Section (${sec.role}): ${sec.description}`);
for (const t of s.steal || []) lines.push(` Worth taking: ${t}`);
lines.push(` URL: ${s.url}`);
@@ -471,7 +512,7 @@ function render(s) {
? `\`${ASSETS_CMD} "${qs[0]}"\`${qs.length > 1 ? `, then ${qs.slice(1).map((q) => `"${q}"`).join(' and ')}` : ''}, then queries from your inventory (its street, its materials, its trade's tools and history)`
: `\`${ASSETS_CMD} "<a place, material or object from your inventory>"\``;
const MODE = {
interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
interface: '**This subject is software and there are no screenshots, so its pictures are its own interface.** Wherever this sheet says photograph, image or object, build a believable fragment of the product in HTML at real size (real labels, numbers that add up, one item hovered or selected) by the app-screen rules (references/app-screens.md: deal its style once with `node <skill>/scripts/draw.mjs --app --kind <kind> --keep palette,type --out .taste-skill/app`, and build it with assets/app.css and assets/charts.js), put the attribute `data-ui` on its root, and crop or scale it the way the blueprint treats a photograph: a full-bleed close crop, a tile, the fixed half of a split. Where a fragment makes no sense, use a flat colour field with type on it. Never a fake dashboard of grey bars, never a device frame. If the product works on something physical (ships, plants, buildings, a city), one real picture of that world can carry a section: find it with `' + ASSETS_CMD + ' "<that world>"`, download it with `--get`, credit it on the page, and never let it pass for the product or its customers.',
'no-product-photo': '**There are no photographs of the product and none can be faked.** Wherever this sheet calls for a picture of the object, draw the object: its pieces as flat shapes in inline SVG, large, in one graphic language and in the colours that make it worth buying (a glaze, a finish, a material), never grey placeholders (references/imagery.md §8). **Around the object, use real pictures of its world** from the open collections: its material up close, its town in old postcards, its craft in engravings, a museum piece of the same kind, never one a visitor could take for the product itself (references/imagery.md §3). Start with ' + find + '. Download what you use with `--get` and credit every one on the page (`--credits` prints the footer line). Type at scale and full colour fields carry the rest; the dealt photographs only where they plainly do not pretend to be the product. In your report, say that product photography is needed and which drawings it will replace, and list every found picture with its source and licence.',
atmosphere: '**The project has no photographs of its own yet.** First choice: **real pictures of the subject\'s world from the open collections**: its city and street in old postcards and photographs, an old map of the neighbourhood, its materials up close, its trade in engravings, museum objects of its kind (references/imagery.md §3). Start with ' + find + '. Download what you use into the project with `--get` and credit every one on the page (`--credits` prints the footer line). They show the world around the client, never the client\'s own room, dish, product, person or team. Second, for what no honest picture exists of: draw the subject\'s own things in code, large, in one flat graphic language and the palette\'s real colours (its tiles, its windows, its objects; references/imagery.md §8). Third: the dealt photographs, only where an honestly generic picture fits (the sea, the weather, a material), never one a visitor could take for the thing itself and never an unrelated scene. One treatment for all of them. If this direction is built on photographs, plan the set before building (four to six pictures of one family) and reuse them at different crops and scales instead of finding or drawing a new picture for every slot; keep drawings as `<symbol>` + `<use>` or generate them with a small script so the page stays light. List every found picture (with its source and licence), every dealt photograph and every drawing that stands in for photography in your report.',
own: '**Use the images that belong to the project in every picture slot.** For slots they cannot fill, real pictures of the subject\'s world from the open collections come next (start with ' + find + '; credit them on the page), then the dealt photographs.',
@@ -507,17 +548,35 @@ function render(s) {
});
p();
if (s.motion) { p(`## 8. Motion — ${s.motion.name}`); p(s.motion.description); p('This is the signature move, the one loud moment. Around it the page still needs the rest of the motion layer (SKILL.md step 9): an entrance for the parts of every section, feedback on every control, and one moving picture. The inspector fails a page that barely moves. Recipes: references/motion.md.'); p(); }
if (d.richness) { p('## 9. Where the richness comes from'); p(d.richness); p(); }
if (d.pitfalls) { p('## 10. How this direction goes wrong'); p(d.pitfalls); p(); }
if (s.scenes?.length) {
p('## 9. Scroll scenes');
p(`The page's big moments: ${s.scenes.length} scroll scenes, tested and ready to fill. They are part of the composition, so build them in step 6 with the section they carry, not as a motion afterthought. Each runs on the shared stage (assets/scenes/stage.js and stage.css, with GSAP and Lenis from the pinned CDN lines in stage.js) and ships its own finished composition for reduced motion and for phones that rest. How to put one on a page, fill it and check it: references/scenes.md. Check them with \`node <skill>/scripts/scenes.mjs <page>\` and look at every sheet.`);
for (const sc of s.scenes) {
p();
p(`### ${sc.name} (\`${sc.id}\`)${sc.carries ? ` — carries the ${sc.carries === 'hero' ? 'first screen' : sc.carries + ' section'}` : ' — a transition between two sections'}`);
p(sc.summary);
if (sc.place) p(`Where: ${sc.place}`);
if (sc.content) p(`Content: ${sc.content}`);
if (sc.options) p(`Options (data-options): ${sc.options}`);
p(`Files: \`<skill>/assets/scenes/${sc.id}.js\` and \`${sc.id}.css\`; a working example: \`<skill>/assets/scenes/demo/${sc.id}.html\`.${sc.phone ? ' On a phone: ' + sc.phone : ''}`);
}
p();
p('Leave a quiet section between two scenes so each one lands. A scene whose content you cannot fill honestly is redrawn (`--redraw scenes`), never faked.');
p();
}
if (d.richness) { p('## 10. Where the richness comes from'); p(d.richness); p(); }
if (d.pitfalls) { p('## 11. How this direction goes wrong'); p(d.pitfalls); p(); }
if (s.references?.length) {
p('## 11. Real references');
p('## 12. Real references');
p('Studied sites that work this way. Take the principle, never the specifics: no names, copy, images or signature assets of theirs.');
for (const r of s.references) p(siteCard(r, false));
p('Full notes on any of them: `node draw.mjs --site <id>`.'); p();
}
p('## Always');
p('Headlines two lines or fewer at every width. No eyebrow labels, no monospace, no 01/02/03, no thin rules, no gradients, no shadows at rest,');
p('no cream-and-terracotta, no violet. `inspect.mjs` measures all of it.');
p('no cream-and-terracotta, no violet. The frame: one h1 on the first screen that says what this is, equal outer margins, no');
p('section huddled on one side with the other half empty, no blank stretch over half a screen, a heading on every section.');
p('`inspect.mjs` measures all of it.');
if (s.allow?.length) p(`This direction is allowed: ${s.allow.join(', ')} (the inspector reads this from the saved sheet).`);
p();
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
+118 -15
View File
@@ -1,16 +1,25 @@
#!/usr/bin/env node
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
//
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--json]
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]
//
// --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel
// input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction
// that dealt scroll scenes switches it on by itself.
//
// --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a
// direction dealt with draw.mjs --app switches it on by itself.
//
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
// Widths sharing a viewport class receive width-qualified screenshot filenames.
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
import { probe, frame } from './lib/probe.mjs';
import { judge } from './lib/rules.mjs';
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';
import { viewportClass as name, screenshotLabel, isInspectionScreenshot } from './lib/inspection-files.mjs';
const args = process.argv.slice(2);
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
@@ -19,14 +28,17 @@ const outDir = resolve(flag('out', '.taste-skill/inspect'));
const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean);
let allow = flag('allow', '').split(',').filter(Boolean);
const asJson = has('json');
let appMode = has('app');
let film = has('film');
let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default
const target = args[0];
if (!target || target === '-h' || target.startsWith('--')) {
console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--json]');
console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]');
process.exit(2);
}
// Allowances granted by the drawn direction (written by draw.mjs) apply automatically.
for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) {
if (existsSync(p)) { try { allow = [...new Set([...allow, ...(JSON.parse(readFileSync(p, 'utf8')).allow || [])])]; } catch {} break; }
if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; }
}
// A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to
// open a file path (some sandboxes show Node and the browser different views of the disk) and relative
@@ -51,11 +63,10 @@ if (!/^https?:\/\//.test(target)) {
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
mkdirSync(outDir, { recursive: true });
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
for (const f of readdirSync(outDir)) if (isInspectionScreenshot(f)) unlinkSync(join(outDir, f));
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
@@ -76,6 +87,22 @@ const MOTION_RECORDER = `(() => {
window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; };
})();`;
// Is the page's title on screen and readable: in the viewport, not faded, not masked away?
const TITLE_SEEN = `(() => {
const h = document.querySelector('h1'); if (!h) return { found: false };
const r = h.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' };
if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business
let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; }
if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' };
// text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre
const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect();
const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2));
const hit = document.elementFromPoint(x, y);
if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; }
return { found: true, visible: true };
})()`;
async function sheet(frames, cols, cellW, cellH, path) {
const rows = Math.ceil(frames.length / cols);
await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 });
@@ -91,12 +118,17 @@ try {
for (const w of widths) {
const h = HEIGHT[w] || Math.round(w * 0.625);
const label = name(w);
const imageLabel = screenshotLabel(w, widths);
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
await sleep(600); // about 1.2s after the load event: is the title there to read yet?
const early = await page.eval(TITLE_SEEN).catch(() => null);
// a dev server is not what ships: bundlers and minifiers change the CSS a visitor receives
const devServer = await page.eval(`(() => { const s = [...document.scripts].map((x) => x.src || '').join(' '); if (/@vite\\/client|webpack-dev-server|\\/_next\\/static\\/development\\/|react-refresh|webpack-hmr/.test(s)) return true; try { return !!(window.__NEXT_DATA__ && window.__NEXT_DATA__.buildId === 'development'); } catch { return false; } })()`).catch(() => false);
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(1400); // let the load choreography finish before judging the first screen
const fold = join(outDir, `${label}-fold.jpg`);
await sleep(800); // let the load choreography finish before judging the first screen
const fold = join(outDir, `${imageLabel}-fold.jpg`);
await page.screenshot({ path: fold, quality: 82 });
const metrics = await page.eval(probe);
@@ -118,6 +150,44 @@ try {
await sleep(350);
const after = await page.eval(probe).catch(() => metrics);
const m = after.headings.length >= metrics.headings.length ? after : metrics;
const lay = await page.eval(layout).catch(() => null);
const appM = appMode ? await page.eval(app).catch(() => null) : null;
if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null);
// scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room
const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : [];
const sceneFindings = [];
if (sceneState.length) {
const idle = sceneState.filter((s) => !s.live && !s.rest);
if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of <body> (references/scenes.md, "Putting a scene on a page").' });
const atRest = sceneState.filter((s) => s.rest);
if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' });
if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' });
const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0);
if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' });
}
// --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a
// scroll scene shows its in-between states, not only where each screen lands.
const filmSheets = [];
if (film && w >= 1100) {
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
await sleep(700);
const shots = []; let lastY = -1, still = 0;
for (let i = 0; i < 96; i++) {
shots.push(await page.screenshot({ quality: 70 }));
await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 });
await sleep(420);
const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1);
if (y === lastY) { if (++still >= 2) break; } else still = 0;
lastY = y;
}
for (let i = 0; i < shots.length; i += 24) {
const p = join(outDir, `${imageLabel}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
filmSheets.push(p);
}
}
const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4;
const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390;
@@ -125,11 +195,25 @@ try {
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
const sheets = [];
for (let i = 0; i < frames.length; i += perSheet) {
const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
const p = join(outDir, `${imageLabel}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
sheets.push(p);
}
const findings = judge(m, { width: w, allow });
let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
if (devServer && !allow.includes('dev-server')) findings.push({ level: 'WARN', id: 'dev-server', width: w, message: 'This page is served by a development server (Vite, webpack or Next in dev mode).', fix: 'Run the last gate on the production build too (vite build && vite preview, next build && next start, or the built index.html): minifiers and bundlers change the CSS a visitor receives.' });
if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f))));
if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' });
}
// a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s
if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' });
if (lay) findings.push(...judgeLayout(lay, { width: w, allow }));
if (appMode) {
findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title');
if (appM) findings.push(...judgeApp(appM, m, { width: w, allow }));
}
// what only shows up while scrolling: text hidden under something, and screens with almost nothing on them
const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' });
@@ -150,11 +234,30 @@ try {
else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
}
if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, consoleErrors: page.consoleErrors.slice(0, 5) });
report.findings.push(...findings);
report.images.push({ viewport: label, width: w, fold, pages: sheets });
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) });
report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings));
report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets });
await page.close();
}
// Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an
// animation that will not run. Two in three studied award pages had no reduced-motion rules at all.
if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) {
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
try {
await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 });
const hidden = await rp.eval(`(() => {
const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = w.nextNode(); n; n = w.nextNode()) {
const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue;
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue;
let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; }
if (gone || o < 0.1) out.push(t.slice(0, 40));
}
return out;
})()`).catch(() => []);
if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' });
} finally { await rp.close(); }
}
} finally {
await browser.close();
if (server) server.close();
@@ -198,7 +301,7 @@ for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN
}
line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:');
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); }
for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(` ${'scroll film'.padEnd(15)} ${rel(p)} (6 frames a row, a third of a screen apart)`); }
line('');
line(`report: ${rel(join(outDir, 'report.json'))}`);
line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`);
+203
View File
@@ -0,0 +1,203 @@
// tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox).
//
// node draw.mjs --app --kind <kind> --brief "one line" [--screens overview,list,detail,settings]
// [--theme light|dark] [--keep palette,type] [--options 3] [--style <id>] [--seed N]
// node draw.mjs --show reprint the saved sheet
// node draw.mjs --redraw style|shell|screen:<name>
// node draw.mjs --app --list kinds|styles|screens
//
// Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not
// end up in the same default look. The library (library/app.json) was distilled from a study of real
// product screens; it holds styles (tokens and the one move that makes each recognisable), shells and
// screen patterns.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { homedir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8'));
const argv = process.argv.slice(2);
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
const outDir = resolve(String(opt('out', '.taste-skill')));
const sheetPath = join(outDir, 'direction.json');
const KINDS = Object.keys(LIB.kinds);
// signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens
const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))];
const SIGNATURES = LIB.patterns.filter((p) => p.signature);
const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); };
if (opt('list')) {
const what = opt('list');
if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`);
else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`);
else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`);
else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`);
process.exit(0);
}
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
const startSeed = seed;
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } };
const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style);
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); }
function stylePool(input) {
const pref = LIB.kinds[input.kind]?.styles || [];
let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id));
if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; }
const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind));
if (fit.length >= 2) pool = fit;
return pool;
}
function dealStyle(input, exclude = []) {
let pool = stylePool(input).filter((s) => !exclude.includes(s.id));
if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id));
const fresh = pool.filter((s) => !recentStyles.includes(s.id));
return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles);
}
// The style's own shell usually carries part of its move, so it is dealt about two times in three.
function dealShell(style, input, exclude = []) {
const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id));
const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id));
if (own && (!alts.length || rnd() < 0.66)) return own;
if (alts.length) return pick(alts);
const rest = LIB.shells.filter((s) => !exclude.includes(s.id));
return pick(rest.length ? rest : LIB.shells);
}
function dealScreen(name, style, input, exclude = []) {
let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id));
// a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a
// leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead
pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind));
return pool.length ? pick(pool) : null;
}
function deal(input) {
const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input);
if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); }
const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] };
for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) });
return s;
}
let sheet;
if (opt('redraw') && saved?.kind === 'app') {
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1;
const what = String(opt('redraw'));
if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); }
else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]);
else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; }
else { console.error('Redraw style, shell or screen:<name>.'); process.exit(2); }
} else {
const kind = String(opt('kind', ''));
if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n ${KINDS.join(', ')}`); process.exit(2); }
const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']);
const bad = screens.filter((x) => !SCREENS.includes(x));
if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); }
const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null };
if (opt('options')) {
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = [];
let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool;
while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); }
console.log(`# ${out.length} app styles dealt for this brief\n`);
console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style <id>` (and without --options).\n');
for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`);
process.exit(0);
}
sheet = deal(input);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id });
console.log(render(sheet));
process.exit(0);
function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
// A Google Fonts link for the style's faces (all of them are free there).
function fontLink(f) {
const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';');
const fams = [fam(f.family, f.weights || [400, 500, 600])];
const second = f.second && f.second.split(',')[0].trim();
if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500]));
return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n<link href="https://fonts.googleapis.com/css2?${fams.join('&')}&display=swap" rel="stylesheet">`;
}
function render(s) {
const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x);
p(`# APP SHEET — ${st.name}`);
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`);
if (s.input.brief) p(`Brief: ${s.input.brief}`);
p();
p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the');
p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is');
p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them');
p('as given rather than rounding them towards your habits.');
p();
p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p();
if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`);
if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.');
if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`);
p();
// the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3)
if (st.identity?.length) {
p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to');
p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.');
for (const x of st.identity) p('- ' + x);
p();
}
const sigs = signaturesFor(s.input.brief);
if (sigs.length) {
p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):');
for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`);
p();
}
const f = t.font || {};
if (!s.input.keep.includes('palette')) {
p('## 2. Tokens'); p('```css'); p(':root {');
for (const [k, v] of Object.entries(t)) {
if (k === 'font' || k === 'density') continue;
if (typeof v === 'string') p(` --${kebab(k)}: ${v};`);
else if (typeof v === 'number') p(` --${kebab(k)}: ${v}px;`);
else if (k === 'chart') v.forEach((c, i) => p(` --chart-${i + 1}: ${c};`));
else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(` --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`);
else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(` --radius-${n}: ${r}px;`);
}
if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(` --fs-${n}: ${v}px;`);
p('}'); p('```');
p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`);
p();
} else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); }
if (!s.input.keep.includes('type') && f.family) {
p(`## 3. Type — ${f.family}`);
p('```html'); p(fontLink(f)); p('```');
p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`);
p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p();
}
p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint);
if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.');
if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p();
p('## 5. Screens');
for (const sc of s.screens) {
p();
if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; }
p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`);
}
p();
p('## 6. Always');
p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,');
p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,');
p('rainbow charts, emoji, lorem or Acme. `<skill>/assets/app.css` and `<skill>/assets/charts.js` are a neutral start set to these');
p('rules. Check with `node <skill>/scripts/lint.mjs <files>` and `node <skill>/scripts/inspect.mjs <page>`: this sheet switches both');
p('to app rules.');
p();
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
return o.join('\n');
}
+47 -4
View File
@@ -1,9 +1,9 @@
// Zero-dependency Chrome DevTools Protocol client.
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
// Requires Node 22+. No npm install.
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir, hostname } from 'node:os';
import { join } from 'node:path';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -37,6 +37,30 @@ export function findChrome() {
return null;
}
// Each profile carries an owner file naming the machine and the two processes that use it (the run and
// its Chrome). A later run removes a profile left by a killed run only when that file names this machine
// and neither process is alive. A profile without one (made before 2.1.4, or by another tool) is never touched.
const OWNER = 'taste-skill-owner.json';
function alive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
}
export function sweepProfiles(base) {
let names;
try { names = readdirSync(base); } catch { return; }
for (const name of names) {
if (!name.startsWith('ad-chrome-')) continue;
const p = join(base, name);
try {
const o = JSON.parse(readFileSync(join(p, OWNER), 'utf8'));
if (o.host !== hostname() || !Number.isInteger(o.pid) || alive(o.pid) || alive(o.chrome)) continue;
rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
} catch {}
}
}
export class Browser {
static async launch({ width = 1440, height = 900, extraArgs = [] } = {}) {
if (typeof WebSocket === 'undefined') {
@@ -46,9 +70,26 @@ export class Browser {
if (!exe) {
throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
}
const dir = mkdtempSync(join(tmpdir(), 'ad-chrome-'));
// A throwaway profile per run, its cache capped (each run starts cold anyway), removed on exit. It is
// claimed with an owner file before Chrome starts, so another run's sweep can tell when it was abandoned.
const base = process.env.TASTE_SKILL_TMP || tmpdir();
sweepProfiles(base);
const dir = mkdtempSync(join(base, 'ad-chrome-'));
const claim = (chrome) => {
try { writeFileSync(join(dir, OWNER), JSON.stringify({ host: hostname(), pid: process.pid, chrome })); } catch {}
};
claim();
// On exit Chrome still holds the profile's files: stop it first, then remove the folder.
let pid = 0;
const drop = () => {
try { if (pid) { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); else process.kill(pid, 'SIGKILL'); } } catch {}
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
};
process.once('exit', drop);
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { drop(); process.exit(130); });
const args = [
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
'--disk-cache-size=16777216', '--media-cache-size=16777216',
'--no-first-run', '--no-default-browser-check', '--mute-audio', '--hide-scrollbars',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows', '--disable-features=Translate,MediaRouter',
@@ -56,6 +97,8 @@ export class Browser {
'--enable-unsafe-swiftshader', `--window-size=${width},${height}`, ...extraArgs, 'about:blank',
];
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
pid = proc.pid;
claim(pid);
const wsUrl = await new Promise((resolve, reject) => {
let buf = '';
const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
@@ -0,0 +1,9 @@
export const viewportClass = (width) => (width >= 1100 ? 'desktop' : width >= 600 ? 'tablet' : 'mobile');
export function screenshotLabel(width, widths) {
const label = viewportClass(width);
const sameClass = new Set(widths.filter((candidate) => viewportClass(candidate) === label));
return sameClass.size > 1 ? `${label}-${width}` : label;
}
export const isInspectionScreenshot = (file) => /^(desktop|tablet|mobile)(?:-\d+)?-(fold|page-\d+|film-\d+)\.jpg$/.test(file);
+363 -3
View File
@@ -437,7 +437,8 @@ export function probe() {
viewport: { w: W, h: H },
page: { height: pageH, screens: round(pageH / H, 1), words: bodyText.split(/\s+/).filter(Boolean).length,
img: document.images.length, video: document.querySelectorAll('video').length, canvas: document.querySelectorAll('canvas').length, svg: document.querySelectorAll('svg').length, sections: document.querySelectorAll('section').length,
horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10) },
horizontalOverflow: doc.scrollWidth > W + 2 ? doc.scrollWidth - W : 0, overflowCulprits, uiFragments, uiAbuse, realPictures, videoCount, uiPictures, pictureSamples, blendText, sideScrollers, brokenImages, emoji: [...new Set(emoji)].slice(0, 10),
videosNoPoster: [...document.querySelectorAll('video')].filter((v) => !v.getAttribute('poster') && v.getBoundingClientRect().width >= 160).length },
hero: heroOut, h1,
headings: headingStats.slice(0, 40),
headingSummary: { count: headingStats.length, maxLines: Math.max(0, ...headingStats.map((h) => h.lines)), over2Lines: headingStats.filter((h) => h.lines > 2 && h.kind !== 'statement').length, statements: headingStats.filter((h) => h.kind === 'statement').length, centered: headingStats.filter((h) => h.align === 'center').length, medianWords: below.length ? below.map((h) => h.words).sort((a, b) => a - b)[Math.floor(below.length / 2)] : null, mixedFontHeadings: headingStats.filter((h) => h.mixedFonts).length },
@@ -498,7 +499,8 @@ export function frame() {
if (srOnly) continue;
// the boxes that clip this text (overflow other than visible, or a clip-path)
const clips = []; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const pcs = getComputedStyle(p); if (pcs.overflowY !== 'visible' || pcs.overflowX !== 'visible' || pcs.clipPath !== 'none') clips.push(p.getBoundingClientRect()); }
const pinned = size >= 32 && pinnedLow(el);
// a scroll scene moves pictures across its own held stage by design; its text is judged by the scene rules
const pinned = size >= 32 && pinnedLow(el) && !el.closest('.scene.is-live');
// lines of one heading set as separate spans overlap each other's glyph boxes; that is not a collision
const unit = el.closest('h1, h2, h3, h4, h5, h6, p, blockquote');
const range = document.createRange(); let total = 0, hidden = 0, cutTop = false, area = 0, over = 0;
@@ -547,10 +549,368 @@ export function frame() {
}
}
// 3. the large text in view, to spot one statement held on screen for screen after screen
const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
const big = [...new Set([...document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote')].filter((el) => { const r = el.getBoundingClientRect(); return !el.closest('.scene.is-live') && parseFloat(getComputedStyle(el).fontSize) >= 32 && r.top >= -4 && r.bottom <= H + 4 && r.height > 0; }).map((el) => (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60)).filter((t) => t.length >= 4))];
return { covered, clipped, pinnedOver, filled: Math.round((filled / samples) * 100) / 100, big, y: Math.round(scrollY) };
}
// layout() runs once, back at the top after the page has been scrolled through (so reveals have fired).
// It measures the frame of the page rather than its details: the two outer margins, sections whose content
// huddles on one side, blank stretches with nothing in them, the title, untitled sections, text pressed
// against the edge of the screen and how many left edges the text uses.
export function layout() {
const W = innerWidth, H = innerHeight, SY = scrollY;
const D = document.documentElement, B = document.body;
const pageH = Math.max(D.scrollHeight, B.scrollHeight);
const pct = (arr, p) => { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * p)))]; };
const shown = (el) => { for (let p = el, i = 0; p && p !== B && i < 30; p = p.parentElement, i++) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; if (i < 4 && (cs.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%\)/.test(cs.clipPath))) return false; } return true; };
const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; };
const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
const boxes = [];
// A range is layout geometry: an ellipsised or scrolled line reaches past what the reader sees. Clip
// each text box to the window its element's overflow allows (its own box and every clipping ancestor).
const clipMemo = new Map();
const clipBox = (el) => {
if (!el || el === B || el === document.documentElement) return null;
if (clipMemo.has(el)) return clipMemo.get(el);
const cs = getComputedStyle(el);
let box = cs.position === 'fixed' ? null : clipBox(el.parentElement);
if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') {
const r = el.getBoundingClientRect();
const own = { l: cs.overflowX !== 'visible' ? r.left : -Infinity, r: cs.overflowX !== 'visible' ? r.right : Infinity, t: cs.overflowY !== 'visible' ? r.top : -Infinity, b: cs.overflowY !== 'visible' ? r.bottom : Infinity };
box = box ? { l: Math.max(box.l, own.l), r: Math.min(box.r, own.r), t: Math.max(box.t, own.t), b: Math.min(box.b, own.b) } : own;
}
clipMemo.set(el, box);
return box;
};
// text, one box per text node (its line boxes merged)
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) {
const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t.length < 2) continue;
const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue;
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
if (!shown(el)) continue;
const c = clipBox(el);
const l = c ? Math.max(r.left, c.l) : r.left, rr = c ? Math.min(r.right, c.r) : r.right;
const tp = c ? Math.max(r.top, c.t) : r.top, bt = c ? Math.min(r.bottom, c.b) : r.bottom;
if (rr - l < 2 || bt - tp < 2) continue;
const cs = getComputedStyle(el);
boxes.push({ kind: 'text', el, l, r: rr, t: tp + SY, b: bt + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
}
// pictures, drawings and controls
for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
const tag = el.tagName;
let kind = null;
if (/^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(tag)) kind = 'media';
else if (tag === 'svg') { if (el.parentElement && el.parentElement.closest('svg')) continue; kind = 'media'; }
else if (/^(INPUT|SELECT|TEXTAREA|BUTTON)$/.test(tag) || el.getAttribute('role') === 'button') kind = 'control';
else { const bi = getComputedStyle(el).backgroundImage; if (bi && bi.includes('url(')) kind = 'media'; else continue; }
const r = el.getBoundingClientRect(); if (r.width < 24 || r.height < 24) continue;
if (kind === 'media' && r.width * r.height < 48 * 48) continue;
if (!shown(el)) continue;
boxes.push({ kind, el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: 0, size: 0, layer: layerOf(el), tag });
}
const flow = boxes.filter((b) => b.layer !== 'fixed');
// 1. the frame: how far the page's content comes to each edge (full-bleed things say nothing about margins)
// a row that runs off one edge (a carousel, a bled display line) says nothing about that side's margin
const framed = flow.filter((b) => b.r - b.l < W * 0.97 && b.l >= -1 && b.r <= W + 1);
const L = framed.length ? Math.max(0, pct(framed.map((b) => b.l), 0.02)) : null;
const R = framed.length ? Math.max(0, W - pct(framed.map((b) => b.r), 0.98)) : null;
// 2. sections: descend through single wrappers, then take the blocks
const tall = (c) => { const r = c.getBoundingClientRect(); return r.height >= 120 && r.width >= W * 0.6 && getComputedStyle(c).position !== 'fixed' && shown(c); };
let root = B;
for (let i = 0; i < 12; i++) { const kids = [...root.children].filter(tall); if (kids.length === 1) root = kids[0]; else break; }
// a tall block is split into its parts; the parts of a block that has its own heading continue it
let blocks = [...root.children].filter(tall).map((el) => ({ el, cont: false }));
for (let pass = 0; pass < 2; pass++) blocks = blocks.flatMap((b) => { const k = [...b.el.children].filter(tall); if (b.el.getBoundingClientRect().height > H * 1.6 && k.length >= 2) { const titled = b.cont || !!b.el.querySelector('h1, h2, h3, h4, [role="heading"]'); return k.map((el) => ({ el, cont: titled })); } return [b]; });
const continues = new Set(blocks.filter((b) => b.cont).map((b) => b.el));
blocks = blocks.map((b) => b.el);
const bodySize = (() => { const m = new Map(); for (const b of flow) if (b.kind === 'text' && b.chars >= 40) m.set(Math.round(b.size), (m.get(Math.round(b.size)) || 0) + b.chars); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 16; })();
const sections = blocks.slice(0, 40).map((s, i) => {
const r = s.getBoundingClientRect(); const top = r.top + SY, bot = r.bottom + SY;
const inside = flow.filter((b) => b.t >= top - 2 && b.b <= bot + 2 && s.contains(b.el));
const own = inside.filter((b) => b.r - b.l < W * 0.97);
const text = inside.filter((b) => b.kind === 'text');
const media = inside.filter((b) => b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= 160 * 120);
const words = text.reduce((a, b) => a + b.text.split(' ').length + Math.max(0, Math.round((b.chars - 50) / 6)), 0);
const headRe = /^H[1-4]$/;
const heading = text.filter((b) => (b.el.closest('h1,h2,h3,h4,[role="heading"]') || b.size >= Math.max(22, bodySize * 1.35)) && b.t <= top + Math.max(520, (bot - top) * 0.45)).sort((a, b) => b.size - a.size)[0];
const cs = getComputedStyle(s);
// the widest stretch across the section's content band that nothing covers, edge to edge excluded
let middle = 0;
if (own.length >= 2) {
const iv = own.map((b) => [Math.max(0, b.l), Math.min(W, b.r)]).sort((a, b) => a[0] - b[0]);
let end = iv[0][1];
for (const [a, b2] of iv.slice(1)) { if (a > end) middle = Math.max(middle, a - end); end = Math.max(end, b2); }
}
return {
sel: sel(s), tag: s.tagName.toLowerCase(), top: Math.round(top), height: Math.round(r.height), words, media: media.length, middle: Math.round(middle),
heading: heading ? { text: heading.text, size: Math.round(heading.size), tag: (heading.el.closest('h1,h2,h3,h4,h5,h6') || heading.el).tagName.toLowerCase() } : null,
left: own.length ? Math.round(Math.max(0, Math.min(...own.map((b) => b.l)))) : null, right: own.length ? Math.round(Math.max(0, W - Math.max(...own.map((b) => b.r)))) : null,
textLeft: text.length ? Math.round(Math.min(...text.map((b) => b.l))) : null, textRight: text.length ? Math.round(W - Math.max(...text.map((b) => b.r))) : null,
fullBleed: inside.some((b) => b.kind === 'media' && b.r - b.l >= W * 0.97), footer: s.tagName === 'FOOTER' || !!s.querySelector('footer') || i === blocks.length - 1,
hero: top < H * 0.5, continues: continues.has(s), pinned: cs.position === 'sticky' || !!s.querySelector('.pin-spacer') || [...s.querySelectorAll('*')].slice(0, 400).some((e) => getComputedStyle(e).position === 'sticky' && e.getBoundingClientRect().height > H * 0.5),
};
});
// 3. blank stretches: rows of the page with no text, picture or control, between the first and the last content.
// A sticky element's track (its parent) and a pinning spacer are scroll distance, not blank page.
const STEP = 4, rows = Math.ceil(pageH / STEP), occ = new Uint8Array(rows);
const mark = (t, b, v) => { for (let y = Math.max(0, Math.floor(t / STEP)); y < Math.min(rows, Math.ceil(b / STEP)); y++) occ[y] = v; };
for (const b of flow) if (b.layer !== 'sticky') mark(b.t, b.b, 1);
for (const el of B.querySelectorAll('.pin-spacer')) { const r = el.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); }
let scanned = 0;
for (const el of B.querySelectorAll('*')) { if (++scanned > 9000) break; const cs = getComputedStyle(el); if (cs.position === 'sticky' && el.parentElement) { const r = el.parentElement.getBoundingClientRect(); mark(r.top + SY, r.bottom + SY, 2); } }
let first = 0; while (first < rows && occ[first] !== 1) first++;
let last = rows - 1; while (last > 0 && occ[last] !== 1) last--;
const gaps = []; let run = 0;
for (let y = first; y <= last + 1; y++) { if (y <= last && occ[y] === 0) run++; else { if (run * STEP >= H * 0.3) gaps.push({ top: (y - run) * STEP, height: run * STEP }); run = 0; } }
// 4. the title
const h1s = [...document.querySelectorAll('h1')].filter((h) => { const r = h.getBoundingClientRect(); return r.width > 0 && r.height > 0 && shown(h); }).map((h) => { const r = h.getBoundingClientRect(); return { text: (h.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 80), size: Math.round(parseFloat(getComputedStyle(h).fontSize)), top: Math.round(r.top + SY), firstScreen: r.top + SY < H * 0.95 }; });
// 5. text pressed against the edge of the screen (type deliberately bled past the edge is a different device)
// fixed headers and corner labels count here: that is where text most often creeps to the edge
const edge = 16;
const edgeText = boxes.filter((b) => b.kind === 'text' && b.chars >= 3 && b.size < 40 && b.l >= -1 && b.r <= W + 1 && (b.l < edge || b.r > W - edge) && !b.el.closest('[class*="marquee" i],[class*="ticker" i],marquee,[data-ui],table')).slice(0, 8).map((b) => ({ text: b.text, side: b.l < edge ? 'left' : 'right', px: Math.round(b.l < edge ? b.l : W - b.r) }));
// display type that runs off one edge by a sliver while it starts inside the margin: a fit error, not a bleed
const slivers = boxes.filter((b) => b.kind === 'text' && b.size >= 40 && b.chars >= 3 && ((b.l >= 8 && b.r > W + 1 && b.r - W < b.size * 0.25) || (b.r <= W - 8 && b.l < -1 && -b.l < b.size * 0.25))).slice(0, 4).map((b) => ({ text: b.text.slice(0, 30), px: Math.round(b.r > W ? b.r - W : -b.l), side: b.r > W ? 'right' : 'left' }));
// the largest empty rectangle on the first screen (below a 90px band for the navigation), on a 24 x 15 grid
let voidShare = 0, voidBox = null;
if (W >= 1000) {
const C = 24, Rn = 15, cw = W / C, ch = H / Rn, occ2 = Array.from({ length: Rn }, () => new Array(C).fill(0));
for (const b of boxes) { if (b.t - SY > H || b.b - SY < 0) continue; const c0 = Math.max(0, Math.floor(b.l / cw)), c1 = Math.min(C - 1, Math.floor((b.r - 1) / cw)), r0 = Math.max(0, Math.floor((b.t - SY) / ch)), r1 = Math.min(Rn - 1, Math.floor((b.b - SY - 1) / ch)); for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) occ2[r][c] = 1; }
const top = Math.ceil(90 / ch), hgt = new Array(C).fill(0);
for (let r = top; r < Rn; r++) {
for (let c = 0; c < C; c++) hgt[c] = occ2[r][c] ? 0 : hgt[c] + 1;
for (let c = 0; c < C; c++) { let mn = Infinity; for (let k = c; k < C && hgt[k]; k++) { mn = Math.min(mn, hgt[k]); const a = mn * (k - c + 1); if (a > voidShare) { voidShare = a; voidBox = { x: Math.round(c * cw), y: Math.round((r - mn + 1) * ch), w: Math.round((k - c + 1) * cw), h: Math.round(mn * ch) }; } } }
}
voidShare = Math.round((voidShare / (C * Rn)) * 100) / 100;
}
// a fixed picture layer that covers the screen (a canvas scene, a film) hides what the DOM cannot see
const stage = boxes.some((b) => b.layer === 'fixed' && b.kind === 'media' && (b.r - b.l) * (b.b - b.t) >= W * H * 0.9);
// the first screen: is the title a bare name, with nothing at reading size saying what this is?
const firstText = flow.filter((b) => b.kind === 'text' && b.t < H * 0.95 && b.size >= 18 && !b.el.closest('h1, nav, header nav, button, a'));
const explains = firstText.some((b) => b.text.split(' ').length >= 6 || b.chars >= 36);
// 6. left edges used by running text and headings (left-aligned blocks only)
// headings and paragraphs only: the items of a grid or a list each start their own column on purpose
const lefts = flow.filter((b) => b.kind === 'text' && b.size >= 14 && !/center|right|end/.test(b.align) && b.l >= 0 && (b.el.closest('h1, h2, h3') || (b.chars >= 60 && b.el.closest('p, blockquote'))) && !b.el.closest('nav, footer, [data-ui], table, li, figcaption')).map((b) => b.l).sort((a, b) => a - b);
const edges = []; for (const x of lefts) { const e = edges.find((g) => Math.abs(g.x - x) <= 10); if (e) e.n++; else edges.push({ x: Math.round(x), n: 1 }); }
edges.sort((a, b) => b.n - a.n);
return { viewport: { w: W, h: H }, pageH, margins: { left: L == null ? null : Math.round(L), right: R == null ? null : Math.round(R) }, sections, gaps, h1s, edgeText, edges: edges.slice(0, 12), leftBlocks: lefts.length, bodySize, stage, explains, slivers, voidShare, voidBox };
}
// app() measures what an app screen (dashboard, admin, settings, inbox) gets right or wrong: the shell,
// how much of the canvas is used, panels and their edges, numbers in tables and KPIs, chart colours,
// the icon-in-a-tinted-square habit, placeholder data, and what happens to the sidebar on a phone.
export function app() {
const W = innerWidth, H = innerHeight, SY = scrollY, B = document.body;
const cvs = document.createElement('canvas'); cvs.width = cvs.height = 1;
const ctx = cvs.getContext('2d', { willReadFrequently: true });
const rgba = (c) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }; };
const hex = (v) => '#' + [v.r, v.g, v.b].map((n) => n.toString(16).padStart(2, '0')).join('');
const lum = (v) => { const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100; };
const hsl = (v) => { const r = v.r / 255, g = v.g / 255, b = v.b / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b); const l = (mx + mn) / 2; let h = 0, s = 0; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h *= 60; } return { h: Math.round(h), s, l }; };
const shown = (el, cs) => { cs = cs || getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
const groundOf = (el) => { for (let p = el; p; p = p.parentElement) { const v = rgba(getComputedStyle(p).backgroundColor); if (v.a >= 0.9) return v; } return { r: 255, g: 255, b: 255, a: 1 }; };
const pageBg = groundOf(B);
const all = [...B.querySelectorAll('*')].slice(0, 12000);
// 1. the shell: a tall column at one side (sidebar) and a bar across the top
let sidebar = null, topbar = null;
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 40) continue;
if (!sidebar && r.left <= 1 && r.top <= 80 && r.height >= H * 0.7 && r.width >= 48 && r.width <= Math.min(360, W * 0.45) && (el.querySelectorAll('a, button').length >= 3) && shown(el)) sidebar = { w: Math.round(r.width), tag: el.tagName.toLowerCase(), links: el.querySelectorAll('a, button').length, share: Math.round((r.width / W) * 100) / 100 };
if (!topbar && r.top <= 1 && r.left <= (sidebar ? sidebar.w + 2 : 2) && r.width >= W * 0.6 && r.height >= 36 && r.height <= 96 && shown(el)) topbar = { h: Math.round(r.height) };
if (sidebar && topbar) break;
}
// 2. how much of the canvas the content uses (right of the sidebar)
const x0 = sidebar ? sidebar.w : 0;
// the text itself (a block element's box runs to the edge whatever its text does), pictures, charts and controls
const boxes = [];
const tw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const trg = document.createRange();
for (let n = tw.nextNode(); n && boxes.length < 5000; n = tw.nextNode()) {
if (n.nodeValue.trim().length < 2 || !n.parentElement || n.parentElement.closest('svg')) continue;
const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue;
trg.selectNodeContents(n); const r = trg.getBoundingClientRect(); if (r.width < 2 || r.left < x0 - 1 || (cs.position === 'fixed' && r.left > W * 0.5)) continue;
boxes.push(r);
}
for (const el of B.querySelectorAll('svg, canvas, img, video, input, select, textarea, button, table')) {
if (el.parentElement && el.parentElement.closest('svg')) continue;
const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || r.left < x0 - 1 || !shown(el)) continue;
boxes.push(r);
}
const rights = boxes.map((r) => r.right).sort((a, b) => a - b), lefts = boxes.map((r) => r.left).sort((a, b) => a - b);
const canvas = boxes.length ? { left: Math.round(lefts[Math.floor(lefts.length * 0.02)] - x0), right: Math.round(W - rights[Math.floor(rights.length * 0.98)]) } : null;
// 3. panels: boxes that read as cards (a fill one step from the ground, a border, or a shadow)
const panels = []; const radii = new Map(); let heavyBorders = 0, heavyShadows = 0, softShadows = 0, glass = 0; const borderSamples = [], shadowSamples = [];
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 140 || r.height < 72 || r.width > W * 0.98) continue;
const cs = getComputedStyle(el); if (!shown(el, cs)) continue;
const bg = rgba(cs.backgroundColor), bw = parseFloat(cs.borderTopWidth) || 0, bc = rgba(cs.borderTopColor);
const ground = groundOf(el.parentElement || B);
const filled = bg.a >= 0.9 && contrast(bg, ground) >= 1.04, bordered = bw >= 0.5 && cs.borderTopStyle !== 'none' && bc.a > 0.05;
const sh = cs.boxShadow !== 'none' ? cs.boxShadow : '';
if (!filled && !bordered && !sh) continue;
if (!(el.innerText || '').trim()) continue;
panels.push({ el, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top + SY), left: Math.round(r.left), bottom: Math.round(r.bottom + SY), right: Math.round(r.right) });
const rad = Math.round(parseFloat(cs.borderTopLeftRadius) || 0); radii.set(rad, (radii.get(rad) || 0) + 1);
if (bordered) { const c = contrast({ ...bc, a: 1 }, bg.a >= 0.9 ? bg : ground); if (c >= (lum(ground) > 0.4 ? 1.45 : 1.9)) { heavyBorders++; if (borderSamples.length < 3) borderSamples.push(`${hex(bc)} on ${hex(bg.a >= 0.9 ? bg : ground)} (${c}:1)`); } }
if (sh) {
for (const part of sh.split(/,(?![^(]*\))/)) {
if (/inset/.test(part)) continue; const col = part.match(/rgba?\([^)]+\)/); const nums = part.replace(/rgba?\([^)]+\)/, '').trim().split(/\s+/).map(parseFloat);
const a = col ? rgba(col[0]).a : 1; const blur = nums[2] || 0, y = Math.abs(nums[1] || 0);
if (a <= 0.02 || (blur === 0 && y === 0 && !(nums[3] > 0))) continue;
// on a dark ground a black shadow barely shows; there it only counts when it is near-opaque
const darkGround = lum(ground) < 0.2;
if (darkGround ? a >= 0.45 : ((a >= 0.12 && blur >= 12) || y >= 8 || a >= 0.25)) { heavyShadows++; if (shadowSamples.length < 3) shadowSamples.push(part.trim().slice(0, 70)); } else softShadows++;
}
}
if (cs.backdropFilter && cs.backdropFilter !== 'none') glass++;
}
// top-level panels only (a card inside a panel is part of it): rows that end ragged, and how many
// panels start on the first screen
const panelEls = new Set(panels.map((p) => p.el));
const tops = panels.filter((p) => { for (let a = p.el.parentElement; a && a !== B; a = a.parentElement) if (panelEls.has(a)) return false; return p.w < W * 0.97 || p.h < H * 2; });
// a column of cards stacked with a gutter of 24px or less is one block: two cards on the left that end level with
// one tall card on the right are not ragged. A pair is ragged only when the cards and their columns both end apart.
const below = (p) => tops.find((q) => q !== p && Math.abs(q.left - p.left) <= 4 && Math.abs(q.w - p.w) <= 4 && q.top >= p.bottom - 2 && q.top - p.bottom <= 24);
const columnBottom = (p) => { let b = p.bottom; for (let q = below(p), n = 0; q && n < 12; q = below(q), n++) b = q.bottom; return b; };
const ragged = [];
for (let i = 0; i < tops.length; i++) for (let k = i + 1; k < tops.length; k++) {
const a = tops[i], b = tops[k];
if (Math.abs(a.top - b.top) > 4 || a.w < 200 || b.w < 200 || (a.right > b.left - 4 && b.right > a.left - 4)) continue; // side by side, same row
const diff = Math.min(Math.abs(a.bottom - b.bottom), Math.abs(columnBottom(a) - columnBottom(b))), tall = Math.max(a.h, b.h);
if (diff > Math.max(24, tall * 0.12)) ragged.push({ diff, top: a.top });
}
// the largest blank band inside a card: the vertical gap between consecutive pieces of its content (text, charts,
// pictures, controls, rules, fills), or from the last piece to the card's padding edge. A legend pinned to the foot
// of a stretched card, or four rows in a card kept at its neighbour's height, leave one.
const voids = [];
const seen = new Map();
const visibleIn = (el, root) => { if (seen.has(el)) return seen.get(el); let ok = true; for (let p = el; p && p !== root.parentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.05 || cs.position === 'fixed') { ok = false; break; } } seen.set(el, ok); return ok; };
for (const p of tops) {
if (p.el.matches('nav, aside, header, footer, dialog, [role="navigation"], [role="dialog"]') || p.el.querySelector('nav') || (sidebar && p.left < sidebar.w - 4) || p.h < 120) continue;
if (panels.filter((q) => q.el !== p.el && p.el.contains(q.el)).length >= 2) continue; // a frame of cards, not a card
const cs = getComputedStyle(p.el), pr = p.el.getBoundingClientRect();
const inTop = pr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0), inBottom = pr.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0);
const bg = rgba(cs.backgroundColor), spans = [];
const tw2 = document.createTreeWalker(p.el, NodeFilter.SHOW_TEXT), rg = document.createRange();
// text that opens a section (a heading, a summary, or text at 14px/600 or 15px/500 and up) may follow a wider, placed gap
for (let n = tw2.nextNode(); n; n = tw2.nextNode()) { if (!n.nodeValue.trim() || !n.parentElement || n.parentElement.closest('svg') || !visibleIn(n.parentElement, p.el)) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { const pcs = getComputedStyle(n.parentElement); const fs = parseFloat(pcs.fontSize), fw = +pcs.fontWeight; spans.push([r.top, r.bottom, !!n.parentElement.closest('h1, h2, h3, h4, h5, h6, [role="heading"], summary, legend, caption') || (fw >= 600 && fs >= 14) || (fw >= 500 && fs >= 15)]); } }
for (const el of p.el.querySelectorAll('*')) {
if (el.parentElement && el.parentElement.closest('svg')) continue;
const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 1 || !visibleIn(el, p.el)) continue;
if (/^(svg|canvas|img|video|input|select|textarea|button|hr|progress|meter|iframe)$/i.test(el.tagName)) { spans.push([r.top, r.bottom]); continue; }
const ecs = getComputedStyle(el), fillC = rgba(ecs.backgroundColor);
if (fillC.a >= 0.1 && (bg.a < 0.9 || contrast({ ...fillC, a: 1 }, bg) >= 1.03) && r.height >= 2) spans.push([r.top, r.bottom]);
if ((parseFloat(ecs.borderTopWidth) || 0) >= 0.5 && ecs.borderTopStyle !== 'none' && rgba(ecs.borderTopColor).a > 0.05) spans.push([r.top, r.top + 1]);
if ((parseFloat(ecs.borderBottomWidth) || 0) >= 0.5 && ecs.borderBottomStyle !== 'none' && rgba(ecs.borderBottomColor).a > 0.05) spans.push([r.bottom - 1, r.bottom]);
}
const ys = spans.filter(([a, b]) => b > inTop - 2 && a < inBottom + 2).sort((a, b) => a[0] - b[0]);
if (!ys.length) continue;
// a band counts when it is over 40px, or over 64px when the content after it opens a new section
let end = ys[0][1], gap = 0, at = null;
for (const [a, b] of ys.slice(1)) { const g = a - end, section = ys.some(([t, , hd]) => hd && Math.abs(t - a) < 6); if (g > (section ? 64 : 40) && g > gap) { gap = g; at = end; } end = Math.max(end, b); }
if (inBottom - end > 40 && inBottom - end > gap) { gap = inBottom - end; at = end; }
const head = (p.el.querySelector('h1, h2, h3, h4, [role="heading"], header') || p.el).innerText || '';
if (gap > 40) voids.push({ gap: Math.round(gap), at: Math.round(at + SY), panel: head.trim().split('\n')[0].slice(0, 40) });
}
voids.sort((a, b) => b.gap - a.gap);
const firstScreen = tops.filter((p) => p.top < H - 48).length;
// the icon-in-a-tinted-square habit: small boxes with a coloured fill holding one icon, repeated
const chips = []; let tiles = 0;
for (const el of all) {
const r = el.getBoundingClientRect(); if (r.width < 24 || r.width > 64 || Math.abs(r.width - r.height) > 4) continue;
const cs = getComputedStyle(el); const bg = rgba(cs.backgroundColor); if (bg.a < 0.08) continue;
const icon = el.querySelector('svg, i, img, [class*="icon" i]'); if (!icon || (el.innerText || '').trim().length > 2) continue;
const c = hsl(bg); if (c.s < 0.18 && bg.a > 0.5) { if (r.width >= 28 && !el.closest('button, a, nav, aside, [role="navigation"]')) tiles++; continue; } // neutral: an avatar, a button, or a boxed icon
chips.push({ hue: c.h, size: Math.round(r.width), radius: cs.borderTopLeftRadius });
}
const chipHues = new Set(chips.map((c) => Math.round(c.hue / 30)));
// 5. numbers: tables and KPI figures
const numRe = /^[−\-+]?[$€£¥]?\s?\d[\d,.\s]*(%|[kKmMbB]|\s?(ms|s|h|min|GB|MB|kB|x))?$/;
const cells = [...B.querySelectorAll('td, [role="cell"], [role="gridcell"]')].filter((el) => numRe.test((el.innerText || '').trim()));
const leftNums = cells.filter((el) => { const a = getComputedStyle(el).textAlign; const inner = el.firstElementChild ? getComputedStyle(el.firstElementChild).textAlign : a; return /left|start/.test(a) && /left|start/.test(inner) && getComputedStyle(el).justifyContent !== 'flex-end'; }).length;
const figures = [];
for (const el of all) { if (el.children.length) continue; const t = (el.innerText || '').trim(); if (!numRe.test(t) || t.length < 2) continue; const cs = getComputedStyle(el); if (!shown(el, cs)) continue; figures.push({ size: parseFloat(cs.fontSize), tabular: /tabular-nums/.test(cs.fontVariantNumeric) || /"tnum"/.test(cs.fontFeatureSettings), weight: +cs.fontWeight }); }
const tabularShare = figures.length ? Math.round((figures.filter((f) => f.tabular).length / figures.length) * 100) / 100 : null;
const rows = [...B.querySelectorAll('tbody tr, [role="row"]')].map((tr) => Math.round(tr.getBoundingClientRect().height)).filter((h) => h > 0);
// 6. chart colours: saturated fills and strokes inside SVG charts and canvas-free charts
const hues = new Map();
for (const svg of B.querySelectorAll('svg')) {
const r = svg.getBoundingClientRect(); if (r.width < 120 || r.height < 60) continue;
for (const s of svg.querySelectorAll('path, rect, circle, line, polyline, polygon')) {
const cs = getComputedStyle(s);
for (const c of [cs.fill, cs.stroke]) { if (!c || c === 'none' || c.startsWith('url')) continue; const v = rgba(c); if (v.a < 0.3) continue; const h = hsl(v); if (h.s < 0.25 || h.l < 0.12 || h.l > 0.92) continue; const k = Math.round(h.h / 24); hues.set(k, (hues.get(k) || 0) + 1); }
}
}
// bars and segments drawn with divs count too (a common way to fake a chart)
// 7. placeholder data
const text = B.innerText || '';
const placeholders = [...new Set((text.match(/\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bLorem ipsum\b|\bexample\.com\b|\bUser Name\b|\bFirstname Lastname\b|\b123 Main St\b|\bfoo(bar)?\b/gi) || []).map((s) => s.toLowerCase()))];
// 8. type at app sizes
const sizes = new Map(); let tiny = 0; const tinySamples = [];
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) { const t = n.nodeValue.trim(); if (t.length < 2 || !n.parentElement) continue; const cs = getComputedStyle(n.parentElement); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const s = Math.round(parseFloat(cs.fontSize)); sizes.set(s, (sizes.get(s) || 0) + t.length); if (s < 11 && t.length >= 3) { tiny++; if (tinySamples.length < 4) tinySamples.push(t.slice(0, 24) + ' ' + s + 'px'); } }
const total = [...sizes.values()].reduce((a, b) => a + b, 0) || 1;
const under13 = Math.round(([...sizes.entries()].filter(([s]) => s < 13).reduce((a, [, c]) => a + c, 0) / total) * 100) / 100;
const h1 = document.querySelector('h1'); const h1r = h1 && h1.getBoundingClientRect();
// the screen's hero: its h1 when that is on the first screen, else the largest text there. Text is read with a
// space between element children, so a figure and its unit ("£55.94" and "at cost") do not run together.
const spaced = (el) => { const out = []; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const t = n.nodeValue.replace(/\s+/g, ' ').trim(); if (t && n.parentElement && !n.parentElement.closest('svg, script, style') && getComputedStyle(n.parentElement).display !== 'none') out.push(t); } return out.join(' ').replace(/\s+([,.;:!?%])/g, '$1').trim(); };
let heroEl = h1 && h1r && h1r.width > 0 && h1r.top + SY < H * 0.95 ? h1 : null;
if (!heroEl) {
let best = null; const hw = document.createTreeWalker(B, NodeFilter.SHOW_TEXT);
for (let n = hw.nextNode(); n; n = hw.nextNode()) {
const el = n.parentElement; if (!el || n.nodeValue.trim().length < 2 || el.closest('svg, nav, aside, [role="navigation"]')) continue;
const r = el.getBoundingClientRect(); if (r.top + SY > H * 0.95 || r.width < 1 || (sidebar && r.left < sidebar.w - 2)) continue;
const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || +cs.opacity < 0.05) continue;
const size = parseFloat(cs.fontSize); if (!best || size > best.size) best = { el, size };
}
if (best) { heroEl = best.el; for (let p = heroEl; p && p !== B; p = p.parentElement) if (!/^inline/.test(getComputedStyle(p).display)) { heroEl = p; break; } }
}
let hero = null;
if (heroEl) {
const big = [...heroEl.querySelectorAll('*'), heroEl].map((e) => ({ e, s: parseFloat(getComputedStyle(e).fontSize) })).filter((o) => [...o.e.childNodes].some((c) => c.nodeType === 3 && c.nodeValue.trim())).sort((a, b) => b.s - a.s)[0]?.e || heroEl;
const cs = getComputedStyle(big), size = parseFloat(cs.fontSize), txt = spaced(heroEl), r = heroEl.getBoundingClientRect();
const tops2 = []; const lw = document.createTreeWalker(heroEl, NodeFilter.SHOW_TEXT); const lr = document.createRange();
for (let n = lw.nextNode(); n; n = lw.nextNode()) { if (!n.nodeValue.trim()) continue; lr.selectNodeContents(n); for (const rc of lr.getClientRects()) { const mid = rc.top + rc.height / 2; if (rc.width >= 1 && !tops2.some((t) => Math.abs(t - mid) < size * 0.55)) tops2.push(mid); } }
const lh = parseFloat(cs.lineHeight);
hero = { text: txt.slice(0, 200), words: txt.split(' ').filter(Boolean).length, chars: txt.length, lines: Math.max(1, tops2.length), size: Math.round(size), vw: Math.round((size / W) * 1000) / 10, font: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(), weight: +cs.fontWeight, tracking: Math.round(((parseFloat(cs.letterSpacing) || 0) / size) * 100) / 100, leading: Number.isFinite(lh) ? Math.round((lh / size) * 100) / 100 : 1.2, top: Math.round(r.top + SY), tag: heroEl.tagName.toLowerCase(), kind: 'heading', align: 'left', position: 'left' };
}
// charts and timelines on the screen (for the data-hue check): sizeable SVGs with marks, or blocks named as one
const chartRe = /(^|[\s_-])(chart|plot|graph|timeline|calendar|cal|gantt|heatmap|matrix|strip)([\s_-]|$)/i;
const chartCount = [...B.querySelectorAll('svg')].filter((s) => { if (s.parentElement && s.parentElement.closest('svg')) return false; const r = s.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && s.querySelectorAll('path, rect, line, polyline, circle').length >= 6 && shown(s); }).length
+ [...B.querySelectorAll('[class]')].filter((e) => typeof e.className === 'string' && chartRe.test(e.className) && !e.closest('svg') && (() => { const r = e.getBoundingClientRect(); return r.width >= 200 && r.height >= 80 && shown(e); })()).length;
return {
viewport: { w: W, h: H }, dark: lum(pageBg) < 0.2, ground: hex(pageBg),
sidebar, topbar, canvas, hero, charts: chartCount,
layout: { panels: tops.length, firstScreen, ragged: ragged.slice(0, 6), voids: voids.slice(0, 4) },
tiles,
panels: { count: panels.length, radii: [...radii.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), heavyBorders, borderSamples, heavyShadows, softShadows, shadowSamples, glass },
chips: { count: chips.length, hues: chipHues.size, samples: chips.slice(0, 4) },
numbers: { cells: cells.length, leftAligned: leftNums, figures: figures.length, tabularShare, kpiMax: figures.length ? Math.max(...figures.map((f) => f.size)) : null },
rows: rows.length ? { count: rows.length, median: rows.sort((a, b) => a - b)[Math.floor(rows.length / 2)] } : null,
chartHues: hues.size,
placeholders,
type: { under13, tiny, tinySamples, sizes: [...sizes.keys()].sort((a, b) => a - b) },
title: h1 && h1r.width > 0 ? { text: (h1.innerText || '').trim().slice(0, 60), size: Math.round(parseFloat(getComputedStyle(h1).fontSize)), top: Math.round(h1r.top + SY) } : null,
};
}
// Share of a screenshot's pixels that are strongly coloured (saturated, neither near-black nor near-white).
// The studied app screens keep it near 3%; pale tints barely count. Runs inside any page; takes a data: URL.
export async function colourShare(dataUrl) {
const img = new Image(); img.src = dataUrl; await img.decode();
const w = 240, h = Math.max(1, Math.round((240 * img.height) / img.width));
const c = document.createElement('canvas'); c.width = w; c.height = h;
const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, w, h);
const d = x.getImageData(0, 0, w, h).data; let n = 0;
for (let i = 0; i < d.length; i += 4) {
const r = d[i] / 255, g = d[i + 1] / 255, b = d[i + 2] / 255, mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
const s = mx === mn ? 0 : l > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn);
if (s >= 0.4 && l >= 0.18 && l <= 0.82) n++;
}
return Math.round((n / (w * h)) * 1000) / 1000;
}
// Dominant colours of a screenshot. Runs inside any page; takes a data: URL.
export async function palette(dataUrl) {
const img = new Image(); img.src = dataUrl; await img.decode();
+107 -1
View File
@@ -114,6 +114,7 @@ export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
else if (m.page.realPictures < 2) add('WARN', 'no-real-pictures', `Only ${m.page.realPictures} real photograph(s) or clip(s); the rest are interface fragments.`, 'Add two or more real pictures of the customer\'s world from scripts/assets.mjs, not stock of people at laptops.');
if (!m.page.videoCount) add('WARN', 'no-footage', 'No video anywhere on the page.', 'Look for one honest clip with scripts/assets.mjs --video (the place, the material, the process) and use it as a muted loop with a poster frame. Defend its absence only if no clip fits the subject.');
}
if (m.page.videosNoPoster) add('WARN', 'no-poster', `${m.page.videosNoPoster} clip(s) without a poster.`, 'Give every clip a poster frame: in the study of 100 Sites of the Day only 31% had one, and the blank or grey boxes before the clips played were recorded as faults (references/imagery.md §11).');
if (m.page.sideScrollers) add('WARN', 'side-scroll', `${m.page.sideScrollers} row(s) scroll sideways with content past their edge.`, 'Jurors and most visitors read a row cut at the edge as clipped, not as scrollable. Wrap it onto more lines, or end it inside the grid.');
if (m.page.blendText) add('WARN', 'blend-text', `${m.page.blendText} text element(s) use mix-blend-mode difference or exclusion.`, 'Over anything but black, white or grey, blended text takes random hues (blue on orange, lilac on green) that jurors read as a glitch. Use a solid ink chosen for the ground, or keep the blended text over neutral grounds only.');
if (m.type.measureChars && m.type.measureChars > 88 && !mobile) add('WARN', 'measure', `Longest paragraph runs about ${m.type.measureChars} characters per line.`, 'Cap paragraphs at 45–75 characters (max-width: 60ch).');
@@ -134,4 +135,109 @@ export function judge(m, { width = m.viewport.w, allow = [] } = {}) {
return out;
}
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page'];
// The frame of the page, measured once per width by layout(): margins, balance, blank stretches, titles.
// These catch the failures a reader names first: "no clear title", "random empty space", "it is all
// pushed to one side", "text touching the edge".
export function judgeLayout(L, { width = L.viewport.w, allow = [] } = {}) {
const out = [];
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
const mobile = width < 600, H = L.viewport.h;
// the outer frame: the two margins match (asymmetry belongs inside the grid, never in the frame)
const { left, right } = L.margins;
if (left != null && right != null) {
const diff = Math.abs(left - right), wide = Math.max(left, right);
if (diff >= Math.max(mobile ? 14 : 40, width * 0.045) && wide >= (mobile ? 28 : width * 0.07)) {
add('FAIL', 'off-centre', `The page sits off-centre: its content comes to ${left}px from the left edge but only to ${right}px from the right, at ${width}px.`, `Give the page one pair of equal margins. Check the wrapper for a max-width without margin-inline: auto, a grid with an empty last column, or padding on one side only. If one side is meant to hold air, it must still be closed by something that reaches the margin (a picture, the navigation's last item, a figure).`);
} else if (diff >= (mobile ? 12 : 16)) {
// strong sites hold their two margins within a few pixels; a 20-30px drift is the "shifted" look
add('WARN', 'off-centre', `The margins differ: ${left}px on the left, ${right}px on the right, at ${width}px.`, 'Put the navigation and the content on one pair of margins; check for padding on one side only, a scrollbar gutter, or a header on another inset than the page.');
}
}
// sections whose content huddles on one side, leaving the other half of the screen empty
if (!mobile && left != null) {
const lop = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.left != null && s.words >= 12 && s.height >= 180 && Math.min(s.left, s.right) <= Math.max(left, right) + 24 && Math.max(s.left, s.right) >= width * 0.42);
if (lop.length >= 2) add('FAIL', 'lopsided', `${lop.length} sections keep all their content on one side and leave ${Math.round(Math.max(...lop.map((s) => Math.max(s.left, s.right))) / width * 100)}% of the width empty: ` + lop.slice(0, 4).map((s) => `${s.heading ? '"' + s.heading.text + '"' : s.sel} (${s.right > s.left ? 'right' : 'left'} ${Math.max(s.left, s.right)}px empty)`).join('; '), 'Use the whole width the page has set up: put the section\'s picture, its second column, its list or its figure in the empty half, or set the text larger across the grid. Empty space must be placed, with something on each side of it.');
else if (lop.length) add('WARN', 'lopsided', `A section keeps its content on one side and leaves ${Math.max(lop[0].left, lop[0].right)}px of the width empty: ${lop[0].heading ? '"' + lop[0].heading.text + '"' : lop[0].sel}.`, 'Fill the other side with what the section has (its picture, a second column, the figure), or defend the void in one sentence.');
// the same hole with the empty part in the middle: a card at the left, a lone button at the right
const hollow = L.sections.filter((s) => !s.hero && !s.footer && !s.fullBleed && !s.pinned && s.words >= 12 && s.middle >= width * 0.45);
if (hollow.length) add('WARN', 'empty-middle', `${hollow.length} section(s) have content at both edges and ${Math.max(...hollow.map((s) => s.middle))}px of nothing between: ` + hollow.slice(0, 3).map((s) => s.heading ? '"' + s.heading.text + '"' : s.sel).join('; '), 'Bring the two parts together on the grid, or put the picture or figure of the section in the middle. An empty middle third reads as a hole, the same as an empty half.');
}
// blank stretches: nothing at all for a long run down the page
const big = L.gaps.filter((g) => g.height >= H * 0.6), mid = L.gaps.filter((g) => g.height >= H * 0.42 && g.height < H * 0.6);
if (big.length || mid.length >= 2) add(L.stage ? 'WARN' : 'FAIL', 'blank-gap', `${big.length + mid.length} stretch(es) of the page hold nothing at all: ` + [...big, ...mid].slice(0, 4).map((g) => `${g.height}px at ${g.top}px`).join('; ') + ` (the screen is ${H}px tall).`, (L.stage ? 'A fixed picture layer covers the screen here, so the DOM cannot tell whether the stretch is empty: check the screenshots. ' : '') + 'Close the gap: a section gap is 120–240px on desktop and 72–112px on a phone. Look for a min-height: 100vh on a short section, an empty grid row, a hidden element still holding its space, or a reveal that never fired. A deliberate rest is a placed picture or colour field, not nothing.');
else if (mid.length) add('WARN', 'blank-gap', `A ${mid[0].height}px stretch at ${mid[0].top}px holds nothing.`, 'Check that it is a deliberate rest (once per page at most); otherwise close it to the normal section gap.');
// the title: one h1, on the first screen, that says what this is
if (!L.h1s.length) add('FAIL', 'no-title', 'The page has no visible h1.', 'Give the first screen a clear title in an h1 that says what this is (a name with what it does, the product with what it is for, the event with its date and place).');
else {
const first = L.h1s[0];
if (!first.firstScreen) add('FAIL', 'no-title', `The h1 ("${first.text.slice(0, 50)}") is not on the first screen (it starts ${first.top}px down).`, 'The title belongs on the first screen, where a visitor decides whether to stay.');
else if (first.size < (mobile ? 26 : 32)) add('WARN', 'small-title', `The h1 is ${first.size}px ("${first.text.slice(0, 50)}").`, 'Set the title at display size, or make the large text of the first screen the h1.');
if (first.firstScreen && first.text.split(' ').length <= 3 && L.explains === false) add('WARN', 'name-only-title', `The h1 is a bare name ("${first.text.slice(0, 40)}") and nothing else on the first screen says what this is.`, 'Keep the name if it leads, and give the line under it (18px or more) the plain words: what it is, for whom, where or when. In the study of 97 Sites of the Day, first screens that named nothing lost usability and content points.');
if (L.h1s.length > 1) add('WARN', 'many-h1', `${L.h1s.length} h1 elements.`, 'One h1 per page; section titles are h2.');
}
// sections with running text but no heading: the reader cannot tell what they are
const untitled = L.sections.filter((s) => !s.hero && !s.footer && !s.continues && !s.heading && s.words >= 30 && s.height >= 200);
if (untitled.length >= 2) add('FAIL', 'untitled-section', `${untitled.length} sections carry text but no heading: ` + untitled.slice(0, 4).map((s) => `${s.sel} at ${s.top}px (${s.words} words)`).join('; '), 'Give each section a short heading that names what it holds (the menu, the rooms, how it works, the price), in the heading face at the section size.');
else if (untitled.length) add('WARN', 'untitled-section', `A section carries text but no heading: ${untitled[0].sel} at ${untitled[0].top}px.`, 'Name it with a short heading unless it is the continuation of the section above.');
// the first screen: a large empty rectangle that nothing closes reads as "random empty space"
if (!mobile && L.voidShare >= 0.28 && !L.stage) add('WARN', 'hero-void', `${Math.round(L.voidShare * 100)}% of the first screen is one empty block (${L.voidBox.w}×${L.voidBox.h}px at ${L.voidBox.x},${L.voidBox.y}).`, 'Place the void on purpose or fill it: move the picture, the film or one fact into it, grow the headline to meet it, or shrink the screen to its content. A void reads as placed only when something closes it on the far side and it is the screen\'s one rest.');
// display type that starts inside the margin but runs off the opposite edge by a sliver: a fit error
if (L.slivers && L.slivers.length) add('WARN', 'one-sided-bleed', `${L.slivers.length} display line(s) start inside the margin but run off the ${L.slivers[0].side} edge by a few pixels: ` + L.slivers.map((t) => `"${t.text}" (${t.px}px)`).join('; '), 'Fit the line to the content box by measurement (a small fit script, or SVG text with textLength), or bleed it on purpose by a clear amount from the margin. A name sliced by a few pixels on one side reads as a mistake.');
// text pressed against the screen edge
const tight = L.edgeText.filter((t) => t.px < (mobile ? 12 : 16)), near = mobile ? L.edgeText.filter((t) => t.px >= 12) : [];
if (near.length && !tight.length) add('WARN', 'edge-text', `${near.length} text block(s) sit 12–15px from the phone's edge: ` + near.slice(0, 3).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px)`).join('; '), 'The page margin is 16–20px on a phone; the header and the corner labels sit on it too.');
if (tight.length) add('FAIL', 'edge-text', `${tight.length} text block(s) sit within ${mobile ? 12 : 16}px of the screen edge: ` + tight.slice(0, 4).map((t) => `"${t.text.slice(0, 30)}" (${t.px}px from the ${t.side})`).join('; '), `Keep text inside the page margin (${mobile ? '16–20px' : '16–32px or more'}). Only display type deliberately bled past the edge may touch it.`);
// too many left edges: nothing lines up
if (!mobile && L.edges.length > 6 && L.leftBlocks >= 8) {
const top3 = L.edges.slice(0, 3).reduce((a, e) => a + e.n, 0) / L.leftBlocks;
if (top3 < 0.6) add('WARN', 'ragged-edges', `Left-aligned text starts at ${L.edges.length} different lines (${L.edges.slice(0, 7).map((e) => e.x + 'px').join(', ')}); the three most used hold only ${Math.round(top3 * 100)}% of it.`, 'Pick two or three anchor lines from the grid and start every heading and paragraph on one of them.');
}
return out;
}
// App screens (dashboards, admin, settings, inboxes) keep conventions a marketing page does not: tables
// and panels may have quiet borders, labels are small, cards repeat, there is no hero, no picture floor
// and no motion floor. These page rules do not apply to them; references/app-screens.md has the reasons.
export const APP_SKIP = new Set(['hero-words', 'eyebrow', 'mono', 'name-only-title', 'empty-middle', 'numbering', 'hairline', 'outlined-box', 'dashed', 'icon-cards', 'card-rows', 'pill-nav', 'grey-body', 'measure', 'first-action', 'few-pictures', 'no-real-pictures', 'no-footage', 'focal-point', 'no-hero', 'body-size', 'small-text', 'statement-length', 'shadow', 'glass', 'still-page', 'empty-screens', 'long-pin', 'side-scroll', 'pinned-over-picture', 'off-centre', 'lopsided', 'ragged-edges', 'untitled-section', 'size-count', 'all-centred']);
export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
const out = [];
const add = (level, id, message, fix) => { if (!allow.includes(id)) out.push({ level, id, width, message, fix }); };
const mobile = width < 600;
if (mobile && A.sidebar && A.sidebar.share > 0.3) add('FAIL', 'sidebar-phone', `The sidebar still takes ${Math.round(A.sidebar.share * 100)}% of the width on a ${width}px phone.`, 'On a phone the navigation folds into a top bar with a menu button or a bottom bar of four or five destinations; the screen\'s content gets the full width.');
if (!mobile && width >= 1100 && A.canvas && A.canvas.right > Math.max(120, width * 0.1) && A.canvas.right > A.canvas.left + 80) add('WARN', 'unused-canvas', `The content stops ${A.canvas.right}px short of the right edge while it starts ${A.canvas.left}px from the sidebar.`, 'Let the grid fill the canvas (columns in fr units), or centre a capped canvas with equal margins. A dead strip on one side reads as unfinished.');
if (A.panels.heavyShadows) add('FAIL', 'heavy-shadow', `${A.panels.heavyShadows} panel shadow(s) heavy enough to read as floating: ${A.panels.shadowSamples.join(' | ')}`, 'Panels sit on the ground: separate them with a fill one step from the ground or a quiet 1px border. A shadow, if any, is under 8% black with a small blur. Real overlays (menus, dialogs, popovers) may float.');
if (A.panels.glass) add('FAIL', 'glass', `${A.panels.glass} blurred glass panel(s).`, 'Opaque surfaces only.');
if (A.panels.heavyBorders >= 3) add('WARN', 'heavy-borders', `${A.panels.heavyBorders} panel borders are dark against their ground: ${A.panels.borderSamples.join(' | ')}`, 'A panel border is a whisper: about 1.1–1.3:1 against its ground on a light screen (#e5e7eb–#ededed on white), and about 1.2–1.5:1 on a dark one (#1a1a1a on #000, #2e2e2e on #111). Darker lines turn the screen into a grid of boxes; keep the stronger line for controls.');
const radii = A.panels.radii.filter(([, n]) => n >= 2).map(([r]) => r);
if (radii.length > 3) add('WARN', 'radius-mix', `Panels use ${radii.length} corner radii (${radii.join(', ')}px).`, 'One radius for panels, one for controls, full round only for pills and avatars.');
if (A.chips.count >= 3) add(A.chips.hues >= 2 ? 'FAIL' : 'WARN', 'icon-chips', `${A.chips.count} icons sit in tinted squares or circles${A.chips.hues >= 2 ? ` in ${A.chips.hues} different colours` : ''}.`, 'This is the commonest tell of a generated dashboard. Drop the tinted square: a KPI is a label and a number; an icon, if it helps, is plain and in the text colour.');
if (A.numbers.cells >= 6 && A.numbers.leftAligned / A.numbers.cells >= 0.5) add('WARN', 'numeric-align', `${A.numbers.leftAligned} of ${A.numbers.cells} numeric table cells are left-aligned.`, 'Right-align numbers, amounts and percentages (and their column headers) so the digits line up.');
if (A.numbers.figures >= 6 && A.numbers.tabularShare != null && A.numbers.tabularShare < 0.5) add('WARN', 'tabular', `Only ${Math.round(A.numbers.tabularShare * 100)}% of the figures use tabular numerals.`, 'Set font-variant-numeric: tabular-nums on tables, KPIs and anything that updates, so digits keep their width.');
if (A.chartHues > 6) add('FAIL', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'One hue for the series that matters, a neutral for the rest, and at most four series colours in total; status colours only for status.');
else if (A.chartHues > 4) add('WARN', 'rainbow-chart', `The charts use ${A.chartHues} different hues.`, 'Four series colours at most; prefer one accent and neutrals.');
if (A.colourShare != null && A.colourShare > 0.12) add('WARN', 'colour-sprawl', `${Math.round(A.colourShare * 100)}% of the first screen is strongly coloured.`, 'Keep the screen neutral and spend colour on data and status: the studied screens sit near 3%, and only tinted styles reach 10–15%, as pale tints rather than saturated fills.');
if (A.placeholders.length) add('FAIL', 'placeholder-data', `Placeholder data on screen: ${A.placeholders.join(', ')}.`, 'Use believable data for this product: real-looking names, amounts that add up, dates in order, units on every figure.');
if (A.type.tiny > 2) add('FAIL', 'tiny-text', `${A.type.tiny} text blocks under 11px: ${A.type.tinySamples.join(', ')}.`, 'Nothing below 11px; secondary text 12px, data 13–14px.');
else if (A.type.under13 > 0.45) add('WARN', 'tiny-text', `${Math.round(A.type.under13 * 100)}% of the text is under 13px.`, 'Data and labels read at 13–14px; keep 11–12px for sublines and axis ticks.');
if (!mobile && A.layout) {
if (A.layout.ragged.length) add('WARN', 'ragged-row', `${A.layout.ragged.length} pair(s) of cards in one row end at different heights (by ${A.layout.ragged.map((r) => r.diff + 'px').slice(0, 3).join(', ')}).`, 'Cards that share a row share a bottom edge: balance them by their content (the same number of rows, a block of similar height beside it), or stack them. In testing, ragged rows were marked down every time.');
if (width >= 1100 && A.layout.panels >= 4 && A.layout.firstScreen / A.layout.panels < 0.5) add('WARN', 'below-fold', `Only ${A.layout.firstScreen} of ${A.layout.panels} blocks start on the first ${width}px screen.`, 'Every block the brief names shows its header and first rows on the first desktop screen: size the headline block to its question, cap long lists with "View all", and put time-sensitive blocks before long tables.');
if (width >= 1100 && A.layout.voids?.length) add('WARN', 'panel-void', `${A.layout.voids.length} card(s) hold a blank band inside: ${A.layout.voids.slice(0, 3).map((v) => `${v.gap}px in "${v.panel}"`).join(', ')}.`, 'A card stretches to its neighbour only when it has content to fill. Keep a legend or a note directly under its chart (only an action or a total may sit at a stretched card\'s foot), let a short card keep its own height (align-items: start), and let a filtered block shrink to its rows.');
}
// an achromatic screen with a chart or a timeline and no hue on any data reads as a kit: the one chart hue goes on
// the main data element (today's line, the booked bars, the margin that breaks)
if (!mobile && A.charts > 0 && A.chartHues === 0 && A.colourShare != null && A.colourShare < 0.002) add('WARN', 'no-data-hue', `The screen has ${A.charts === 1 ? 'a chart' : A.charts + ' charts or timelines'} but no hue on any of its data (${(A.colourShare * 100).toFixed(1)}% coloured pixels).`, 'Put the style\'s one chart hue (or the brand\'s) on the screen\'s main data element: today\'s line, the booked bars, the current period, the cover margin. Keep the chrome neutral; the hue still stays under the style\'s colour budget.');
// in app mode the screen's hero is its h1 or the largest text on the first screen, not the page probe's guess
// (which can be a figure below the fold read without its spaces); the report and the MEASURED line use this one
if (A.hero && m) m.hero = A.hero;
if (A.title && A.title.size > (mobile ? 28 : 34)) add('WARN', 'app-title-size', `The h1 is ${A.title.size}px ("${A.title.text.slice(0, 40)}").`, 'An app screen is not a landing page: the title is 18-32px (20-24px on a phone).');
if (A.numbers.kpiMax && A.numbers.kpiMax >= 48) add('WARN', 'kpi-size', `A figure is set at ${Math.round(A.numbers.kpiMax)}px.`, 'Figures run 16-40px on app screens; none of the studied screens reaches 48px. Size and isolation make a number loud, not display type.');
if (A.type.sizes.length > 10) add('WARN', 'size-count', `${A.type.sizes.length} font sizes (${A.type.sizes.join(', ')}).`, 'App screens use 4-9 sizes (median 6): a scale such as 12/14/16/24 with one figure size.');
if (A.tiles >= 4) add('WARN', 'icon-tiles', `${A.tiles} icons sit in neutral boxes repeated down the screen.`, 'A boxed icon on every row or card is still the template habit, tinted or not. Keep one plain 16px icon where it helps the eye, or none.');
if (!A.title) add('FAIL', 'no-title', 'The screen has no h1.', 'Name the screen in an h1 at the top of the canvas (18–28px): "Overview", "Payouts", the project\'s name.');
if (m && m.type && m.type.monoProseShare > 0.15) add('WARN', 'mono', `Monospace is ${Math.round(m.type.monoProseShare * 100)}% of the text.`, 'Monospace for IDs, keys and code only; numbers use tabular figures in the text face.');
return out;
}
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy', 'dev-server'];
+13 -2
View File
@@ -2,7 +2,7 @@
// tasteskill / lint — static scan of source files for the tells this skill bans.
// Catches them before a render, plus the ones a render cannot see (copy habits, motion values).
//
// node lint.mjs [files or directories ...] [--json]
// node lint.mjs [files or directories ...] [--app] [--json]
//
// Exit code 1 when any FAIL is found. Zero dependencies.
import { readFileSync, readdirSync, statSync } from 'node:fs';
@@ -10,6 +10,11 @@ import { join, extname, relative, resolve } from 'node:path';
const args = process.argv.slice(2);
const asJson = args.includes('--json');
// App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels
// and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself.
let appMode = args.includes('--app');
try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {}
const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null };
const roots = args.filter((a) => !a.startsWith('--'));
if (!roots.length) roots.push('.');
const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']);
@@ -19,7 +24,7 @@ const files = []; const tooBig = [];
const walk = (p) => {
let st; try { st = statSync(p); } catch { return; }
if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); }
else if (EXT.has(extname(p)) && !/\.min\.|lock|\.d\.ts$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); }
};
for (const r of roots) {
try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); }
@@ -54,6 +59,11 @@ const R = [
['WARN', 'h-screen', /\bh-screen\b|height\s*:\s*100vh/, '100vh jumps on mobile. Use 100svh / 100dvh.'],
['WARN', 'bounce', /animate-(bounce|ping|pulse)\b/, 'Looping attention animation (bounce / ping / pulse dot).'],
];
if (appMode) {
for (const r of R) if (r[1] in APP_LEVEL) r[0] = APP_LEVEL[r[1]];
R.push(['FAIL', 'placeholder-data', /\b(John|Jane) Doe\b|\bAcme( Inc| Corp)?\b|\bexample\.com\b|\bFirstname Lastname\b|\b123 Main St\b/, 'Placeholder data. Use believable names, amounts that add up and real units for this product.']);
R.push(['WARN', 'framework-colour', /#(3b82f6|2563eb|1d4ed8|60a5fa|10b981|22c55e|ef4444|f59e0b)\b/i, 'A framework default colour (Tailwind blue, green, red, amber). Take the accent and status colours from the dealt style.']);
}
const findings = [];
let emDashes = 0;
@@ -73,6 +83,7 @@ for (const file of files) {
if (/taste-skill-ignore/.test(line)) return;
if (!/^\s*(\/\/|\/\*|\*|<!--)/.test(line)) emDashes += (line.replace(/\/\/.*$|\/\*.*?\*\/|<!--.*?-->/g, '').match(/—/g) || []).length; // comments are not copy
for (const [level, id, re, message] of R) {
if (!level) continue;
if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// tasteskill / scenes — photograph every scroll scene on a page at exact points of its progress.
//
// node scenes.mjs <url | path/to/file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]
//
// For each <section class="scene"> (references/scenes.md): the screen before it, 0, 10, … 100% of its
// hold, and the screen after it, on desktop (1440x900) and on a phone (390x844), as labelled contact
// sheets; then the reduced-motion page screen by screen. Prints each scene's height in screens, frame
// timing while the page is wheeled through its scenes, and console errors. Read every sheet it writes.
//
// Exit code 0 = every scene ran; 1 = a scene never started, fell back to rest on desktop, or the page
// logged errors. Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
import { existsSync, mkdirSync, statSync, createReadStream } from 'node:fs';
import { resolve, join, dirname, extname, sep, relative } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
const args = process.argv.slice(2);
const flag = (k, d) => { const i = args.indexOf('--' + k); if (i === -1) return d; const v = args[i + 1]; args.splice(i, 2); return v; };
const outDir = resolve(flag('out', '.taste-skill/scenes'));
const STEPS = Math.max(2, +flag('steps', 10)), WAIT = +flag('wait', 900);
const only = flag('scene', '');
const target = args[0];
if (!target || target.startsWith('--')) { console.error('usage: node scenes.mjs <url | file.html> [--out DIR] [--steps 10] [--scene "#id"] [--wait 900]'); process.exit(2); }
// A local file is served from 127.0.0.1 (the working directory when the file is inside it, else the
// file's folder), so relative assets and CDN scripts behave as they will when the page is hosted.
let url = target, server = null;
if (!/^https?:\/\//.test(target)) {
const file = resolve(target);
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
const cwd = resolve('.');
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff': 'font/woff', '.woff2': 'font/woff2' };
server = createServer((req, res) => {
try {
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
if (statSync(f).isDirectory()) f = join(f, 'index.html');
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
createReadStream(f).pipe(res);
} catch { res.writeHead(404); res.end('not found'); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
mkdirSync(outDir, { recursive: true });
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const problems = [];
async function sheet(frames, labels, cellW, cellH, cols, path) {
const rows = Math.ceil(frames.length / cols);
await helper.setViewport({ width: cols * cellW, height: rows * (cellH + 22), dsf: 1 });
await helper.eval((a) => {
document.body.style.cssText = `margin:0;background:#777;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px);font:12px/22px system-ui;color:#fff`;
document.body.innerHTML = a.frames.map((s, i) => `<figure style="margin:0"><img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #777"><figcaption style="padding-left:6px;background:#111">${a.labels[i]}</figcaption></figure>`).join('');
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
}, { frames, labels, cellW, cellH, cols });
await helper.screenshot({ path, quality: 78 });
}
const SCENES = `[...document.querySelectorAll(${JSON.stringify(only || 'section.scene, .scene[data-scene]')})].map((el, i) => { const r = el.getBoundingClientRect(); return { id: el.id || el.dataset.scene || ('scene-' + (i + 1)), top: Math.round(r.top + scrollY), height: Math.round(r.height), live: el.classList.contains('is-live'), rest: el.classList.contains('is-rest') }; })`;
const go = (y) => `(() => { const y = ${y}; if (window.Stage && Stage.lenis) Stage.lenis.scrollTo(y, { immediate: true, force: true }); else window.scrollTo(0, y); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); })()`;
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
async function run(label, vp) {
const page = await browser.newPage(vp);
await page.goto(url, { timeoutMs: 40000, settleMs: 1200 });
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(500);
const scenes = await page.eval(SCENES);
if (!scenes.length) console.log(`${label}: no <section class="scene"> on the page`);
const H = vp.height;
for (const sc of scenes) {
if (!sc.live && !sc.rest) problems.push(`${label} ${sc.id}: never started (stage.js, the scene file or Stage.init() missing)`);
if (label === 'desktop' && sc.rest) problems.push(`${label} ${sc.id}: shows its finished composition instead of running (GSAP not loaded, or the scene threw)`);
const frames = [], labels = [];
const span = Math.max(0, sc.height - H);
const points = [{ y: Math.max(0, sc.top - H * 0.5), l: 'before' }, ...Array.from({ length: STEPS + 1 }, (_, k) => ({ y: sc.top + (span * k) / STEPS, l: `${Math.round((k / STEPS) * 100)}%` })), { y: sc.top + span + H * 0.6, l: 'after' }];
for (const p of points) {
await page.eval(go(Math.round(p.y))).catch(() => {});
await sleep(WAIT);
frames.push(await page.screenshot({ quality: 72 }));
labels.push(`${sc.id} ${p.l}`);
}
const cw = vp.width >= 1000 ? 480 : 240, ch = Math.round((cw * H) / vp.width);
const path = join(outDir, `${sc.id}-${label}.jpg`);
await sheet(frames, labels, cw, ch, vp.width >= 1000 ? 4 : 7, path);
console.log(`${label.padEnd(8)} ${sc.id}: ${(sc.height / H).toFixed(1)} screens tall, ${sc.live ? 'running' : sc.rest ? 'finished composition' : 'not started'} → ${rel(path)}`);
}
if (label === 'desktop' && scenes.length) {
// frame timing while the page is wheeled through its scenes
await page.eval(go(Math.max(0, scenes[0].top - H))).catch(() => {});
await sleep(400);
await page.eval(`(() => { window.__f = []; let last = performance.now(); const tick = (t) => { window.__f.push(t - last); last = t; if (window.__f.length < 600) requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()`);
await page.wheel(Math.min(scenes.reduce((a, s) => a + s.height, 0), 9000), { step: 120, pauseMs: 16 });
await sleep(600);
const f = await page.eval('window.__f.slice(2)');
const sorted = f.slice().sort((a, b) => a - b);
console.log(`smoothness: ${f.length} frames, median ${(sorted[Math.floor(f.length / 2)] || 0).toFixed(1)} ms, ${f.filter((d) => d > 34).length} over 34 ms (headless timing is rough; a handful is normal)`);
}
if (page.consoleErrors?.length) { problems.push(`${label} console: ${page.consoleErrors.slice(0, 3).join(' | ')}`); }
await page.close();
}
try {
await run('desktop', { width: 1440, height: 900, dsf: 1 });
await run('phone', { width: 390, height: 844, dsf: 1, mobile: true });
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
await rp.goto(url, { timeoutMs: 40000, settleMs: 1200 });
const h = await rp.eval('document.documentElement.scrollHeight');
const frames = [], labels = [];
for (let y = 0, k = 0; y < h && k < 16; y += 900, k++) { await rp.eval(`window.scrollTo(0, ${y})`); await sleep(350); frames.push(await rp.screenshot({ quality: 70 })); labels.push(`reduced motion, screen ${k + 1}`); }
await sheet(frames, labels, 360, 225, 4, join(outDir, 'reduced-desktop.jpg'));
console.log(`reduced ${frames.length} screens → ${rel(join(outDir, 'reduced-desktop.jpg'))}`);
await rp.close();
} finally {
await browser.close();
if (server) server.close();
}
if (problems.length) { console.log('\nPROBLEMS'); for (const p of problems) console.log(' ' + p); }
console.log(problems.length ? `\nRESULT: ${problems.length} problem(s). Fix them, then look at every sheet.` : '\nRESULT: every scene ran. Now look at every sheet: does each frame read, and is the scene as bold as it should be?');
process.exit(problems.length ? 1 : 0);
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Every skill must load in every agent. Claude Code and Codex read SKILL.md frontmatter with a strict
// YAML parser and silently drop a skill whose frontmatter fails to parse (an unquoted "word: word" in
// a description is enough), so this check parses each one the same way and fails loudly.
//
// npm install --no-save --no-package-lock yaml@2.6.1 (once; CI does this)
// node tests/frontmatter.mjs
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
let YAML;
try { YAML = await import('yaml'); } catch {
console.error('This check needs the "yaml" package: npm install --no-save --no-package-lock yaml@2.6.1');
process.exit(2);
}
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = join(ROOT, 'skills');
const errors = [];
const fail = (where, msg) => errors.push(`${where}: ${msg}`);
const parse = (text, where) => {
const doc = YAML.parseDocument(text, { strict: true, uniqueKeys: true, prettyErrors: true });
for (const e of [...doc.errors, ...doc.warnings]) fail(where, `YAML ${e.name}: ${e.message.split('\n')[0]}`);
return doc.errors.length ? null : doc.toJS();
};
const names = new Map();
for (const dir of readdirSync(SKILLS)) {
const file = join(SKILLS, dir, 'SKILL.md');
if (!statSync(join(SKILLS, dir)).isDirectory() || !existsSync(file)) continue;
const where = `skills/${dir}/SKILL.md`;
const src = readFileSync(file, 'utf8');
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
if (!m) { fail(where, 'no frontmatter block (--- ... ---) at the top'); continue; }
const fm = parse(m[1], where);
if (!fm) continue;
if (typeof fm.name !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(fm.name) || fm.name.length > 64) fail(where, `name must be kebab-case, 64 characters at most (got ${JSON.stringify(fm.name)})`);
if (typeof fm.description !== 'string' || !fm.description.trim()) fail(where, 'description must be a non-empty string');
else if (fm.description.length > 1024) fail(where, `description is ${fm.description.length} characters; agents allow 1024 at most`);
if ('disable-model-invocation' in fm && typeof fm['disable-model-invocation'] !== 'boolean') fail(where, 'disable-model-invocation must be true or false');
if (fm.name) { if (names.has(fm.name)) fail(where, `name "${fm.name}" is also used by ${names.get(fm.name)}`); else names.set(fm.name, where); }
const openai = join(SKILLS, dir, 'agents', 'openai.yaml');
if (existsSync(openai)) {
const o = parse(readFileSync(openai, 'utf8'), `skills/${dir}/agents/openai.yaml`);
if (o && o.policy && 'allow_implicit_invocation' in o.policy && typeof o.policy.allow_implicit_invocation !== 'boolean') fail(`skills/${dir}/agents/openai.yaml`, 'policy.allow_implicit_invocation must be true or false');
}
}
// plugin manifests: valid JSON, and every skill path they list exists
for (const rel of ['.claude-plugin/marketplace.json', '.codex-plugin/plugin.json', '.agents/plugins/marketplace.json']) {
const p = join(ROOT, rel);
if (!existsSync(p)) continue;
let j; try { j = JSON.parse(readFileSync(p, 'utf8')); } catch (e) { fail(rel, 'invalid JSON: ' + e.message); continue; }
for (const plugin of j.plugins || []) for (const s of [].concat(plugin.skills || [])) if (!existsSync(join(ROOT, s, 'SKILL.md'))) fail(rel, `plugin "${plugin.name}" lists ${s}, which has no SKILL.md`);
}
// the default skill's data files
const lib = join(SKILLS, 'taste-skill', 'library');
if (existsSync(lib)) for (const f of readdirSync(lib)) if (f.endsWith('.json')) { try { JSON.parse(readFileSync(join(lib, f), 'utf8')); } catch (e) { fail(`skills/taste-skill/library/${f}`, 'invalid JSON: ' + e.message); } }
if (errors.length) {
console.error(`not ok - ${errors.length} problem(s):`);
for (const e of errors) console.error(' ' + e);
process.exit(1);
}
console.log(`ok - ${names.size} skills load: every frontmatter parses, names are unique, manifests point at real skills`);
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { screenshotLabel, isInspectionScreenshot } from '../skills/taste-skill/scripts/lib/inspection-files.mjs';
test('default widths retain their existing screenshot names', () => {
const widths = [1440, 768, 390];
assert.deepEqual(widths.map((width) => screenshotLabel(width, widths)), ['desktop', 'tablet', 'mobile']);
});
test('distinct widths in one viewport class cannot overwrite fold, page, or film images', () => {
const widths = [1280, 1024, 768, 390];
for (const suffix of ['fold', 'page-01', 'film-01']) {
const files = widths.map((width) => `${screenshotLabel(width, widths)}-${suffix}.jpg`);
assert.equal(new Set(files).size, widths.length);
assert.ok(files.includes(`tablet-1024-${suffix}.jpg`));
assert.ok(files.includes(`tablet-768-${suffix}.jpg`));
}
});
test('collisions are disambiguated in each viewport class', () => {
const widths = [1600, 1280, 900, 768, 500, 390];
assert.deepEqual(widths.map((width) => screenshotLabel(width, widths)), [
'desktop-1600', 'desktop-1280', 'tablet-900', 'tablet-768', 'mobile-500', 'mobile-390',
]);
});
test('cleanup recognizes legacy and width-qualified inspection images only', () => {
for (const file of ['desktop-fold.jpg', 'tablet-1024-fold.jpg', 'tablet-768-page-01.jpg', 'desktop-1280-film-02.jpg']) {
assert.equal(isInspectionScreenshot(file), true, file);
}
for (const file of ['notes.jpg', 'tablet-1024-original.jpg', 'mobile-fold.png', 'desktop-fold.jpg.bak']) {
assert.equal(isInspectionScreenshot(file), false, file);
}
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { hostname, tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
const OWNER = 'taste-skill-owner.json';
// A pid that is certainly gone: a child that has already exited.
const deadPid = () => spawnSync(process.execPath, ['-e', '']).pid;
const profile = (base, name, owner) => {
const p = join(base, name);
mkdirSync(p);
writeFileSync(join(p, 'sentinel'), 'preserve');
if (owner !== undefined) writeFileSync(join(p, OWNER), typeof owner === 'string' ? owner : JSON.stringify(owner));
return p;
};
// Run in a child so Browser.launch()'s exit/signal handlers cannot leak into the test runner.
if (process.argv[2] === '--fixture') {
const { Browser } = await import('../skills/taste-skill/scripts/lib/cdp.mjs');
globalThis.WebSocket = class {
constructor() { queueMicrotask(() => this.onopen?.()); }
send(raw) {
const { id } = JSON.parse(raw);
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ id, result: {} }) }));
}
close() {}
};
const base = process.env.TASTE_SKILL_TMP;
const profiles = ['ad-chrome-open-lock', 'ad-chrome-no-lock'].map(name => profile(base, name));
const gone = deadPid();
const abandoned = profile(base, 'ad-chrome-abandoned', { host: hostname(), pid: gone, chrome: gone });
let lock;
let browser;
try {
lock = openSync(join(profiles[0], 'lockfile'), 'w+');
for (const p of [...profiles, abandoned]) {
const old = new Date(Date.now() - 10 * 60 * 1000);
utimesSync(p, old, old);
}
browser = await Browser.launch();
for (const p of profiles) {
assert.ok(existsSync(join(p, 'sentinel')), 'launch deleted another profile');
}
assert.equal(existsSync(abandoned), false, 'launch kept a profile whose owners are gone');
const owner = JSON.parse(readFileSync(join(browser.dir, OWNER), 'utf8'));
assert.deepEqual(owner, { host: hostname(), pid: process.pid, chrome: browser.proc.pid }, 'launch did not claim its profile');
} finally {
if (browser) await browser.close();
if (lock !== undefined) closeSync(lock);
}
assert.equal(existsSync(browser.dir), false, 'close left the owned profile behind');
} else {
test('launch preserves older foreign profiles and close removes its own', {
skip: process.platform === 'win32' && 'POSIX fake browser fixture',
}, () => {
const base = mkdtempSync(join(tmpdir(), 'taste-profile-test-'));
try {
const fakeChrome = join(base, 'fake-chrome');
// No real browser, network, or existing user profile is accessed.
writeFileSync(fakeChrome, '#!/bin/sh\nprintf "DevTools listening on ws://fixture\\n" >&2\nexec tail -f /dev/null\n', { mode: 0o755 });
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--fixture'], {
env: { ...process.env, CHROME_PATH: fakeChrome, TASTE_SKILL_TMP: base },
encoding: 'utf8',
timeout: 15000,
});
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stdout + result.stderr);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('sweep removes a profile only when its owner file says both processes are gone', async () => {
const { sweepProfiles } = await import('../skills/taste-skill/scripts/lib/cdp.mjs');
const base = mkdtempSync(join(tmpdir(), 'taste-sweep-test-'));
try {
const gone = deadPid();
const here = hostname();
const abandoned = profile(base, 'ad-chrome-abandoned', { host: here, pid: gone, chrome: gone });
const kept = [
profile(base, 'ad-chrome-run-alive', { host: here, pid: process.pid, chrome: gone }),
profile(base, 'ad-chrome-chrome-alive', { host: here, pid: gone, chrome: process.pid }),
profile(base, 'ad-chrome-other-machine', { host: `${here}-elsewhere`, pid: gone, chrome: gone }),
profile(base, 'ad-chrome-no-owner'),
profile(base, 'ad-chrome-torn-owner', '{"host":'),
profile(base, 'ad-chrome-bad-pid', { host: here, pid: 'x', chrome: gone }),
profile(base, 'not-a-profile', { host: here, pid: gone, chrome: gone }),
];
sweepProfiles(base);
assert.equal(existsSync(abandoned), false, 'sweep kept a profile whose owners are gone');
for (const p of kept) assert.ok(existsSync(join(p, 'sentinel')), `sweep removed ${p}`);
sweepProfiles(join(base, 'missing')); // a missing base is not an error
} finally {
rmSync(base, { recursive: true, force: true });
}
});
}
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env bash
set -u
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
failures=0
assert_contains() {
local actual="$1"
local expected="$2"
if [[ "$actual" != *"$expected"* ]]; then
printf 'Expected output to contain:\n %s\nActual output:\n%s\n' "$expected" "$actual" >&2
return 1
fi
}
assert_not_contains() {
local actual="$1"
local unexpected="$2"
if [[ "$actual" == *"$unexpected"* ]]; then
printf 'Expected output not to contain:\n %s\nActual output:\n%s\n' "$unexpected" "$actual" >&2
return 1
fi
}
assert_equal() {
local actual="$1"
local expected="$2"
local message="$3"
if [[ "$actual" != "$expected" ]]; then
printf '%s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
return 1
fi
}
run_test() {
local name="$1"
shift
if "$@"; then
printf 'ok - %s\n' "$name"
else
printf 'not ok - %s\n' "$name" >&2
failures=$((failures + 1))
fi
}
assert_source_returns_to_caller() {
local expected_status="$1"
shift
local caller_output
local caller_status
caller_output="$(
"$BASH" -c '
set -u
source ./skill.sh "$@" >/dev/null 2>&1
source_status=$?
printf "continued:%s\n" "$source_status"
' bash "$@"
)"
caller_status=$?
assert_equal "$caller_status" "0" "Sourcing caller terminated before it could handle the result." || return 1
assert_equal "$caller_output" "continued:$expected_status" "Sourced helper returned the wrong status."
}
test_help_uses_source_invocation() {
local output
output="$(source ./skill.sh --help)"
assert_contains "$output" "Usage: source ./skill.sh <skill-name>" || return 1
assert_contains "$output" " source ./skill.sh --list" || return 1
assert_not_contains "$output" "Usage: ./skill.sh <skill-name>" || return 1
assert_not_contains "$output" " ./skill.sh --list"
}
test_help_returns_to_sourcing_caller() {
assert_source_returns_to_caller 0 --help
}
test_list_returns_to_sourcing_caller() {
assert_source_returns_to_caller 0 --list
}
test_valid_skill_returns_path_to_sourcing_caller() {
local caller_output
local caller_status
caller_output="$(
"$BASH" -c '
set -u
source ./skill.sh taste-skill
source_status=$?
printf "continued:%s\n" "$source_status"
'
)"
caller_status=$?
assert_equal "$caller_status" "0" "Valid lookup terminated the sourcing caller." || return 1
assert_equal "$caller_output" $'skills/taste-skill/SKILL.md\ncontinued:0' "Valid lookup returned the wrong path or status."
}
test_unknown_skill_returns_to_sourcing_caller() {
assert_source_returns_to_caller 2 typo
}
run_test "help documents only the supported source invocation" test_help_uses_source_invocation
run_test "help returns to the sourcing caller" test_help_returns_to_sourcing_caller
run_test "list returns to the sourcing caller" test_list_returns_to_sourcing_caller
run_test "valid skills return paths to the sourcing caller" test_valid_skill_returns_path_to_sourcing_caller
run_test "unknown skills return to the sourcing caller with status 2" test_unknown_skill_returns_to_sourcing_caller
if ((failures > 0)); then
exit 1
fi
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Text files stay UTF-8. An editor that reads UTF-8 as Latin-1 and saves it back turns every middle dot
// into an A-circumflex plus a dot and every dash into three stray characters across a whole file (it
// happened in a pull request to library/type.json); the JSON still parses, so nothing else catches it.
// node tests/text-encoding.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const TEXT = new Set(['.md', '.json', '.js', '.mjs', '.cjs', '.css', '.html', '.yml', '.yaml', '.txt', '.sh', '.svg']);
const SKIP = new Set(['.git', 'node_modules']);
// UTF-8 bytes of common characters read as Latin-1 / Windows-1252, and the replacement character
const MOJIBAKE = /\u00C3[\u0080-\u00BF]|\u00C2[\u0080-\u00BF]|\u00E2\u20AC|\uFFFD/;
const bad = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
if (SKIP.has(name)) continue;
const p = join(dir, name); const st = statSync(p);
if (st.isDirectory()) { walk(p); continue; }
if (!TEXT.has(extname(name).toLowerCase()) || st.size > 4e6) continue;
const lines = readFileSync(p, 'utf8').split('\n');
lines.forEach((l, i) => { const m = l.match(MOJIBAKE); if (m) bad.push(`${relative(ROOT, p).split('\\').join('/')}:${i + 1}: "${l.slice(Math.max(0, m.index - 20), m.index + 20).trim()}"`); });
}
};
walk(ROOT);
if (bad.length) {
console.error(`not ok - ${bad.length} line(s) look double-encoded (UTF-8 saved as Latin-1). Re-save the file as UTF-8:`);
for (const b of bad.slice(0, 20)) console.error(' ' + b);
process.exit(1);
}
console.log('ok - every text file reads as clean UTF-8');