Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b76575f9 | ||
|
|
b900a8075f | ||
|
|
9b4bf937a1 | ||
|
|
f578e23218 | ||
|
|
151afddbe3 | ||
|
|
7a373bf2ed | ||
|
|
8fcde309b3 | ||
|
|
386d187d86 | ||
|
|
4c492ecf6d | ||
|
|
3324132353 | ||
|
|
b152b5b8bd | ||
|
|
ac4fffa237 | ||
|
|
f094ecf43e | ||
|
|
822e0b9d87 | ||
|
|
50cb75fc27 | ||
|
|
a851fc5c20 | ||
|
|
c9890c32eb | ||
|
|
76822a3408 | ||
|
|
c0e5dbf185 | ||
|
|
c707f2ac6d | ||
|
|
2d4644c53b | ||
|
|
0042d7f8bb | ||
|
|
829d610b13 | ||
|
|
94b71d8bc4 | ||
|
|
f2d1470444 | ||
|
|
e596f504b4 | ||
|
|
939b5640b5 | ||
|
|
be1fd9bc97 | ||
|
|
0280476a85 | ||
|
|
b5615ab1a2 | ||
|
|
94ac7c53e5 | ||
|
|
b592220c47 | ||
|
|
5ac696986a | ||
|
|
079a1cf5d0 | ||
|
|
de63a423aa | ||
|
|
f7e77ca5dc | ||
|
|
3a5767e80f | ||
|
|
8bc269818b | ||
|
|
f810636b7b | ||
|
|
47c7dee7cb | ||
|
|
cec6ef61af | ||
|
|
f05a86f7a4 | ||
|
|
134b6cd21f | ||
|
|
a30aedadc2 | ||
|
|
6b21f9ff58 | ||
|
|
71aae5b5cc | ||
|
|
6ddd0e4acf | ||
|
|
9544064aba | ||
|
|
b2a5350e84 | ||
|
|
67a5015b10 | ||
|
|
55dfd617d6 | ||
|
|
254909555e | ||
|
|
7daebfd6f5 | ||
|
|
d357baaba8 | ||
|
|
0f9a7a32da | ||
|
|
50eac221f6 | ||
|
|
5dabfc2ca9 | ||
|
|
46e9927e4a | ||
|
|
be03e9fda4 | ||
|
|
fade7eca82 | ||
|
|
e96625ecb8 | ||
|
|
d114090f18 | ||
|
|
83d4384ae4 | ||
|
|
283993be4c | ||
|
|
3943913de7 | ||
|
|
c2a8cf33fc | ||
|
|
9b840aee50 | ||
|
|
12af471f62 | ||
|
|
af8a897996 | ||
|
|
f99d059146 | ||
|
|
fd25565e10 | ||
|
|
5fac2066df | ||
|
|
643565c72d | ||
|
|
114deccee7 | ||
|
|
1dcd3a2644 | ||
|
|
aa6d99659b | ||
|
|
6010f91a99 | ||
|
|
99c1877abe | ||
|
|
588d11adca | ||
|
|
b39320c06f | ||
|
|
63aba5f43b | ||
|
|
825e306497 | ||
|
|
b9ca2fd804 | ||
|
|
9919bcb117 | ||
|
|
a9c73c3d5d | ||
|
|
f6831c5df8 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"description": "Taste skill library for Claude Code — frontend design, image-to-code, and aesthetic skills",
|
||||
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, checked in a real browser.",
|
||||
"owner": {
|
||||
"name": "leonxlnx",
|
||||
"email": "hello@tasteskill.dev"
|
||||
@@ -8,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.1.0",
|
||||
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser checker and finish gate.",
|
||||
"version": "2.5.1",
|
||||
"source": "./",
|
||||
"skills": ["./skills/taste-skill"],
|
||||
"author": {
|
||||
"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", "websites"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.1.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"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"version": "2.1.0",
|
||||
"description": "tasteskill v2: art direction for coding agents, plus image-to-code, redesign, image-generation and style skills.",
|
||||
"version": "2.5.1",
|
||||
"description": "tasteskill 2.5.1: professional company websites at the figma.com and x.ai level, checked in a real browser.",
|
||||
"author": {
|
||||
"name": "leonxlnx",
|
||||
"url": "https://github.com/Leonxlnx"
|
||||
@@ -14,21 +14,22 @@
|
||||
"frontend",
|
||||
"design",
|
||||
"taste",
|
||||
"ui"
|
||||
"ui",
|
||||
"websites"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Taste Skill",
|
||||
"shortDescription": "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": "Professional company websites, checked in a real browser.",
|
||||
"longDescription": "tasteskill 2.5.1 from Leonxlnx/taste-skill: professional company websites at the figma.com and x.ai level, with one section per screen, real photographs, real product UI, effects kept for tech and gaming pages, and a real-browser checker and finish gate.",
|
||||
"developerName": "leonxlnx",
|
||||
"category": "Design",
|
||||
"capabilities": [],
|
||||
"websiteURL": "https://github.com/Leonxlnx/taste-skill",
|
||||
"defaultPrompt": [
|
||||
"Design a distinctive frontend without generic AI patterns.",
|
||||
"Redesign this interface while preserving its brand.",
|
||||
"Turn this reference image into production-ready UI."
|
||||
"Design a professional company website without generic AI patterns.",
|
||||
"Redesign this landing page while preserving its brand and content.",
|
||||
"Build a product page with real UI cards and one section per screen."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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: |
|
||||
node -e '
|
||||
const fs = require("node:fs"), path = require("node:path"), { execFileSync } = require("node:child_process");
|
||||
const walk = d => fs.readdirSync(d, { withFileTypes: true }).flatMap(e => {
|
||||
const p = path.join(d, e.name);
|
||||
return e.isDirectory() ? walk(p) : /\.(mjs|js)$/i.test(e.name) ? [p] : [];
|
||||
});
|
||||
for (const f of [...walk("skills/taste-skill/scripts"), ...walk("skills/taste-skill/assets")]) {
|
||||
execFileSync(process.execPath, ["--check", f], { stdio: "inherit" });
|
||||
}
|
||||
'
|
||||
@@ -1,4 +1,5 @@
|
||||
.taste-skill/
|
||||
.taste-pro/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -4,6 +4,95 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
|
||||
|
||||
---
|
||||
|
||||
## 2.5.1 - 2026-11-07
|
||||
|
||||
What a 44-site test of `2.5.0` showed, fixed: builds now iterate until the checker is clean, every page moves a little, products without photographs are shown in photographs, palettes fit the trade, the copy gets a voice of its own, and nothing lies on anything else on a phone. The kit's own page models now pass the checker at 1440 and 390.
|
||||
|
||||
### What's new
|
||||
|
||||
- **The finish gate (`scripts/gate.mjs`, `references/gate.md`):** A build arms the gate at its first step (`gate.mjs --start`), and `start.mjs deal` arms it too. `gate.mjs index.html` runs the checker and `assets/layout/check.mjs` at `1440` and `390`, plus static checks for an edited kit file and for page code that hunts for a headless browser, and ends on exactly one line: `GATE: PASS`, `GATE: FAIL`, `GATE: OPEN` (three rounds used up), `GATE: SETUP` (no page yet, a framework's source shell or a page that is not local: arm the right one and run again), `GATE: ERROR` (the checks could not run) or `GATE: OFF`. It holds two checker WARNs as FAILs, a hero without its entrance (`hero-enter`) and a page with no dealt moment (`moment-none`), lints the copy of the page as the browser built it when the file is an app shell, and opens only local pages. A FAIL judged false is waived in `.taste-skill/waivers.json` with its reason (five at most), and the report starts with the `GATE:` line, the waivers and every open FAIL. In Claude Code the skill's frontmatter adds the gate as a Stop hook (it looks for the gate in the skill's folder and the usual install folders, does nothing in a folder with no build, except to say once how to arm a tasteskill page changed in the last day, and says on stderr when a build there goes unchecked); Codex and Antigravity take the same command from their own hook files. The gate never traps an agent: blocks are counted and capped; a crash or a framework shell blocks once, then allows, and a crashed run is reused, not rerun on every stop; a stop before the first run by hand is no round, so a question mid-build costs none; after a block that lists FAILs the next stop on the unchanged page is allowed once, and other stops on it block twice, then count as a round. When the user ends the build, `gate.mjs --done "<what they said>"` closes it. A PASS, or the last round's OPEN, closes it too, so a later deal starts a build of its own. Where a `.taste-skill` folder exists, every hook call, an allowed one too, is recorded in `gate.json`'s history (`"mode": "hook"`, the decision and why), and a hook that cannot find the gate leaves `.taste-skill/gate-hook-missing.txt` with the time and the variables it read. Every script that opens Chrome keeps its default profile in the OS temp folder or the project's `.taste-skill`: a relative `--profile` that leads out of the project is replaced by `.taste-skill/chrome-<script>`, with a warning (an absolute one is used as given).
|
||||
- **Motion on every page (`assets/motion`, `references/motion.md`, `references/moments.md`):** Every hero enters once on load (`data-enter` on the hero and one line in the head): the h1 rises line by line, the lead and buttons follow, the picture settles from 1.05, about a second in all, and without JavaScript, in print or under reduced motion the hero shows as written. Every page deals one moment, two on a long page: a cafe, shop, studio or service gets the photo- and type-led ones (`photo-chapters`, new: one photograph per chapter uncovered over the last; `picture-develops` in a new film look; `word-highlight`; `pinned-chapters` on product and service), still with zero WebGL. The short page (under 8 photographs) carries `picture-develops` on its section's photograph, or, with no photograph, `word-highlight` in a one-screen statement section. Shader effects and scenes stay tech only. `lines-rise`, `window-parallax` and `word-rotate` are retired (each repeated the hero's entrance or the word hero, or was a parallax), so no kind needs `--exclude`. The kit's own motion is calmer: one ease-out curve, no overshoot, hover moves only on a fine pointer, hover out at 120ms against 150ms in, the focus ring shown at once, the tab pill and switch moved by transform only. A link inside a moment's statement keeps its accessible name.
|
||||
- **Photographs over renders (`scripts/photos.mjs search`, `references/photos.md`):** A physical product, hardware or a game without the client's pictures is shown in licensed stand-in photographs of the same kind of thing, each listed in the report. `photos.mjs search` asks Wikimedia Commons for large CC0, CC BY, CC BY-SA or public-domain JPEGs (no NC or ND licences, no paintings, maps, logos or renders), and `fetch` and `credits` take its results like library frames. A product render (`assets/objects/product-render`) is made only when the brief asks for 3D or a render; the product hero has no default hotspots. A game without art leads with its name set large over a stand-in of its setting (`deal.mjs --has setting`). Set `TASTE_SKILL_RECENT=<file>` to share recent frames and palettes across folders: frames another build used lately go behind fresh ones, so two cafes no longer start from the same photographs. Unset, nothing is written outside the project. A live search's file is fetched only from Commons' own file host.
|
||||
- **Palettes by trade (`start.mjs deal --trade`):** Every palette names the trades it was made for, and `--trade cafe`, `--trade "yoga studio"` or `--trade "developer tool"` deals those first, away from the folder's last twelve built palettes (`start.mjs css` records them) and, with `TASTE_SKILL_RECENT`, the trade's last six across folders. Eight new light palettes give every place trade seven to thirteen to deal from, and a place only gets grounds that fit a place. The builder takes the first palette and writes one sentence on why it fits the trade; the place look no longer hard-codes its colours. The trade is read from the brief more carefully (an estate agent is an agency, a kitchen fitter a workshop, a delivery service no deli), and a deal in a folder under an open build joins that build instead of arming a second gate.
|
||||
- **Copy layer (`references/copy-voice.md`, `copy-registers.md`, `copy-moves.md`, `copy-tells.md`, `scripts/copy-lint.mjs`):** Leon's copy-taste research and skill, rewritten for tasteskill: a fact sheet from the brief, a voice card on the plan, the page's shape from the reader's questions, one register, the moves for each part of the page and a budget for each device. `copy.md` routes to them and now allows one verb-led heading form and the page's one pun as its brand line. `copy-lint.mjs` counts the tells of model copy (old slop words, stock lines, two-fragment headings, contrast frames, virtue words, invented stat strips, default-cast names, footer sign-offs) in about 30 ms a page with no browser; a device used past twice its budget FAILs (two-fragment and number-pair headings, contrast frames, negation runs, virtue words; riders, puffery, reveals and repeated promises only WARN). It leaves UI mocks, menus, timetables and one wording repeated alone; literal trade and virtue words (a quiet room, leverage up to 1:30, a 2,000 W motor) and abbreviations (St. Ives) pass; a part with a missing end tag no longer hides the rest of the page; a `.md` draft lints like the page. The gate runs it and its FAILs block: one finding per slop word with its sentence, a tic waived only by its whole finding, and a word from the client's own copy waived hit by hit with the brief as the reason. A listed word inside the client's own name (Elevate Pilates), as the logo, the title before its ":" or `og:site_name` writes it, is not counted and WARNs once (`copy-brand-word`).
|
||||
- **Accessibility, forms and metadata (`basics` in the checker, `references/system.md`):** The checker now reads accessible names, alt text, visible field labels, heading order, a Tab walk with a focus ring you can see at once and never under the header, the skip link, the menu button's state, `lang`, the title, description and share tags, one action at most twice a screen, map links that only send a street to directions, assumed hours that never drive an open-now line, and forms that only say thank you after a real send. The starter gains the head block, a skip link, `lang`, a `booking-form` template with each error under its own field, and `system.js` checks forms on submit.
|
||||
- **Phones (`system.css`, `collide`, `tap-size`):** On a phone a section is at least its screens and grows with its content; it used to keep a fixed height and lay its last rows over the next section, in eight of the kit's own hero demos too. The hero grows on a short phone, and on one under 700px tall its photograph and facts give way so its button stays on the first screen. Links, the nav name, footer links, credits and small buttons are 44px targets; without script a phone's bar keeps the name and the main button; a page's own phone-only hiding of a section wins over the kit's phone rule. The phone action bar waits until the hero's button has scrolled away and steps aside while the screen shows one of its actions (without script it simply stays, covering nothing); the place hero's photograph grows into what its words leave of the first screen (up to 4:5, with the 4:5 phone crop `photos.mjs` now prints and its `data-phone-fill`), and the visit block's photograph into the rest of its screen, so neither ends in an empty band. A two-screen section's four open tiles stack into its two phone screens (180px panels, 32px between every tile, the second group clear of the header), and the centred hero keeps a three-line headline and its window's whole answer on the first screen. The checker gains `collide` (words over words, over another section's picture, button or card, a picture over a button, a fixed bar over content at the top or the end of the page), `tap-size` (a target under 32px tall or 24px wide FAILs when another target is under 8px away, else WARNs; under 44px WARNs) and a walk at 320 for collide, tap-size, the first screen's fold (WARN) and anything past the screen (`off-viewport [320]`); the gate checks 390 and 320 on every run, `--quick` too.
|
||||
- **Checker fixes and `empty-area` (`scripts/consistency.mjs`, `tests/kit-clean.mjs`):** Six false positives that failed the kit's own heroes are fixed at their cause (a centred or boxed hero measured against its own frame, the product render as one box, a dealt moment's display type, drawn mocks in the stagger check), and no fix text reads as an invitation to invent a fact. New `empty-area` WARNs on a section over 35% empty inside its container and FAILs over 50%. Motion inside a dealt moment's section is accepted on every kind, the hero entrance is no reveal, and `hidden-content` also loads the page with scripts off. A hero or section with `overflow: clip` is not read as spilling, a panel with its own fill counts to its edges, the starter hero's facts at the foot of its column are no hole, and a mounted statement moment's own section is left out of `section-gaps` (and of the layout check's `sparse`). A live `photo-chapters` section may run to four whole screens, counted in viewports under any header, and any section holding `word-highlight` counts as a statement. A hero whose big scene is its entrance (`data-enter="off"`) passes `hero-enter`. `check.mjs`'s long-headline stress test adds a long compound word only on pages whose language builds them, so `hero-lines` can be fixed or waived.
|
||||
- **The kit's page models pass the checker at 1440 and 390 (`tests/kit-clean.mjs`):** All 11 hero demos, the starter and two new pages that mount moments in a real page (`assets/moments/_demo/in-page.html` for a place, `in-page-service.html` for a service) report no FAIL at those widths; before, 30 of 31 kit pages had one. The hero demos link their logos to `#top`, set later splits text left and picture right, name what they sell in their titles and keep their phone panels whole. A product page is dealt only the product, photo and film heroes (`heroes.json`), since a split, word, statement or triptych hero cannot show the product at 80% of the content width; the split demo is now a service page. The starter's two-screen features section has all four tiles, the close's big button stays within half the container, and `pinned-chapters` sits on the page's 6/6 split. `kit-clean.mjs` checks the hero demos, the starter and the in-page pages with the checker and `check.mjs` at the gate's widths (the gate also holds `hero-enter` and `moment-none` and lints the copy); `--demos` adds the standalone moment demos, which show a moment alone and are not page models.
|
||||
- **The stack:** An empty folder builds a static page from `assets/starter.html`; an existing project builds in its own stack with the kit served from its public folder; a brief that asks for Next.js builds a static export, checked on its built page with `--root`. The description no longer promises any stack.
|
||||
|
||||
### Thanks
|
||||
|
||||
To the community contributors whose pull requests went into `2.5.x`: @bougieL (each checked page gets its own report folder, [#181](https://github.com/Leonxlnx/taste-skill/pull/181)), @hummbl-dev (`--root` for the scripts that serve a page file, [#182](https://github.com/Leonxlnx/taste-skill/pull/182)), @ALANDVO (`font-load` warns for each web font that never loads, [#186](https://github.com/Leonxlnx/taste-skill/pull/186)) and @ICR-1 (the eyebrow fix in [#185](https://github.com/Leonxlnx/taste-skill/pull/185), written for the inspector that 2.5 replaced).
|
||||
|
||||
## 2.5.0 - 2026-11-05
|
||||
|
||||
A complete rebuild of `design-taste-frontend` (`tasteskill 2.5.0`) around professional company websites at the `figma.com` and `x.ai` bar, replacing the `2.1.x` direction-sheet and app-screen pipeline (`draw.mjs`, `assets.mjs`, `lint.mjs`, `inspect.mjs`, `scenes.mjs`).
|
||||
|
||||
### What's new compared with 2.1.x
|
||||
|
||||
- **One page system (`starter.html`, `system.css`, `system.js`, `assets/layout/`):** Every build starts from a shared HTML/CSS foundation instead of open-ended layout generation. The hero fills the first screen alone, every body section and `footer.closing` is planned to one or two full screens at `1440x900` with no neighbour peeking into a section tile, and left and right content edges match across the page. `assets/layout/deal.mjs` deals eleven tested hero layouts (`split`, `centred`, `product`, `photo`, `place`, `collage`, `editorial`, `word`, `film`, `statement`, `triptych`), verified by `assets/layout/check.mjs` including max-headline stress tests.
|
||||
- **Strict business-kind routing (effects only on tech and gaming pages):** Every page declares its kind up front (`software`, `gaming`, `tech` for technical hardware, `product`, `service` or `place`). Shader grounds (`scripts/effects.mjs`, `assets/effects/`), creative scroll and pointer moments (`scripts/moments.mjs`, `assets/moments/`), scroll-held big scenes (`assets/scenes/`) and WebGL object prints (`assets/objects/`) are dealt only on `software`, `tech` and `gaming` pages within a shared cap of 4 live WebGL contexts, and every effect has a static phone and reduced-motion state. Cafes, hotels, shops, studios, services and non-technical products get zero WebGL or scroll effects and lead with photographs, typography and flat colour.
|
||||
- **Real product UI fragments, components and diagrams:** `assets/ui/` ships 19 HTML product UI fragments (`chat`, `diff-review`, `pipeline-board`, `stat`, `notify`, `terminal-run`, `voice` and others) staged inside lit feature-card panels (`assets/ui/cards.css`, `assets/ui/ui.js`, `references/cards.md`), joined by interactive feature components in `assets/components/` and preset-driven node-and-line diagrams in `assets/diagrams/`.
|
||||
- **Curated photographs, no captions, real brand icons:** `scripts/photos.mjs` deals vetted Unsplash and Wikimedia Commons photographs (`assets/photos/`, a set of them bundled as offline WebP) by trade as detail slots (capped at 1 in 3 page images unless `--draft-lead` is passed for a client without photographs), enforces image weight budgets, and writes a single expandable footer credit line (`photos.mjs credits`). Captions under images, renders and UI visuals are banned across all page kinds. `scripts/icons.mjs` resolves real full-colour brand marks (Gil Barbara's logos, Simple Icons where no colour mark exists), file-type icons and Lucide interface icons into local SVGs (`assets/icons/`).
|
||||
- **Palettes, typography, CSS grounds and calm motion:** `scripts/start.mjs` deals palettes and Google Fonts pairings by page kind (`library/palettes.json`, `library/type.json`) and emits the exact `<head>` links and `:root` tokens. `assets/backgrounds/` provides 16 contrast-verified CSS grounds for tech sections, and `assets/motion/` supplies restrained hover, press, tab, focus, count and single-loop card interactions.
|
||||
- **Real-browser consistency, performance and screenshot checks over HTTP:** `scripts/consistency.mjs` replaces `inspect.mjs` and `lint.mjs` with over 100 browser checks across `1440`, `1024`, `768` and `390` (rendered visual emptiness, GLSL ES reserved-word linting, forced shader-failure fallbacks, WCAG text and button contrast, grid and split alignment, section rhythm, placeholder and invented-address detection, slow-mobile image loading and reduced-motion compliance), paired with `scripts/perf.mjs` and `scripts/shoot.mjs`.
|
||||
- **A product is always shown, and shown big (`assets/objects/product-render`):** When a physical product has no photographs, the page renders it from a JSON spec of its parts in real studio materials (a city bike, a desk lamp, a robot arm) instead of hiding it behind an abstract shader or an order form. The hero product stands on the page ground with no box around it and fills most of the content width (the checker fails a product hero under 60% of it), every section shows a different view (`side`, `three-quarter`, `front`, close-ups of a named part, loaded or in-use scenes with props such as a child seat or crates), and an order card's colour buttons swap the picture with no page script (`data-product-swatch`, or the `product:set` event). `make-posters.mjs` turns a spec into stills, which pages show by default once they are made; a live turn then runs only when a page asks for it (`data-product-mode="live"`). The checker fails a product page whose pictures never show the product or repeat a picture, and one that shows a view twice in one colour or in neighbouring sections (another colour further down warns).
|
||||
- **One object never carries the page:** The product or hero object is shown whole in the hero and two more sections at most; the other sections are carried by a different kind of picture (for a game its world and crew, for hardware the product in use, its parts or an exploded view, close-ups, a still diagram, a type moment). The checker warns when one object, shown whole, is the main picture of more than three sections (`same-object`, on product, tech and gaming pages).
|
||||
- **The client's own assets get slots (`client-slots` in `references/sections-library.md`):** When a brief says the client has a trailer, key art, screenshots, CAD renders or a motion video, the page holds a designed slot for each that already reads finished (a video frame with a poster, a strip of captures, a CAD frame), named in a comment for the client's file.
|
||||
- **Software shows its product in one app window (`app-frame` in `references/sections-library.md`):** Every software page has one app window across the full content width, a list pane beside the open item, composed from the `assets/ui` fragments (pull requests beside a diff, an inbox beside a thread, a pipeline board). It stacks at tablet width and shows the open item alone on a phone.
|
||||
- **Hover you can see:** Buttons step 1px on hover and press on click, linked cards lift 2px, photographs zoom 1.02-1.03 inside their frame and underlines draw; under reduced motion only the colours change.
|
||||
- **Scroll moments stay inside their section:** `iris-opens` and `line-carries-colour` open inside an inset panel with an even gutter and reveal each block whole, so no word is cut mid-move.
|
||||
- **Tablet screens are planned and checked:** Sections are planned at `768` to `820` as well as `1440` and `390`, and the checker's rhythm pass runs at `768` by default.
|
||||
- **Runs on any machine:** Every script that opens Chrome takes `--profile` for its Chrome profile, then the `TASTE_SKILL_TMP` folder, then the system temp folder.
|
||||
- **Install on Windows:** `install.sh` makes a real link (or a directory junction when symlinks are not allowed) instead of silently copying the folder in Git Bash, and an older copied folder or a failed link ends the run with an error instead of a quiet skip.
|
||||
- **Tested on a fresh install:** A clean install built a small bakery page from `SKILL.md` alone and ran every script; what it tripped on is fixed. The split nav, the hero photograph `photos.mjs fetch` prints and the offer's menu groups beside a photograph pass the checks; a place with under 8 photographs is the short page whatever its trade, with one layout for its menu, its visit block and the photograph beside the menu that passes at every width; a brief without a street prints the district only; `assets/layout/check.mjs` serves a page file itself like the other checkers; `photos.mjs` prints commands that run from any folder; the scene index is never written into the installed skill; and every script runs the same through a symlinked install (`install.sh`).
|
||||
- **Honest reports:** A check whose measurement timed out reads `partly measured` (WARN) instead of PASS, a product without stills is told to run `make-posters.mjs`, and a product's own render is never counted as a page effect.
|
||||
- **Plain words and honest pictures:** `references/copy.md` and the checker cap the words on a screen and the footer to one line, and every picture must render (a visual that comes up empty fails at `1440`, `1024`, `768` and `390`).
|
||||
|
||||
## 2.1.5 - 2026-10-25
|
||||
|
||||
Fixes from the community. Thanks to everyone who reported and sent them.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -1,34 +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>
|
||||
|
||||
<p align="center" style="margin-bottom: 8px;">
|
||||
<p align="center">
|
||||
<a href="https://tasteskill.dev" title="Visit tasteskill.dev"><img src="https://img.shields.io/badge/website-tasteskill.dev-24292f?style=flat" alt="Visit tasteskill.dev" /></a>
|
||||
|
||||
<a href="https://tastecode.dev" title="Visit tastecode.dev"><img src="https://img.shields.io/badge/new-TasteCode-ff6b00?style=flat" alt="New: TasteCode" /></a>
|
||||
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat&logo=github&logoColor=white" alt="MIT License" /></a>
|
||||
|
||||
<a href="https://github.com/vercel-labs/agent-skills"><img src="https://img.shields.io/badge/Agent_Skills-compatible-24292f?style=flat&logo=github&logoColor=white" alt="Agent Skills compatible" /></a>
|
||||
|
||||
<a href="#install"><img src="https://img.shields.io/badge/Codex_%2F_Cursor_%2F_Claude-install-24292f?style=flat" alt="Codex, Cursor, Claude" /></a>
|
||||
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-view-24292f?style=flat" alt="Changelog" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tastecode.dev"><img src="assets/readme-tastecode.webp" alt="TasteCode is here. Taste Skill as a desktop app, with a design agent built in. Free and open source. Download at 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>The easiest way to get Taste Skill results: <a href="https://tastecode.dev"><strong>download TasteCode</strong></a>. Its design agent briefs, builds and visually reviews your UI. <a href="https://github.com/Leonxlnx/tastecode">Source on GitHub</a>.</sub></p>
|
||||
|
||||
<h3 align="center">Sponsors</h3>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="104"><a href="https://fluxionai.space/register?source=github&campaign=github-tasteskill&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&campaign=github-tasteskill&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">
|
||||
@@ -60,9 +58,9 @@
|
||||
|
||||
<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** (`tasteskill 2.5.1`) is an Agent Skill that makes coding agents build professional company websites at the `figma.com` and `x.ai` level instead of assembling a generic landing page. Every build uses plain copy, one design system, one section per screen, equal left and right edges, real photographs (licensed stand-ins when the client has none), real brand icons and real product UI, a hero entrance and one or two dealt moments on every page, quiet micro-interactions, and shader effects and scroll scenes kept strictly for tech, gaming and software pages, all verified in a real browser by a checker and a finish gate that enforce the rules.
|
||||
|
||||
Pages it has built, each from one brief:
|
||||
Pages built from a single brief in earlier v2 runs:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -77,26 +75,20 @@ Pages it has built, each from one brief:
|
||||
|
||||
The photographs are real, found in open archives and credited by the skill ([credits](examples/v2/CREDITS.md)). Watch the [launch film](https://github.com/Leonxlnx/taste-skill/releases/tag/v2.0.0).
|
||||
|
||||
<p align="center"><sub><a href="#install">Install</a> · <a href="#how-v2-works">How it works</a> · <a href="#skills">Skills</a> · <a href="#common-questions">FAQ</a> · <a href="#support-the-project">Sponsors</a> · <a href="#license">License</a></sub></p>
|
||||
|
||||
## Install
|
||||
|
||||
```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** (set `CHROME_PATH` if it is in a non-standard place). Optional: **ffmpeg** on the PATH, for WebP output and the phone crop of a hero photograph in `photos.mjs`. It installs nothing and needs no API keys. Bundled Wikimedia Commons photographs work offline; fetching additional photographs or icons uses network access.
|
||||
|
||||
<details>
|
||||
<summary>Claude Code plugin</summary>
|
||||
|
||||
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
|
||||
@@ -115,113 +107,78 @@ 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. On Windows (Git Bash) it makes a real link, or a directory junction when symlinks are not allowed, and it reports an older copied folder instead of skipping it silently.
|
||||
|
||||
</details>
|
||||
|
||||
Already have the skill? Run the install command again. The install name did not change, 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
|
||||
## How 2.5 works
|
||||
|
||||
The default skill is built from a study of 247 curated sites. A build runs in six steps:
|
||||
Use it by asking your agent to build or redesign a company site; the agent loads the skill, copies `assets/` into the project, starts from `assets/starter.html` and runs the checker before it hands the page over. Every build starts by classifying the client's business (`software`, `gaming`, `tech`, `product`, `service` or `place`) and planning the page screen by screen before writing markup:
|
||||
|
||||
1. Reads the brief and lists what only this client has.
|
||||
2. A script deals three of 20 researched directions and the agent picks one.
|
||||
3. Finds the idea: one sentence tying the client's own material to that direction.
|
||||
4. Builds with real pictures and footage from open collections, credited.
|
||||
5. Checks the page in a real browser at three widths and fails it on slop.
|
||||
6. Looks at its own screenshots and fixes the top three problems, up to three rounds.
|
||||
1. **Deal tokens and layout.** `scripts/start.mjs` deals a palette made for the trade and a Google Fonts pairing for the page kind (`library/palettes.json`, `library/type.json`), and `assets/layout/deal.mjs` deals a hero layout (`split`, `centred`, `product`, `photo`, `place`, `collage`, `editorial`, `word`, `film`, `statement` or `triptych`).
|
||||
2. **Start from one system.** `assets/starter.html`, `assets/system.css` and `assets/layout/layout.css` enforce one section per screen at desktop (`1440x900`), equal left and right content edges, a fixed navigation bar and a single-screen closing footer (`footer.closing`).
|
||||
3. **Show real product UI and real photographs.** Software and tech pages populate lit feature cards (`assets/ui/cards.css`) with rewritten HTML UI fragments (`assets/ui/`) and node-and-line diagrams (`assets/diagrams/`), with real brand and interface SVGs resolved locally by `scripts/icons.mjs`. Place, shop, hospitality and non-technical product pages lead with curated photographs (`scripts/photos.mjs`, `assets/photos/`), typography and flat colour, crediting sources in one expandable footer line with no captions under images.
|
||||
4. **Keep effects for tech and gaming pages.** Shader grounds (`scripts/effects.mjs`), scroll scenes (`assets/scenes/`) and procedural 3D prints (`assets/objects/`) are dealt only on `software`, `tech` and `gaming` pages within a four-context WebGL cap, with static fallbacks for phones and reduced motion. Every page gets a hero entrance and one or two moments from `scripts/moments.mjs`; cafes, hotels, shops, studios and services get only the photo- and type-led ones, and zero WebGL.
|
||||
5. **Check in a real browser over HTTP.** `scripts/consistency.mjs`, `assets/layout/check.mjs`, `scripts/perf.mjs` and `scripts/shoot.mjs` render the built page in headless Chrome across `1440`, `1024`, `768` and `390`, catching empty visuals, broken shaders, contrast drops, misaligned columns, section-height drift, placeholder text, invented street numbers and slow-network image gaps before the build ships. `scripts/gate.mjs` then holds the finish until no fixable FAIL is left, three rounds at most.
|
||||
|
||||
A build takes roughly 30 to 45 minutes and a few hundred thousand tokens. Details are in the [changelog](CHANGELOG.md) and the [study](skills/taste-skill/references/research.md).
|
||||
Details of what changed from `2.1.x` are in the [changelog](CHANGELOG.md).
|
||||
|
||||
Dashboards and app screens (v2.1) take their own route: the skill deals one of the app styles distilled from real product screens, builds the shell, the data, the charts and the tables to those numbers, and checks the result with app rules: quiet panels, aligned figures, colour spent on data, a phone layout where the navigation folds away. A product's landing page builds the app it shows the same way, so its screenshots look like a working product.
|
||||
## The tools
|
||||
|
||||
v2.1 also checks the frame of every page (a clear title on the first screen, equal margins, no section shoved to one side, no random blank stretches) and moves the way strong award sites do, measured on the latest 100 Awwwards Sites of the Day.
|
||||
Everything is a plain Node 22+ script in the skill folder; none installs anything.
|
||||
|
||||
## Skills
|
||||
| Tool | What it does |
|
||||
| --- | --- |
|
||||
| `scripts/start.mjs` | Deals a palette and a Google Fonts pairing for the page kind and prints the `<head>` links and `:root` tokens. |
|
||||
| `assets/layout/deal.mjs` | Deals one of eleven tested hero layouts; `assets/layout/check.mjs` checks it with long-headline stress tests. |
|
||||
| `scripts/photos.mjs` | Deals vetted photographs by trade, enforces image weight and writes the one footer credit line. |
|
||||
| `scripts/icons.mjs` | Resolves real brand and interface icons into local SVG files. |
|
||||
| `scripts/effects.mjs`, `scripts/moments.mjs` | Deal shader grounds for tech, gaming and software pages, and the moments for every kind. |
|
||||
| `scripts/gate.mjs`, `scripts/copy-lint.mjs` | The finish gate (also a Stop hook) and the copy linter it runs. |
|
||||
| `scripts/consistency.mjs` | The checker: renders the page in headless Chrome over HTTP at `1440`, `1024`, `820`, `768` and `390` and prints PASS, WARN or FAIL per rule. |
|
||||
| `scripts/perf.mjs`, `scripts/shoot.mjs` | Page weight and speed, and full-page and per-section screenshots. |
|
||||
|
||||
Each skill does one job. You do not need all of them. The **install name** is the value you pass to `--skill`.
|
||||
First run, from the project folder (a deal also arms the finish gate there): `node scripts/start.mjs deal --page software` (kinds: `software`, `gaming`, `tech`, `product`, `service`, `place`). Check a built page with `node scripts/consistency.mjs <url or file> --page <kind>`.
|
||||
|
||||
| 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, and since v2.1 for dashboards and app screens, in any stack. Works as described above. |
|
||||
## Moved skills
|
||||
|
||||
<details>
|
||||
<summary>Skills for a specific job (8)</summary>
|
||||
This repo holds one skill, `design-taste-frontend`. The others moved to repos of their own and kept their install names:
|
||||
|
||||
| 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. |
|
||||
| Install name | Where it lives now |
|
||||
| --- | --- |
|
||||
| `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)** |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Image generation skills (3)</summary>
|
||||
|
||||
These produce design images only, no code. Use them with ChatGPT Images, Codex image mode or any agent that generates images, then hand the frames to your coding agent.
|
||||
|
||||
| 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. |
|
||||
|
||||
</details>
|
||||
|
||||
### Which one should I use?
|
||||
|
||||
- Building a new public page, a dashboard or an app screen: **taste-skill**.
|
||||
- Improving a page that already exists: **redesign-skill**.
|
||||
- The visual direction is already decided: **soft-skill**, **minimalist-skill** or **brutalist-skill**.
|
||||
- You want images first, then code: an image-generation skill, or **image-to-code-skill** for the whole pipeline in one go.
|
||||
- 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 default skill need?**
|
||||
Node 22 or newer and Chrome, Edge or Chromium. It installs nothing and uses no API keys. It needs the internet only to find pictures.
|
||||
**What does the skill need?**
|
||||
Node 22 or newer and Chrome, Edge or Chromium; ffmpeg is optional (WebP photographs and the phone crop of a hero photograph). It installs nothing and uses no API keys. Bundled Commons photographs are stored locally; network access is used only when downloading additional photographs, Google Fonts or brand icons.
|
||||
|
||||
**Where do the pictures come from, and can I use them?**
|
||||
From open collections such as Wikimedia Commons, Openverse and the Met. Only public domain, CC0, CC BY and CC BY-SA files are offered, and the skill puts the credits on the page. Replace them with your own when you have them.
|
||||
From the skill's vetted photograph library (`assets/photos/photos.json`, including bundled Wikimedia Commons WebP sets and Unsplash photographs) dealt by `scripts/photos.mjs`. Library frames are used for trade and material details (or marked explicitly as draft stand-ins when the client has no photographs yet), never passed off as the client's own room, staff or product, and credited in one expandable footer line (`photos.mjs credits`).
|
||||
|
||||
**Does it work with React, Vue, Svelte, plain HTML?**
|
||||
Yes. The rules are about design, not a framework.
|
||||
An empty folder gets a static page from `assets/starter.html`. An existing React, Vue, Svelte, Astro or Tailwind project is built in its own stack, with the kit served from its public folder; a Next.js brief is built as a static export. The browser checks run on the served or built page.
|
||||
|
||||
**Is it for dashboards and app screens?**
|
||||
Yes, since v2.1. The skill tells a page from an app screen and builds a dashboard, an admin view or a settings screen with its own run: a style dealt from a study of real product screens, quiet panels, honest data and charts, and its own checks (`inspect.mjs --app`).
|
||||
**When does a page get shaders or scroll effects?**
|
||||
Shaders only on `software`, `tech` (technical hardware) and `gaming` pages, and always within a four-context WebGL budget with static phone and reduced-motion fallbacks. Places, shops, cafes, hotels, studios, services and everyday physical products stay calm: photographs, sharp typography and flat colour, with a hero entrance and one or two photo- or type-led moments in their own sections.
|
||||
|
||||
## Support the project
|
||||
|
||||
@@ -242,9 +199,9 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Community sponsors</summary>
|
||||
### 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>
|
||||
@@ -268,6 +225,7 @@ If Taste Skill helps you, consider sponsoring:
|
||||
<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">
|
||||
@@ -279,8 +237,6 @@ If Taste Skill helps you, consider sponsoring:
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -16,6 +16,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Windows (Git Bash, MSYS2, Cygwin): plain `ln -s` silently COPIES a folder there, so a
|
||||
# `git pull` would never reach the install. Ask for a real link, and fall back to a
|
||||
# directory junction (no admin or Developer Mode needed) when a symlink is not allowed.
|
||||
WINDOWS=0
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
WINDOWS=1
|
||||
export MSYS=winsymlinks:nativestrict CYGWIN=winsymlinks:nativestrict ;;
|
||||
esac
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILLS_DIR="$REPO/skills"
|
||||
|
||||
@@ -72,17 +82,42 @@ wants() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# make_link <dir> <link> -- a symlink, or on Windows a junction when symlinks are not
|
||||
# allowed; fails (non-zero) rather than leaving a copy behind
|
||||
make_link() {
|
||||
if ln -s "$1" "$2" 2>/dev/null && [[ -L $2 ]]; then return 0; fi
|
||||
[[ -e $2 && ! -L $2 ]] && rm -rf "$2" # a copy left by a non-strict ln
|
||||
if [[ $WINDOWS -eq 1 ]] && command -v cygpath >/dev/null 2>&1; then
|
||||
cmd //c mklink //J "$(cygpath -w "$2")" "$(cygpath -w "$1")" >/dev/null 2>&1 && [[ -L $2 || -d $2 ]] && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# remove_link <link> -- a symlink or a Windows junction, never the folder it points at
|
||||
remove_link() {
|
||||
if [[ $WINDOWS -eq 1 ]] && command -v cygpath >/dev/null 2>&1; then
|
||||
cmd //c rmdir "$(cygpath -w "$1")" >/dev/null 2>&1 && return 0
|
||||
fi
|
||||
rm "$1"
|
||||
}
|
||||
|
||||
problems=0
|
||||
|
||||
if [[ $mode == uninstall ]]; then
|
||||
for target in "${targets[@]}"; do
|
||||
[[ -d $target ]] || continue
|
||||
for link in "$target"/*; do
|
||||
[[ -L $link ]] || continue
|
||||
if owned_by_repo "$link"; then
|
||||
rm "$link"; echo "removed $link"
|
||||
if [[ -L $link ]]; then
|
||||
if owned_by_repo "$link"; then
|
||||
remove_link "$link"; echo "removed $link"
|
||||
fi
|
||||
elif [[ -d $link && -f $link/SKILL.md ]] && grep -q "$(basename "$link")" <(for d in "$SKILLS_DIR"/*/; do install_name "$d/SKILL.md"; done) 2>/dev/null; then
|
||||
echo "left $link (a copy, not a link -- delete it by hand)" >&2
|
||||
problems=$((problems + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
exit 0
|
||||
exit $(( problems > 0 ? 1 : 0 ))
|
||||
fi
|
||||
|
||||
found=0
|
||||
@@ -110,17 +145,23 @@ for dir in "$SKILLS_DIR"/*/; do
|
||||
link="$target/$name"
|
||||
if [[ -L $link ]]; then
|
||||
if owned_by_repo "$link"; then
|
||||
rm "$link"
|
||||
remove_link "$link"
|
||||
else
|
||||
echo "skip $link (symlink to another install -- remove it first)" >&2
|
||||
problems=$((problems + 1))
|
||||
continue
|
||||
fi
|
||||
elif [[ -e $link ]]; then
|
||||
echo "skip $link (real file/dir, not a symlink -- move it aside first)" >&2
|
||||
echo "skip $link (real file/dir, not a symlink: an older copy? move it aside first)" >&2
|
||||
problems=$((problems + 1))
|
||||
continue
|
||||
fi
|
||||
ln -s "$dir" "$link"
|
||||
echo "linked $link -> skills/$(basename "$dir")"
|
||||
if make_link "$dir" "$link"; then
|
||||
echo "linked $link -> skills/$(basename "$dir")"
|
||||
else
|
||||
echo "error could not link $link (on Windows: enable Developer Mode, or run from an elevated shell)" >&2
|
||||
problems=$((problems + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
@@ -133,3 +174,8 @@ if [[ ${#wanted[@]} -gt 0 && $found -ne ${#wanted[@]} ]]; then
|
||||
printf ' %s\n' "${all_names[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $problems -gt 0 ]]; then
|
||||
echo "$problems install(s) not done: see the lines above" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -1,81 +0,0 @@
|
||||
# tasteskill v2 (`design-taste-frontend`)
|
||||
|
||||
A design skill for coding agents, built from a study of 247 curated sites (One Page Love and Awwwards
|
||||
Sites of the Day, October 2026). It gives an agent an art direction instead of letting it fall back on
|
||||
the average landing page, holds it to a strict no-ornament house style, and makes it look at its own
|
||||
render before calling the work done.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Inventory.** The agent lists what only this client has: the name and what it means, the street,
|
||||
the numbers, the one constraint, the materials, the hours.
|
||||
2. **Draw.** `scripts/draw.mjs` deals three of 20 directions (each an idea engine seen on the
|
||||
strongest studied sites); the agent picks the one that can carry something from the inventory, and
|
||||
the script deals the rest: a hero composition, section compositions, a type system of real free
|
||||
fonts, a palette, photographs, a motion, studied references, and under every blueprint a list of
|
||||
where it disagrees with the house rules. Recent draws are remembered so projects do not repeat.
|
||||
3. **Idea and live thing.** One sentence tying the client's own material to the direction's device,
|
||||
checked against the nearest competitor, and one thing the visitor can do on the page.
|
||||
4. **Build** from the sheet, words first, full content. When the project has no photographs of its
|
||||
own, `scripts/assets.mjs` finds real pictures of the subject's world in open collections (its
|
||||
street in old postcards, old maps, its materials, engravings of its trade, museum objects of its
|
||||
kind), downloads them into the project and records their credits; the subject is drawn in code
|
||||
only where no honest picture exists.
|
||||
5. **Gate.** `scripts/lint.mjs` (source) and `scripts/inspect.mjs` (real browser, 1440 / 768 / 390)
|
||||
measure the house rules: headlines of two lines or fewer, no eyebrow labels, no monospace, no
|
||||
01/02/03, no thin rules, no gradients, no resting shadows, no cream-and-terracotta, no violet, and
|
||||
the faults blind jurors punished: no focal point, empty screens, text covered or cut, long pins,
|
||||
sideways rows, faded text.
|
||||
6. **Look.** The inspector saves screenshots; the agent critiques them with an Awwwards-weighted
|
||||
rubric, fixes the top three problems and repeats, at most three rounds.
|
||||
|
||||
Five rounds of blind tests against a no-skill baseline and another design skill are recorded in
|
||||
`references/research.md` §11.
|
||||
|
||||
## Contents
|
||||
|
||||
```
|
||||
SKILL.md the run, the house rules, the laws
|
||||
references/ copy, type, colour, layout, imagery, motion, critique, tells, research
|
||||
scripts/draw.mjs deal options / a direction / reprint / redraw; --alternatives, --photos, --site
|
||||
scripts/assets.mjs search open image collections (Openverse, Wikimedia Commons, Art Institute of
|
||||
Chicago, the Met) and, with --video, real footage (Wikimedia Commons, Internet
|
||||
Archive, NASA); --get downloads with the credit, --credits prints them
|
||||
scripts/lint.mjs static scan for banned patterns and stock phrases
|
||||
scripts/inspect.mjs render, measure, photograph
|
||||
scripts/lib/ zero-dependency Chrome DevTools client, the in-page probe, the rules
|
||||
library/ directions (20), heroes, sections, type systems, palettes, photographs,
|
||||
studied sites, motions
|
||||
assets/base.css, base.js an optional neutral foundation
|
||||
evals/evals.json six briefs for testing the skill against a no-skill baseline
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
Node 22 or newer and an installed Chrome, Edge or Chromium for `inspect.mjs` (it drives the browser
|
||||
over the DevTools protocol with Node's built-in WebSocket; nothing is installed). `draw.mjs`,
|
||||
`lint.mjs` and `assets.mjs` run on Node 18+; `assets.mjs` needs network access and no API keys. Set
|
||||
`CHROME_PATH` if the browser is somewhere unusual.
|
||||
|
||||
## Install
|
||||
|
||||
Copy this folder to your skills directory, for example `~/.claude/skills/design-taste-frontend/`, or keep it in
|
||||
a repository under `.claude/skills/`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Photographs in the library are served by Lorem Picsum from Unsplash, under the Unsplash licence.
|
||||
They are placeholders for atmosphere; replace them with the project's own pictures.
|
||||
- Pictures found with `assets.mjs` keep their own licences. By default it offers only CC0, public
|
||||
domain, CC BY and CC BY-SA, and `--get` records the title, creator, licence and source of every
|
||||
download in `assets/credits.json`. The page must carry the credits (`--credits` prints them).
|
||||
- `assets.mjs --video` finds short clips of the subject's world, takes a 720p-or-smaller rendition,
|
||||
refuses files over 25 MB unless `--max-mb` is passed, and saves a poster frame with each clip; how
|
||||
to use one on a page (muted loop, poster, stopped under reduced motion) is in
|
||||
`references/imagery.md` §11.
|
||||
- Fonts are loaded from Google Fonts and Fontshare. Check each face's licence for your use.
|
||||
- The scores in `references/research.md` are model judgements from screenshots, not Awwwards scores.
|
||||
- The draw history lives in `~/.taste-skill/history.json` (set `TASTE_SKILL_HISTORY` to move it).
|
||||
Delete it to reset.
|
||||
- A build takes an agent roughly 30–45 minutes and a few hundred thousand tokens with the full
|
||||
look-and-fix loop; most of that is finding or drawing pictures when a project has no photographs.
|
||||
@@ -1,500 +1,381 @@
|
||||
---
|
||||
name: design-taste-frontend
|
||||
description: tasteskill v2, the anti-slop frontend skill. Art direction for websites, distilled from a study of 247 curated gallery sites (One Page Love, Awwwards Sites of the Day). Deals researched directions (idea, composition, type, palette, photographs, section blueprints) from a library, enforces a strict no-slop house style with a real-browser inspector, and runs a look-and-fix loop on screenshots. Use this whenever you build, design, redesign, restyle, polish or review a landing page, marketing site, portfolio, product page, one-pager, hero or any public-facing web page, in any stack (HTML/CSS, React, Next.js, Vue, Svelte, Astro, Tailwind) — also when the user says a page looks generic, templated, "AI-made", sloppy, or asks to make it look designed, premium, award-level or less like every other site. Also for app screens: dashboards, admin and analytics views, settings, inboxes and other screens people work in, with their own run and rules.
|
||||
description: >-
|
||||
tasteskill 2.5.1, the anti-slop frontend skill. Builds professional company websites at the level
|
||||
of figma.com and x.ai: plain copy in a voice of its own, one system, one section per screen,
|
||||
equal edges, real photographs and real product UI (licensed stand-in photographs when the client
|
||||
has none; a product render only when the brief asks for 3D), a hero entrance and one or two dealt
|
||||
moments on every page, shader effects and scenes only on tech, gaming and software pages, quiet
|
||||
micro-interactions, and a checker and finish gate that enforce it in a real browser. Use this
|
||||
whenever you build, design, redesign, restyle, polish or review a landing page, company,
|
||||
product, shop, cafe, hotel, studio, SaaS, AI, developer-tool or game site, as static HTML/CSS or
|
||||
inside an existing React, Next.js, Vue, Svelte, Astro or Tailwind project, or when the user says
|
||||
a page looks generic, templated or "AI-made".
|
||||
hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: 'for f in ${CLAUDE_SKILL_DIR:+"$CLAUDE_SKILL_DIR/scripts/gate.mjs"} ${CLAUDE_PLUGIN_ROOT:+"$CLAUDE_PLUGIN_ROOT/scripts/gate.mjs" "$CLAUDE_PLUGIN_ROOT/skills/taste-skill/scripts/gate.mjs"} ${CLAUDE_PROJECT_DIR:+"$CLAUDE_PROJECT_DIR/.claude/skills/design-taste-frontend/scripts/gate.mjs" "$CLAUDE_PROJECT_DIR/.agents/skills/design-taste-frontend/scripts/gate.mjs"} ${HOME:+"$HOME/.claude/skills/design-taste-frontend/scripts/gate.mjs" "$HOME/.agents/skills/design-taste-frontend/scripts/gate.mjs"}; do if [ -f "$f" ]; then exec node "$f" --hook claude; fi; done; if [ -d .taste-skill ]; then printf "%s gate.mjs not found; CLAUDE_PLUGIN_ROOT=%s CLAUDE_PROJECT_DIR=%s HOME=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${CLAUDE_PLUGIN_ROOT-}" "${CLAUDE_PROJECT_DIR-}" "${HOME-}" > .taste-skill/gate-hook-missing.txt 2>/dev/null; fi; for d in .taste-skill */.taste-skill; do if [ -f "$d/build.json" ]; then echo "tasteskill gate: gate.mjs not found (CLAUDE_PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT-}); the build in $d is not checked" >&2; break; fi; done; exit 0'
|
||||
timeout: 600
|
||||
---
|
||||
|
||||
# tasteskill v2
|
||||
# tasteskill 2.5
|
||||
|
||||
A page built by a model usually looks like the average of every page the model has seen: a centred
|
||||
headline over three lines, a little label above it, a row of icon cards, a soft gradient, a few thin
|
||||
rules and a monospace caption. Nothing in it is wrong and nothing in it was decided.
|
||||
Build the website a good company would ship: it says plainly what is sold, shows the real thing on
|
||||
every screen, and looks made for this client, not from a template and not like an art piece.
|
||||
figma.com and x.ai are the bar (in words: `layout-v4.md` section 1).
|
||||
|
||||
This skill replaces those defaults with decisions, in four ways:
|
||||
|
||||
1. **A script deals the directions, you choose among them.** Asked to invent a look, a model picks
|
||||
the category default every time. `scripts/draw.mjs` deals three of twenty researched directions;
|
||||
you choose the one that carries the subject's idea, and the script then deals its type system,
|
||||
palette, hero and section compositions from a library distilled from 247 real sites.
|
||||
2. **The idea comes from the subject.** In the study, what separated the best sites from the forgettable
|
||||
ones was a concept taken from the thing itself, not the styling.
|
||||
3. **A house style with no ornaments,** measured in a real browser by `scripts/inspect.mjs`, so the
|
||||
rules are checked rather than hoped for.
|
||||
4. **You look at what you made.** Screenshots at three widths, a juror's rubric, fix, look again.
|
||||
|
||||
One warning learned in testing. Taking the ornaments away is only half the job; a page that has lost its
|
||||
labels, rules and cards and gained nothing in their place reads as thin, and blind jurors rightly prefer
|
||||
a richer page with tells over an empty page without them. The other half is substance: type set
|
||||
larger, whole fields of colour, real pictures of this subject's world (found or drawn), and every
|
||||
section carrying its full content. The skill is built to give you that; do not trade it for restraint.
|
||||
|
||||
In the commands below `<skill>` is this skill's folder. Run them from the project's root, so the
|
||||
working files land in `.taste-skill/` there. The scripts need Node 22+ and an installed Chrome, Edge
|
||||
or Chromium, and install nothing. `assets.mjs` also needs network access; it uses no API keys.
|
||||
|
||||
## Page or app screen
|
||||
|
||||
Decide first which of two kinds of work this is.
|
||||
|
||||
- **A page** is read and scrolled: a landing page, a marketing or product site, a portfolio, an
|
||||
event, a restaurant, a one-pager. It follows **the run** below.
|
||||
- **An app screen** is worked in: a dashboard, an admin or analytics view, settings, billing, an inbox
|
||||
or queue to work through, a wallet, a list with filters, an editor or builder. It follows
|
||||
**`references/app-screens.md`**: its own run, its own draw (`draw.mjs --app`), its own house rules
|
||||
(quiet borders, small labels and repeated panels are normal there; gradients, glows, icons in tinted
|
||||
squares, rainbow charts and invented vanity numbers are not) and `inspect.mjs --app`. Read that file
|
||||
whole before you start; the page run below does not apply.
|
||||
|
||||
A product site that shows its app is a page; the interface fragments on it follow the app-screen
|
||||
rules for their insides. Deal the product's app style once, in its own folder, with the page's colours
|
||||
and type kept:
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/draw.mjs --app --kind <kind> --brief "<the product>" --keep palette,type --out .taste-skill/app
|
||||
```
|
||||
|
||||
and build every fragment the page shows (the hero's live screen, a feature's crop, a chart) as a real
|
||||
piece of that app: `assets/app.css` for its panels, tables and controls, `assets/charts.js` for its
|
||||
charts, real data that adds up, one finding stated, cropped and scaled the way the page's blueprint
|
||||
treats a picture. A fragment that looks like a working product is the strongest picture a software page
|
||||
has; grey bars and lorem numbers are the weakest. When a request mixes both (a landing page and the
|
||||
dashboard behind it), build each with its own run and one shared palette and typeface.
|
||||
|
||||
## The run
|
||||
|
||||
Do these in order. Steps 1–5 take a few minutes and decide whether the page is any good.
|
||||
|
||||
### 1. Read the brief and the project
|
||||
|
||||
Find out what is real: the subject, the audience, the one thing the page must get someone to do, the
|
||||
stack, any existing brand (colours, fonts, logo), and above all **what imagery truly exists**: the
|
||||
project's own photographs or product shots, a real interface that can be shown, an image generation
|
||||
tool you can call, or nothing. Do not plan around pictures you do not have. Nothing of its own is
|
||||
rarely nothing at all: the subject's city, street, materials and history are in open archives and
|
||||
museums, free to use with a credit (step 6, `scripts/assets.mjs`).
|
||||
|
||||
If the request is vague, decide and state your reading in one line instead of asking. Ask only when a
|
||||
missing fact blocks the work (for example, no product name).
|
||||
|
||||
Then **inventory the subject** in your notes: eight to twelve things that belong to this client and to
|
||||
no competitor. Look for its name and what the name means; its address, door number, street and
|
||||
neighbourhood, and what that place looks like (its tiles, signs, paint colours, typefaces on its shop
|
||||
fronts, its light, its weather); the numbers in the brief (seats, rooms, minutes to the sea, the
|
||||
price, the year); the constraint it lives with (only one batch a month, closed in winter, by
|
||||
appointment); its materials and colours; its rituals and hours (the oven lit at five, the ferry at
|
||||
seven); the printed things of its world (a timetable, a label, a ticket, a sign). This list is where
|
||||
the idea comes from in step 4. In blind tests, the page that won each brief was built from an item on
|
||||
such a list (the client's name and what it means, the street it stands in, the one constraint that
|
||||
makes it what it is), and the pages that lost had a good composition with a category idea. The list
|
||||
is also your picture search: each place, material and printed thing on it is a query for step 6.
|
||||
|
||||
### 2. Write the argument
|
||||
|
||||
Before any design, list the page as claims with their proof, one line per section, the way
|
||||
`references/copy.md` §1 shows. The claims decide which sections exist and in what order. Delete any
|
||||
section that has no claim. This is how you avoid the stock sequence of hero, logos, three features,
|
||||
testimonial, pricing, call to action.
|
||||
|
||||
### 3. Draw the direction
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/draw.mjs --sector <sector> --brief "<one line about the subject>" \
|
||||
--sections <roles in the order of your argument> [--tone <tone>] [--have <assets>] [--keep palette,type] \
|
||||
--options 3
|
||||
```
|
||||
|
||||
This deals three directions and prints a short summary of each. Hold each one against your inventory:
|
||||
which way of composing can carry something only this client has? Choose that one, not the most
|
||||
familiar, and write one line in your notes on why. In testing, a single random direction that did not
|
||||
suit the subject was the commonest cause of a weak page. Then deal the full sheet:
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/draw.mjs <the same arguments, without --options> --direction <chosen id>
|
||||
```
|
||||
|
||||
- `--sector`: `node <skill>/scripts/draw.mjs --list sectors`. Pick the closest.
|
||||
- `--sections`: roles from `--list roles` (features, showcase, how-it-works, testimonial, proof, stats,
|
||||
pricing, faq, cta, footer, intro, about, work, gallery, list, contact). The hero is implied.
|
||||
- `--tone` (optional): quiet, bold, warm, technical, playful, luxury, editorial. Only pass it when the
|
||||
brief clearly asks for one.
|
||||
- `--have`: assets that really exist, comma-separated: `photography` (the project's own photographs of
|
||||
the place, people or work), `product-photo` (photographs of the specific product, or an image tool
|
||||
that can make them), `product-ui` (real screenshots), `illustration`, `video`. Leave it out if you
|
||||
have none: the draw then avoids compositions that depend on them and the sheet says how to fill
|
||||
picture slots honestly (software shows its own interface built in HTML; a product with no photographs
|
||||
is drawn and set among real pictures of its world, never a fake product shot). Pictures you will
|
||||
find in the open collections are not the project's own; leave them out of `--have`.
|
||||
- `--keep palette,type`: when the brand already fixes colours or typefaces. The draw then deals only
|
||||
the composition, and you map the brand's values onto the roles.
|
||||
|
||||
The command prints a **direction sheet**. It is the art direction for this build:
|
||||
|
||||
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
|
||||
good: the familiar layout is the one this skill exists to prevent.
|
||||
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
|
||||
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
|
||||
exist. Never redraw to get something more comfortable. Record every redraw in your report.
|
||||
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
|
||||
it wants five quotations and there are none), do not bend the content to fit. List the others with
|
||||
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with
|
||||
`--redraw section:<role>=<id>`.
|
||||
- **Check the palette against the subject.** A dealt palette is a set of proportions (one ground, one
|
||||
ink, one accent, fields) as much as a set of colours. If its colours fight the subject's own material
|
||||
(candy pastels for a peat whisky, neon for a mountain hut), redraw the palette once; if the subject
|
||||
has unmistakable colours of its own (its material, the paint of its street, the landscape outside
|
||||
its windows), keep the dealt proportions and take the colours from the subject instead, and say so
|
||||
in the report.
|
||||
- Reprint it any time with `node <skill>/scripts/draw.mjs --show`. In a long session, re-read it before each
|
||||
section you build.
|
||||
|
||||
### 4. Find the idea
|
||||
|
||||
The direction is a way of composing. The idea is what makes the page belong to this subject and no
|
||||
other. Write this sentence in your notes and do not start building until both blanks are filled from
|
||||
the subject itself:
|
||||
|
||||
> On this page, **[something that belongs to the subject]** becomes **[the device the direction calls for]**.
|
||||
|
||||
Places to look for the first blank:
|
||||
|
||||
- **Its output.** What does the thing produce? Put the real output on the page as material. (A camera
|
||||
that prints poems delivered its copy as paper slips.)
|
||||
- **Its object or shape.** What does it physically look like, in cross-section, in silhouette?
|
||||
Enlarge it, crop it, make it the frame or the one motif.
|
||||
- **Its name.** Can the letters hold a photograph, stack into a wall, split to two corners?
|
||||
- **Its place.** Is there one view that makes someone want to be there?
|
||||
- **Its ritual.** Is there a sequence the page can perform as it scrolls?
|
||||
- **Its artefact.** What printed thing already exists in its world: ticket, menu, label, receipt,
|
||||
invitation, folder, manual?
|
||||
- **Its opposite.** Which other category owns the feeling this one lacks?
|
||||
|
||||
Fill the first blank from your inventory (step 1), not from the category. **The competitor test:** put
|
||||
the nearest real competitor in the sentence (the bakery two streets away, another oak furniture maker,
|
||||
another hut on the same mountain). If the sentence still works, the idea belongs to the category: a
|
||||
loaf of bread is every bakery; the seven doors of the street this one is named after are only this one.
|
||||
"Clean and modern" is not an idea. The sites the study's panel scored lowest were the ones whose idea
|
||||
was recorded as "none beyond the standard formula".
|
||||
|
||||
**One live thing.** Give the page one thing the visitor can do, or that is different for each visit,
|
||||
made from the idea: pick a wood and see the chair in it; type your name and see it cut in the
|
||||
foundry's letters; set the loan and read the real monthly figure; see whether the hut's stove is lit
|
||||
right now and how long until the last ferry; choose a size and see it against the room. In blind tests every winning page had one and the jurors named it first. Rules: it uses the subject's real content, it works on a phone with a thumb, it has a plain
|
||||
fallback without script, it never blocks reading, and it is one thing, not a playground. It is
|
||||
built in step 6 as part of the page, not added as motion in step 9.
|
||||
|
||||
**The scenes carry the idea too.** The scroll scenes on the sheet are devices waiting for a subject.
|
||||
Decide what each one shows from your inventory before building: the fly-through passes through this
|
||||
studio's own title cards, the route is the road this shipment really takes, the picture that opens to
|
||||
the whole screen is the room the guest will wake up in. A scene filled with pictures that could belong
|
||||
to anyone is a screensaver.
|
||||
|
||||
### 5. Write the words
|
||||
|
||||
Write the whole page as a copy deck (plain text) before markup, following `references/copy.md`:
|
||||
|
||||
- **Every headline fits in two lines or fewer at every width.** Do the arithmetic in copy.md §2 before
|
||||
committing to a sentence: about 32 characters is safe everywhere at a confident size. The studied
|
||||
gallery heroes have a median of 5 words and 32 characters.
|
||||
- One supporting sentence, directly under the headline (32–48px below it) and shown with it, never
|
||||
docked at the foot of the first screen or held back by an animation. When the headline is a name or
|
||||
a figure of speech, this sentence says plainly what the thing is. One primary action, named for what
|
||||
it does, as a real button on the first screen.
|
||||
- **The first screen answers the visitor's first question in large type.** For an event: what it is,
|
||||
when and where (the dates belong in the headline or at the second size, never only in small text).
|
||||
For a product: what it is and what it costs or does. For a person: who and what they do. Every
|
||||
section the visitor came for (the line-up, the menu, the rooms, the price) has its own heading and
|
||||
its own place in the navigation.
|
||||
- **Write the full content.** Every section the brief asks for delivers its information completely:
|
||||
the whole menu with prices, each room with its own details, every speaker and talk, the price with
|
||||
what it includes, an answer for each real question. Thin sections are the commonest reason a clean
|
||||
page loses to a busier one.
|
||||
- Sample details are expected where the brief leaves gaps (dish names, room names and rates, project
|
||||
titles) so the design is seen at full density; list them in the report for the client to confirm.
|
||||
Proof is never invented: no customers, quotes, reviews, press, logos or business statistics.
|
||||
|
||||
### 6. Build it, still
|
||||
|
||||
Build the page with motion switched off in your head; a page that needs animation to look finished is
|
||||
not finished. Order of work:
|
||||
|
||||
1. **Tokens.** Colour and type roles from the sheet as CSS custom properties. Everything downstream
|
||||
references tokens only. `<skill>/assets/base.css` is a neutral foundation you may start from (reset, token
|
||||
names, fluid type steps, grid, focus and selection styles, the motion helpers); it has no look of
|
||||
its own.
|
||||
2. **The hero, exactly as the blueprint describes it,** at desktop and at phone width. Get the big
|
||||
thing big and the small things small before anything else exists.
|
||||
3. **Each section from its blueprint,** with this project's content. Keep the geometry, change the
|
||||
subject. Neighbouring sections must not share a layout. **Carry the idea into every section,**
|
||||
pricing, FAQ and footer included: the commonest failure in the study, even among good sites, is a
|
||||
distinctive first screen followed by stock blocks.
|
||||
4. **Imagery,** in this order of preference:
|
||||
1. the project's own;
|
||||
2. generated, if you have an image tool;
|
||||
3. **real pictures of the subject's world from the open collections,** whenever an honest one
|
||||
exists. Search them with `node <skill>/scripts/assets.mjs "<query>"` (Openverse, Wikimedia
|
||||
Commons, the Art Institute of Chicago and the Met; only licences that allow commercial use, by
|
||||
default). Search the inventory, not the category: its street in old postcards, an old map of the
|
||||
neighbourhood, its material up close, its trade in engravings, a museum object of its kind, a
|
||||
botanical plate, a star chart. Use them large and with intent: one archival photograph as the
|
||||
hero, an engraving enlarged into the page's motif, the real map of the street as the contact
|
||||
section. Look at each before choosing; `--get <ref>` downloads it into `assets/` and records
|
||||
its source and licence, and `--credits` prints the credit line for the footer. A found picture
|
||||
shows the world around the client and is never presented as the client's own product, room,
|
||||
dish, person or team;
|
||||
4. **drawn in code from the subject itself,** for what no honest picture exists of (its objects,
|
||||
its tiles, its windows, its product's interface, made as one consistent flat graphic language in
|
||||
the palette's real colours);
|
||||
5. the photographs dealt on the sheet, only where an honestly generic picture fits (the sea for a
|
||||
sea house), never as a stand-in for the subject;
|
||||
6. type and colour alone.
|
||||
|
||||
When the project has footage or a real picture of its own process or product, the first screen
|
||||
shows it, large (about 40% of the screen or more). Never write a sentence on the page about where
|
||||
the pictures come from or what they are not ("none of these shows our bar"): a caption says what
|
||||
the picture is, and the credit line sits small in the footer. A portfolio whose work is invented
|
||||
for the brief draws each piece as a finished still of that work (its title card with its own type,
|
||||
an end frame, a poster), with real detail, never as a pictogram of the client's product.
|
||||
|
||||
**The floor, which the inspector checks:** at least four real pictures on the page, usually six to
|
||||
twelve (photographs, archival images, maps, engravings, footage, raster artwork; for software, its
|
||||
cropped interface fragments count too). SVG drawings do not count toward the floor: draw what no
|
||||
picture exists of, and still carry real pictures. Add one moving picture wherever an honest clip
|
||||
exists: search it with `assets.mjs --video "<query>"` and use it as a muted loop with a poster
|
||||
frame. Models skip pictures and footage because finding them is work; the page fails without them.
|
||||
|
||||
`references/imagery.md` covers searching, licences and credits, video, drawing, crops and treatments.
|
||||
5. **The live thing** from step 4, built into the section where it proves the claim (often the hero
|
||||
or the booking), with real content, a thumb-sized phone version and a plain fallback.
|
||||
6. **The scroll scenes,** when the sheet deals them (section 9), each with the section it carries: the page's big
|
||||
moments, the camera that flies through the work, the picture that opens to the whole screen, the
|
||||
word that buckles under the scroll. Copy the shared stage and the scene's files from
|
||||
`<skill>/assets/scenes/`, fill the scene with this project's own pictures and words, and set the
|
||||
page's tokens so it takes the page's look. A scene is composition: it decides how long the page is
|
||||
and what each screen shows, so it is built now and seen in every look round, not added at the end.
|
||||
`references/scenes.md` says how to put one on a page, fill it, fit it and check it;
|
||||
`assets/scenes/demo/<id>.html` is a working example of each.
|
||||
7. **Fullness.** Look at each desktop screen as a poster: one dominant thing and the content it
|
||||
carries. A screen that is mostly empty ground is an underbuilt section, not a rest; one deliberate
|
||||
rest per page is the limit, and the inspector fails a page with two or more near-empty screens.
|
||||
Running text is in the full ink at 16px or more; grey is for secondary notes.
|
||||
8. **The frame.** The page has one pair of equal outer margins at every width, and every section uses
|
||||
the width the page has set up: when its text sits in the left half, the right half holds its
|
||||
picture, its second column, its list or its figure, never nothing. Asymmetry lives inside the grid,
|
||||
never in the frame: a void is placed only when something closes it on the far side. No stretch of
|
||||
the page taller than about half a screen holds nothing at all; sections are 120–240px apart on
|
||||
desktop. Every section the visitor came for opens with a heading that names it, and the first
|
||||
screen carries the page's title in an `h1`. These are what readers name first when a page looks
|
||||
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
|
||||
inspector measures each of them.
|
||||
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
|
||||
its own composition.
|
||||
|
||||
Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
|
||||
behind a decision; the laws below are the short form.
|
||||
|
||||
### 7. Gate
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/lint.mjs <source files or folder>
|
||||
node <skill>/scripts/inspect.mjs <url or path/to/index.html>
|
||||
node <skill>/scripts/scenes.mjs <url or path/to/index.html> # when the page has scroll scenes
|
||||
```
|
||||
|
||||
`scenes.mjs` photographs every scroll scene at 0, 10 … 100% of its hold, on desktop and on a phone,
|
||||
and the reduced-motion page; with scenes on the sheet, `inspect.mjs` also films the whole scroll
|
||||
(`desktop-film-NN.jpg`). A scene that never started or rests on desktop is a `FAIL`.
|
||||
|
||||
`lint.mjs` scans source for banned patterns and stock phrases. `inspect.mjs` opens the page in a real
|
||||
browser at 1440, 768 and 390 pixels, measures it (headline line counts, type sizes, fonts actually
|
||||
loaded, colours, rules, shadows, gradients, contrast, overflow, and the frame: equal margins, sections
|
||||
huddled on one side, blank stretches, the title, untitled sections, text against the screen edge) and
|
||||
saves screenshots to `.taste-skill/inspect/`. Fix every `FAIL`. Treat each `WARN` as wrong unless you can defend it to a
|
||||
designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with the meter and do not
|
||||
hide things from it. The inspector serves local files over a throwaway local server; if the page must
|
||||
also open straight from the file system, open `index.html` that way once before you finish.
|
||||
|
||||
### 8. Look
|
||||
|
||||
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
|
||||
screenshots the inspector lists (every viewport, with your image viewer or Read tool) and critique them
|
||||
with `references/critique.md`: the fourteen passes, the Awwwards-weighted score, the caps. Write the critique
|
||||
down in its format, fix the top three problems, run the inspector again, look again. Three rounds at
|
||||
most.
|
||||
|
||||
Judge only the pictures. If a problem is visible in a screenshot, it is real, whatever the code says.
|
||||
|
||||
### 9. Move
|
||||
|
||||
Now build the motion layer. It is required, and it is more than one fade: the inspector counts the
|
||||
elements that really move while the page loads and is scrolled, and fails a page under eight (it warns
|
||||
under sixteen). Any scroll scenes are already built (step 6); around them the layer has four parts:
|
||||
|
||||
1. **The signature move** named on the sheet: one, done well (a dealt scene can be it).
|
||||
2. **An entrance for the parts of every section:** the heading, then the text, then the picture,
|
||||
staggered by 60–90ms, rising 16–32px with opacity, triggered once as the section enters. Not one
|
||||
fade for the whole block, and never content that stays hidden if the script fails.
|
||||
3. **Feedback on every control:** press, hover and focus on buttons, links, cards and the live thing.
|
||||
4. **One moving picture:** a found clip as a muted loop, a slow scale or drift on a photograph, or a
|
||||
scroll-linked move on the hero picture.
|
||||
|
||||
Transform and opacity only; every move has a `prefers-reduced-motion` fallback that keeps the content
|
||||
and drops the travel. A move that changes the colour or the layout of whole screens (colour by
|
||||
chapter, a pinned story) is part of the composition, so build it in step 6 and let every look round see
|
||||
it. `references/motion.md` has the tokens and tested recipes. Then run the inspector once more and check
|
||||
every screenshot for content that failed to appear.
|
||||
|
||||
### 10. Report
|
||||
|
||||
Do not report until all of these are true; each one was skipped in a test run that came out badly:
|
||||
|
||||
- `inspect.mjs` shows **0 FAIL** at 1440, 768 and 390, on the final version of the page.
|
||||
- You opened every screenshot it listed after the last change, not only the first screen.
|
||||
- With scroll scenes: `scenes.mjs` ran on the final page with no problems, and you read every sheet
|
||||
(desktop, phone, reduced motion).
|
||||
- A stranger reading the first screen for five seconds can say what this is and what to do.
|
||||
- Every section has a heading; the margins match on both sides; no stretch of the page is empty.
|
||||
- The critique of your last round is written down in the shape of `references/critique.md` §4.
|
||||
|
||||
Tell the user, briefly: the direction dealt (name and seed) and any redraws with reasons; the idea
|
||||
sentence and the live thing; the final scores from your last critique round and what still falls short;
|
||||
every found picture and clip with its title, creator, licence and source page (`assets.mjs --credits` prints
|
||||
the list) and where its credit sits on the page; which bundled photographs and which drawings stand in
|
||||
for photography (so they can be replaced); and any sample content that must be swapped before launch.
|
||||
Do not claim a check you did not run.
|
||||
`<skill>` is this folder. It needs Node 22+ and Chrome, Edge or Chromium (ffmpeg optional).
|
||||
|
||||
## House rules
|
||||
|
||||
These hold on every build, whatever the direction. `inspect.mjs` measures them.
|
||||
These nine win over everything else in this skill.
|
||||
|
||||
| Rule | Instead |
|
||||
| --- | --- |
|
||||
| **Headlines: two lines or fewer, at every width.** Every `h1`–`h6`, and the main line of the first screen whatever its tag. | Cut words. Then widen the measure. Size last. |
|
||||
| **A clear title on the first screen**, in the page's one `h1`, and a heading on every section. | Say what this is: the name with what it does, the product with what it is for, the event with its date and place. |
|
||||
| **Equal outer margins; no section huddled on one side** with the other half of the screen empty. | Close every void with something on its far side: the section's picture, a second column, a list, a figure. |
|
||||
| **No blank stretches** taller than about half a screen. | The normal section gap. A deliberate rest is a placed picture or a field of colour, once per page. |
|
||||
| **Statements are paragraphs.** An intro sentence or a quotation set large is marked up as `<p>` or `<blockquote>`, stays at statement size (36px or less on desktop, 24px on a phone) and runs to about 25 words, three lines. | Larger than that it is a headline and the two-line rule applies. In blind tests jurors read a 42px sentence over four lines as a four-line headline. |
|
||||
| **No eyebrows.** No small label above a heading, no pill badge above the headline, no "New ·" chip. | Put the information in the heading or the first sentence, or drop it. |
|
||||
| **No monospace** outside real code blocks. | The text face. Tabular figures where numbers align. |
|
||||
| **No index numbering.** No 01 / 02 / 03, no (001), no "Step 2". | Order shown by position, size, or the words. A real `<ol>` if order matters. |
|
||||
| **No thin rules.** No hairline dividers, no 1px outlined boxes, no grid lines, crosshairs or crop marks. | Space. A step in background tone. A change of scale. A filled shape. |
|
||||
| **No gradients.** Not as backgrounds, not on buttons, not in text. | Flat colour, or a real photograph. A neutral scrim under text on a photo is the one exception. |
|
||||
| **No shadows on things at rest.** No glows. | A tone step between surfaces. Shadows only on what truly floats: a menu, a dialog. |
|
||||
| **No assistant palette.** No yellow-cream ground with terracotta or rust; no violet or indigo. | The dealt palette. |
|
||||
| **No tiny uppercase letter-spaced labels.** | Sentence case at a readable size. |
|
||||
| **No icon-topped feature cards, no rows of identical cards.** | The dealt section blueprint. |
|
||||
| **No glass.** No backdrop blur panels, no floating pill navigation. | A plain bar: wordmark, a few links, one action. |
|
||||
| **No emoji, no gradient blobs, no decorative shapes, no fake dashboards of grey bars.** | A real picture, a real interface fragment, or nothing. |
|
||||
| **No styled accent word** in a headline (italic, serif, coloured, underlined). | One face, one colour. The words carry the emphasis. |
|
||||
| **Two typefaces at most,** usually one. | The dealt type system. |
|
||||
1. **No caption or description under any image, render or visual.** Licence credits go in one
|
||||
footer line.
|
||||
2. **Every visual renders, never an empty ground,** at 1440, 1024, 768 and 390.
|
||||
3. **One section per screen:** the hero alone on the first screen, every section exactly one or
|
||||
two screens tall at 1440x900, no second section heading peeking in. The one exception is a
|
||||
live `photo-chapters` section, three or four screens, set by the moment.
|
||||
4. **Equal left and right content edges.**
|
||||
5. **Hover micro-animations:** a linked card lifts 2px, a photo zooms 1.02-1.03 inside its frame,
|
||||
buttons step on hover and press on click, underlines draw, card loops cycle in 2.5-4 s, repeat
|
||||
and restart on hover. The motion layer does it; never strip or override it. No tilt, carousels
|
||||
or timed tabs. Every hero enters once on load (`data-enter`), and every page carries one dealt
|
||||
moment, two on a long page (the gate holds both).
|
||||
6. **More and better real photographs where they fit;** nicer backgrounds; better heroes, layouts
|
||||
and fonts.
|
||||
7. **Real full-colour icons** (a known product's real icon, never a drawn one); small diagram
|
||||
visuals in cards (a hub with branches; one input, a main processor, three parallel lanes;
|
||||
pulses running along the lines).
|
||||
8. **The big award-site scenes and creative moments are part of the skill:** every page deals its
|
||||
moments (photo- and type-led off tech), but shader effects and scenes only where the trade has a
|
||||
technical subject (tech, AI, developer tools, gaming, fintech, hardware), never on a local cafe,
|
||||
bakery, guesthouse or shop: those get photographs.
|
||||
9. **Addresses:** street and district only (no street given: the district), no house number, postcode or coordinates; no register
|
||||
or VAT numbers; no phone in a real format; contacts on `.example`.
|
||||
|
||||
Three places keep their own conventions and are left alone by the inspector: real code in
|
||||
`<pre>`/`<code>` (monospace is correct there), real tables and form fields (they may have lines), and
|
||||
a real interface fragment whose root carries the attribute `data-ui` (the product's own borders and
|
||||
menus; see `references/imagery.md` §7).
|
||||
## 1. The kind of business decides everything
|
||||
|
||||
Why these, honestly. Some are what the study measured: among the 247 sites, those the panel scored
|
||||
7.5 or higher used icon feature cards 1% of the time against 28% for sites under 6.5, pill badges 14%
|
||||
against 34%, gradients 22% against 52%, shadows 24% against 48%, tiny tracked caps 16% against 57%.
|
||||
Others are the owner's taste and go against current gallery fashion: monospace labels, index numbers
|
||||
and hairline grids are actually *more* common on award sites than on weak ones, which is exactly why
|
||||
generators imitate them and why they now read as costume. They are banned here without exception.
|
||||
`references/tells.md` keeps the dated registry.
|
||||
Write the kind on the plan's first line and declare it on the page: the page type on
|
||||
`<html data-type>` and `<meta name="page-type">` (the same value), and `<meta name="site-kind">`;
|
||||
a place adds `<meta name="page-kind" content="place">`. The dealers take the last column
|
||||
(`start.mjs --page`, `moments.mjs --page`), the hero dealer the page type; `consistency.mjs` reads
|
||||
the metas.
|
||||
|
||||
Removing ornament leaves space. Never fill it with another ornament. Fill it with scale (make the main
|
||||
thing larger), with the full content, with a picture made for this subject, or with a field of flat
|
||||
colour. Leave it empty on purpose once per page at most.
|
||||
| Brief | page type | site-kind | kind |
|
||||
|---|---|---|---|
|
||||
| App, SaaS, AI, developer tool, fintech or banking app, a studio selling its own software | software | software | software |
|
||||
| A game, a studio launching its game | software | gaming | gaming |
|
||||
| Hardware or a tech device (a drone, a robot arm, a keyboard) | product | tech | tech |
|
||||
| Any other physical product or brand (a chair, a bike, coffee beans sold online) | product | other | product |
|
||||
| People are hired: clinic, law firm, agency, creative studio, trade | service | other | service |
|
||||
| People come to it: cafe, bar, bakery, restaurant, guesthouse, small hotel, shop, florist, bike shop, gym, local studio | place | other | place |
|
||||
|
||||
## Laws of the craft
|
||||
A maker that also opens its door or teaches (a ceramics studio, a bakery with a web shop) is a
|
||||
**place**; its web shop becomes the goods section. It is a product only when nothing happens at the door.
|
||||
|
||||
Short form. Numbers are from the study unless marked as house style.
|
||||
"Tech" below means site-kind software, gaming or tech; "other" is everything else. Effects,
|
||||
scenes, loops, counts, CSS grounds, dark pages and dark heroes are tech only. An other page is
|
||||
photographs, flat colour, type, real icons, the hero's entrance, its dealt moment (photo- or
|
||||
type-led, `moments.md`) and the hover layer.
|
||||
|
||||
**Type**
|
||||
- Two or three sizes do the visible work: one very large, one small, nothing in between. Median
|
||||
headline-to-body ratio on the studied sites is 4.3; the best go far beyond (largest type: median
|
||||
8.4vw on the top sites against 5.0vw on the weak ones).
|
||||
- Display type: tracking -0.02 to -0.05em, leading 0.85–1.05. Text: 16–20px, leading 1.45–1.6,
|
||||
45–75 characters per line.
|
||||
- Five sizes on a page is the ceiling. One family is normal (a third of the studied sites); two is the
|
||||
limit.
|
||||
- Left-aligned by default. Centre at most one moment.
|
||||
## 2. The page plan by kind
|
||||
|
||||
**Space**
|
||||
- One grid, one pair of tight margins (16–32px), used everywhere; large things run to the edges. Text
|
||||
blocks share two or three left edges on the whole page.
|
||||
- Asymmetry inside the grid, never in the frame: the two outer margins are equal, and an off-centre
|
||||
heading is answered by something at the far side of the grid.
|
||||
- The gap between sections is clearly larger than any gap inside one: 120–240px on desktop (the study's median is about 240).
|
||||
- Each section has a different composition from its neighbours; none is a card grid.
|
||||
The full plans, deal lists and section order: `references/sections.md`.
|
||||
|
||||
**Colour**
|
||||
- One ground, one ink, one accent. The accent appears on the action and perhaps one other thing.
|
||||
- Running text in the full ink. A page set in soft grey reads unfinished.
|
||||
- Surfaces are separated by a small step in tone, never by a line or a shadow.
|
||||
- A section may change the whole field colour. Nothing fades into anything.
|
||||
- **Place** (a cafe, bakery, guesthouse, shop). Photographs lead: 12 to 20 on the full page, 8 to
|
||||
12 compact, under 8 frames the 3-screen short page, whatever the trade; no client photographs:
|
||||
the draft path and a live search (`photos.md`), then count; the palette dealt by trade. The hero
|
||||
a photograph whenever a frame fits (a remote place: its region's landscape), the visit block on
|
||||
screen two, the offer, the place, orders or booking.
|
||||
**Variety without effects** (`sections.md` section 4): the offer and the place deal different
|
||||
compositions, never two sections round one picture; one type moment (the address or name set large); one main action a screen; list text 16px. Flat grounds, square
|
||||
photographs, one button style, the phone bar, the dealt moments; no effect, ground, loop,
|
||||
fragment, diagram or tab.
|
||||
- **Product and service** (site-kind other). Photographs lead: the product, the people, the rooms,
|
||||
the work, two or more a screen on a product page. Real icons for the tools a studio uses; one
|
||||
still diagram at most; flat grounds and one brand band. No shader effect, print, grain, gradient,
|
||||
scene or loop (the dealt moments only); WebGL only for a product render the brief asks for. **A
|
||||
physical product with no photographs is shown in photographs:** stand-ins of the same kind of
|
||||
thing from `photos.mjs search`, each listed in the report, on the full product page; a render
|
||||
(`assets/objects/product-render`, stills) only when the brief asks for 3D or a render; never a
|
||||
drawing, mood stock or a set of props (`sections.md`, Product).
|
||||
- **Every product page** (photographed, in stand-ins or rendered on a 3D ask, hardware too; the bar is Figure and 1X;
|
||||
`sections.md`, Product): the product **big** where it stands
|
||||
whole (most of its frame, or on the ground at anchor scale), never mid-size in a grey rounded
|
||||
panel on a grey ground; **a different picture each section**, never one view twice; **every headline promise pictured** (a family bike shows the child bench); dealt
|
||||
compositions, no structure twice, one contrasting ground; the **swatch changes the picture**,
|
||||
every colour shown once; **no fact twice**; buttons step and press (rule 5).
|
||||
- **Tech** (software, gaming, hardware). The client's own subject leads at true size. Software: its
|
||||
own UI as live HTML showing what it works on (a code-review tool: a red and green diff with its
|
||||
Accept action) in one `app-frame`, never a generic dashboard or only fragments on panels; cards:
|
||||
fragments on lit panels, pulsing diagrams, real icons. Hardware: its photographs, else stand-in
|
||||
photographs of its kind (a render only when the brief asks for 3D), never a split hero of only
|
||||
an order form or a drawn SVG. A game: its name in the h1, a launch line, its world as full-bleed
|
||||
key art (else its name set large over stand-in photographs of its setting), an effect never in
|
||||
its place (`sections.md`, Gaming). **60 to 80 words a screen** outside UI mocks (`copy.md`). By
|
||||
deal: **one effect** (a shader ground in the hero or the one `[data-visual]` frame, a WebGL
|
||||
part, an object or one big scene) and **one or two moments** in their own sections; four live
|
||||
WebGL contexts at most; no card moves with the scroll (`effects-usage.md`).
|
||||
- **One object never carries the page.** The product or hero object whole leads the hero and **two more sections at
|
||||
most**; another kind of visual the brief supports carries the rest (`sections.md` section 1,
|
||||
its table): a game's world and crew; hardware's use at the bench, its parts close up; a still
|
||||
diagram card, a type moment, a context photograph where the trade allows.
|
||||
- **The client's own assets get slots.** A trailer, key art, screenshots, CAD renders or a motion
|
||||
video the brief names each get a frame that reads finished now (a real `<video>` with a stand-in
|
||||
poster, a play control only when there is a page or file to play, three to five stand-in
|
||||
photographs of the world, a CAD frame), named in a comment for the client's file, never empty
|
||||
(`sections-library.md`, client-slots).
|
||||
|
||||
**Imagery**
|
||||
- One dominant image beats six small ones. On the top-scored sites the hero media covers at least half
|
||||
the first screen 68% of the time.
|
||||
- Same crop ratios, same treatment, square corners unless the direction says otherwise (73% of the
|
||||
studied sites leave media square).
|
||||
- No image is better than a placeholder, an icon, or an abstract shape. A picture drawn for this
|
||||
subject, in its colours and with care, is better than either. A real picture of its world, found
|
||||
and credited, is better still.
|
||||
- Every found picture is downloaded into the project, credited on the page and listed in the report.
|
||||
None is presented as the client's own.
|
||||
- Nothing ever lies on top of a headline or a statement (the inspector fails it).
|
||||
## 3. What serves which page
|
||||
|
||||
**Components**
|
||||
- Buttons: flat, one fill colour, either square-cornered or fully round, decided once (top sites: 52%
|
||||
square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
|
||||
button at all, or one.
|
||||
- Navigation: wordmark, three to five plain links, one action. No bar chrome.
|
||||
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
|
||||
Open the reference of every asset family the page uses first. A dash means never.
|
||||
|
||||
## Special cases
|
||||
| Need | Asset | Software, gaming | Hardware | Product, service | Place | Reference |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Heights, edges, heroes, type | `system.css`, `assets/layout` | dealt hero | dealt hero | dealt hero | place hero | `layout-v4.md` |
|
||||
| Sections and recipes | `starter.html` | its plan | product plan | its plan | place plan | `sections.md`, `sections-library.md` |
|
||||
| Tokens, grid, buttons, nav, footer, colour | `system.css`, `system.js` | yes | yes | yes | place tokens | `system.md` |
|
||||
| Forms, keyboard, head and share tags | `starter.html`, `system.css`, `system.js` | yes | yes | yes | yes | `system.md` |
|
||||
| Copy, contacts, credits | `scripts/copy-lint.mjs` | yes | yes | yes | yes | `copy.md`, `copy-voice.md` |
|
||||
| Hero entrance, hover, press, focus, nav, tabs | `assets/motion` | yes | yes | yes | yes | `motion.md` |
|
||||
| Card loops, a count | `assets/motion`, `assets/ui` | yes | yes | - | - | `motion.md`, `cards.md` |
|
||||
| Photographs | `scripts/photos.mjs` | people, customers; a game's art, else stand-ins of its setting | lead | lead | lead, 12-20 | `photos.md` |
|
||||
| Real tool, app and file icons | `scripts/icons.mjs` | yes | yes | tools really used | - | `icons.md` |
|
||||
| Feature cards, UI fragments | `assets/ui` | lead (gaming: none) | its app | an app the client runs | - | `cards.md` |
|
||||
| Diagram cards (hub, lanes, pulses) | `assets/diagrams` | 0-2, pulsing | 0-2, pulsing | 0-1, still | - | `diagrams.md` |
|
||||
| CSS grounds | `assets/backgrounds` | 0-2 dealt | 0-2 dealt | flat tokens | flat tokens | `backgrounds.md` |
|
||||
| Shader grounds | `assets/effects` | the one effect | the one effect | - | - | `effects-usage.md` |
|
||||
| Creative parts (`fx-*`) | `assets/components` | yes | yes | `fx-photo` plain, `fx-map` still | `fx-map` still | `effects-creative.md` |
|
||||
| Objects and prints | `assets/objects` | one, as the effect; a render when the brief asks for 3D | its render when the brief asks for 3D | its render when the brief asks for 3D | - | `effects-objects.md` |
|
||||
| Award-site moments | `assets/moments` | 1-2 | 1-2 | 1-2 (photo, type) | 1-2 (photo, type) | `moments.md` |
|
||||
| Big scenes | `assets/scenes` | 0-1 | 0-1 | - | - | `scenes.md` |
|
||||
| Speed and weight | `scripts/perf.mjs` | yes | yes | yes | yes | `perf.md` |
|
||||
|
||||
**An existing brand or design system.** Pass `--keep palette,type`, map the brand's colours and faces
|
||||
onto the roles, and take only composition from the draw. The house rules still apply; if the brand
|
||||
itself requires a banned device (its logo sits in a gradient, its accent is violet), keep the brand
|
||||
asset, say so in the report, and pass `--allow <rule>` to the inspector for that rule only.
|
||||
Read `copy.md`, `copy-voice.md`, `system.md`, `layout-v4.md` and `sections.md` in full before the first page.
|
||||
|
||||
**Redesign 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.
|
||||
## 4. The run
|
||||
|
||||
**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.
|
||||
0. **The stack.** An empty folder builds a static page: copy `<skill>/assets` into `assets` and
|
||||
start from `assets/starter.html`. An existing project builds in its own stack, its components
|
||||
taking the starter's markup and classes, with the kit copied into its public folder
|
||||
(`public/assets`) and loaded from there as plain files. A brief that asks for Next.js builds a
|
||||
static export (`output: 'export'`), checked on its built page (`--root out`).
|
||||
|
||||
**No checkout, no form backend.** The primary button goes to the real destination when the brief names
|
||||
one. Otherwise it opens an email with a written subject and body (`mailto:` with the visitor's choices
|
||||
filled in), or, for a shop, it points at `#checkout` and the report says it must be wired before
|
||||
launch. Never fake a payment form.
|
||||
1. **Read the brief.** Write down the kind (section 1), what is sold, who buys it, the one main
|
||||
action (the transaction: Book a room, Order a cake, Start a free trial; a place with neither:
|
||||
Get directions) and the real material: logo, screens, photographs, prices, address, hours.
|
||||
Then arm the finish gate, before any page exists:
|
||||
|
||||
**No Chrome or Node 22.** Run `lint.mjs` (Node 18+), take screenshots with whatever browser tool you
|
||||
have, and critique those. Say in the report that the inspector could not run.
|
||||
```bash
|
||||
node <skill>/scripts/gate.mjs --start index.html --kind <kind> --category "<what is sold>" --primary "<main action>"
|
||||
```
|
||||
|
||||
**No network.** If `assets.mjs` reaches no source, draw the pictures and say in the report that the
|
||||
open collections could not be searched.
|
||||
A framework build names its dev server's URL (`--start http://localhost:5173/`) or its built page
|
||||
(`--start dist/index.html --root dist`; a Next.js export: `out`) in place of `index.html`, and
|
||||
rebuilds before every gate run.
|
||||
|
||||
## Files
|
||||
2. **Write all the copy first,** with the copy layer (`copy-voice.md`: the fact sheet, the voice
|
||||
card on the plan, the page's shape, the device budget; a register from `copy-registers.md`, the
|
||||
moves of `copy-moves.md`) within the rules of `copy.md`: the h1 names the category and the buyer or the place (a game: its name and genre),
|
||||
one hard fact on the first screen, a noun in every heading, buttons as a verb and an object.
|
||||
Nothing is invented but a coined brand name (searched first, the result kept in `names.json`
|
||||
beside the page) and, on a place, plausible hours and prices, each marked `data-assumed`
|
||||
(`copy.md`, What may be assumed). A draft kept in a `.md` file lints the same way as the page
|
||||
(the voice card and part labels are skipped).
|
||||
|
||||
| File | Read it when |
|
||||
| --- | --- |
|
||||
| `references/app-screens.md` | Any dashboard, admin, settings or other app screen: its own run, rules, tokens and checks. |
|
||||
| `references/copy.md` | Writing the argument and the copy deck (steps 2 and 5). The two-line arithmetic. |
|
||||
| `references/type.md` | Setting type: scales, tracking, measure, loading fonts, pairing. |
|
||||
| `references/colour.md` | Applying the palette: roles, proportions, tone steps, dark pages, contrast. |
|
||||
| `references/layout.md` | Grid, margins, rhythm, section variety, navigation, footer, the phone composition. |
|
||||
| `references/imagery.md` | Choosing, finding, crediting, cropping and treating pictures; what to do with none. |
|
||||
| `references/motion.md` | Step 9. Budget, tokens, recipes, reduced motion. |
|
||||
| `references/scenes.md` | Step 6, when the sheet deals scroll scenes: putting one on a page, filling, fitting, the phone, checking, the catalogue. |
|
||||
| `references/critique.md` | Step 8. How to look, how to score, what to fix first. |
|
||||
| `references/tells.md` | The dated registry of generated-page tells and what each is replaced by. |
|
||||
| `references/research.md` | The study: method, numbers, what the best and worst sites do. Cite it, do not guess. |
|
||||
| `scripts/draw.mjs` | Deals and reprints the direction; `--photos`, `--site <id>` for more references; `--app` deals an app style instead. |
|
||||
| `scripts/assets.mjs` | Step 6: searches open collections for real pictures of the subject's world; `--get` downloads with the credit, `--credits` prints them. |
|
||||
| `scripts/lint.mjs` · `scripts/inspect.mjs` | The gate. |
|
||||
| `scripts/scenes.mjs` | The gate for scroll scenes: each scene at 0-100% of its hold, desktop, phone, reduced motion. |
|
||||
| `assets/base.css` · `assets/base.js` | An optional neutral foundation to build on. |
|
||||
| `assets/scenes/` | The scroll scenes: the shared stage (`stage.js`, `stage.css`) and, for each scene in the library, its `.js`, `.css` and a working demo page in `demo/`. |
|
||||
| `assets/app.css` · `assets/charts.js` | For app screens: a neutral shell, panels, tables and controls, and small SVG charts drawn to the app rules. |
|
||||
| `library/` | The data the draw reads: directions, heroes, sections, type systems, palettes, photographs, studied sites, motions, scroll scenes (`scenes.json`), and the app styles, shells and screen patterns (`app.json`). Do not read it whole; use the script. |
|
||||
3. **Plan the page as screens.** Line 1: the kind. Line 2: the deal (step 4), with one sentence on
|
||||
why the palette fits the trade. Then one line per
|
||||
section: its id, its one idea, its picture and its kind (object, use, parts, world, slot, diagram, type; three object lines at most), its
|
||||
screens (1 or 2, from its content height, `layout-v4.md` section 2), which section holds a
|
||||
moment and why it moves (on tech also the effect or the scene). Pick the photographs now (`photos.md`); their count,
|
||||
stand-ins included, sets a place's length (section 2). Then read `cards.md` and the recipe of
|
||||
every id you use in `sections-library.md`.
|
||||
|
||||
4. **Deal, never choose by taste.** Each command keeps a history, so pages differ:
|
||||
|
||||
```bash
|
||||
node <skill>/assets/layout/deal.mjs --type <page type> --has <photo,ui,film,object,setting> --history .taste-skill/layout-history.json # the hero; a game adds --kind gaming, hardware --kind tech
|
||||
node <skill>/scripts/start.mjs deal --page <kind> --trade <trade> --history .taste-skill/history.json # three palettes made for the trade, three pairings: take the first; add --axis after a word or editorial hero
|
||||
node <skill>/scripts/start.mjs css --page <kind> --palette <id> --type <id> --radius soft --history .taste-skill/history.json
|
||||
node <skill>/scripts/start.mjs brand --page place --bg "#.." --ink "#.." --accent "#.." [--surface "#.."] [--muted "#.."] --type <id> --radius soft # the client's own colours (a logo, a painted shopfront), in place of css
|
||||
node <skill>/scripts/moments.mjs pick --page <kind> --count 1|2 --fits section,statement --photo --history .taste-skill/history.json # every kind
|
||||
node -e "for(const l of process.argv.slice(1)){const o=l.split('|');console.log(o[Math.floor(Math.random()*o.length)])}" <the kind's lists, below>
|
||||
```
|
||||
|
||||
Run every command from the project folder (the deal arms the gate in the folder it runs in, or
|
||||
joins the build already open one or two folders up). The
|
||||
lists, in order nav, footer, close, rhythm, buttons, mark (`sections.md` sections 2 and 3):
|
||||
- software: `"nav-mega|nav-cards|nav-centre" "footer-dark|footer-statement|footer-columns" "close-type|close-word" "dark-band|surface-pairs|space" "solid|pill|tonal|arrow" "wordmark|initial|arc|ring|stack|dots"`
|
||||
- gaming: `"nav-mega|nav-centre|nav-cards" "footer-dark|footer-statement" "close-word|close-type" "dark-band|media-band|space" "solid|pill|block" "wordmark|initial|arc|ring|stack|dots"`
|
||||
- product and hardware: `"nav-cards|nav-split|nav-mega" "footer-columns|footer-dark" "close-photo|close-type|close-word" "dark-band|brand-band" "arrow|solid|block|pill" "wordmark|initial|arc|ring|stack|dots"`
|
||||
- service: `"nav-mega|nav-split|nav-centre" "footer-visit|footer-dark|footer-columns" "close-type|close-photo" "surface-pairs|dark-band|space" "solid|tonal|block|arrow" "wordmark|initial|arc|ring|stack|dots"`
|
||||
- place: the whole line in `sections.md` section 4, Place (The deal), read in its order.
|
||||
|
||||
A product or hardware with no photographs deals with `--has photo` for its stand-ins;
|
||||
`--has object` only for a cut-out or a render the brief asks for (always the `product` hero, its
|
||||
widest view across the wrap, no box); a game without art files passes `--has setting` (the
|
||||
`photo` hero, its name over a stand-in of its setting; a game always passes `--has`). A product
|
||||
page is dealt only the product, photo and film heroes. A place's hero is printed, never dealt (`--type
|
||||
place`). A dealt part that needs a photograph the page lacks is dealt again (`sections.md` section 2). Take the
|
||||
first palette and the first pairing printed (the next only when the first clashes with the
|
||||
client's own logo or photographs, or the pairing fails `hero-lines`, said on plan line 2); the client's own colours go through
|
||||
`start.mjs brand` instead (`sections.md` section 4, Look); never violet: deal again. Keep the `:root` block as printed. Radius: `sharp` for fintech and developer tools, `soft` by default,
|
||||
`round` for consumer apps, square on a place. On tech only: the lit ground (`<html data-ground>`:
|
||||
`sand|sky|sage` through the one-liner), the CSS grounds (`backgrounds.md`) and the one effect
|
||||
(`effects-usage.md`). Write every result on plan line 2 and in
|
||||
the comment at the top of `<body>`, and build exactly that (a short page drops what it has no section for: `sections.md` section 4).
|
||||
|
||||
5. **Build.** In `starter.html`: set `<html lang data-type data-radius data-buttons data-motion>`
|
||||
(lang: the brief's language), the three metas, the head block (title, description, og, icon,
|
||||
theme-color: `system.md`, Head) and the nav class; keep the skip link and the `m-enter` head
|
||||
line; copy only the `<section class="hero …" data-enter>` of
|
||||
`assets/layout/hero-<id>.html` (never its nav, other sections, footer or credit line; every word
|
||||
and picture becomes the client's); move the dealt close and footer out of their `<template>`s
|
||||
and delete the rest. Every section is `main > section` with
|
||||
one `.wrap`, its h2 first, `--h: 1` or `2`. Build each to its recipe with the system's classes
|
||||
and tokens; write page CSS only where a reference says page CSS (inside a visual, `--p-h`,
|
||||
`.wall`, `.rail`, `.device`). Paste UI from `assets/ui/<name>/fragment.html` and rewrite every
|
||||
name, number and logo to the client's (kit icons become `data-icon`, `cards.md`); a diagram card is `data-diagram="<preset>"`
|
||||
with the client's labels; run `icons.mjs scan index.html --out assets/icons --colour` last, so every icon
|
||||
the page names is a local file. Mount an effect, object, moment or scene exactly as its
|
||||
reference says, never from your own script. A moment's section is copied from
|
||||
`assets/moments/_demo/in-page.html` (place: photo-chapters, word-highlight) or
|
||||
`in-page-service.html` (pinned-chapters, picture-develops film); a moment's own `demo.html` shows
|
||||
it alone and is not a page model. A form is the starter's `booking-form` (`system.md`,
|
||||
Forms). Credits: the one footer line from `photos.mjs credits`, once the markup uses the
|
||||
photographs (it reads the page). Copy the hero's tile from `shoot.mjs` beside the page as `share.jpg`.
|
||||
|
||||
6. **Check and look.** Only the skill's scripts, each running one headless Chrome with WebGL on the
|
||||
CPU, closed by its own PID, one at a time in the foreground with a 10-minute timeout; never in
|
||||
the background, a windowed browser or a browser tool.
|
||||
Each serves the page file itself over http on 127.0.0.1 (never `file://`), so no server is needed;
|
||||
for a built page whose links start at the site root (`/_astro/page.css`), pass `--root` with the build folder (`dist`).
|
||||
`--profile <dir>` is the Chrome profile: a folder of its own in the OS temp folder or in the
|
||||
project's `.taste-skill` folder (`.taste-skill/chrome-check`), never outside the project (no
|
||||
`../`); a relative one that leads out of it is replaced by `.taste-skill/chrome-<script>`. Then:
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/copy-lint.mjs index.html --kind <kind>
|
||||
node <skill>/scripts/consistency.mjs index.html --widths 1440,1024,820,768,390 --rhythm-widths 1440,820,768,390 --profile <dir> --page <kind> --category "<what is sold>" --primary "<main action>"
|
||||
node <skill>/assets/layout/check.mjs --url index.html --profile <dir> --sizes 1440x900,1024x768,820x1180,768x1024,390x844 --shots shots/layout
|
||||
node <skill>/scripts/shoot.mjs --url index.html --w 1440 --out shots/1440.png --profile <dir>
|
||||
node <skill>/scripts/perf.mjs index.html --profile <dir>
|
||||
```
|
||||
|
||||
Shoot 1024, 820x1180, 768x1024 and 390 as well. Every `check.mjs` finding fails the page (no `--no-stress`).
|
||||
copy-lint opens no browser and takes under a second. Fix every FAIL (a copy-lint WARN too, or say
|
||||
why it stays) and read the copy aloud (`copy-voice.md` section 6); shoot again and read every
|
||||
screenshot against the checklist. In the fix rounds run `consistency.mjs --quick --widths
|
||||
1440,390`. Then the gate, which runs the checker at 1440 and 390 every time, walks the page at 320
|
||||
for overflow, collide and tap-size, and runs copy-lint, whose FAILs block like the checker's:
|
||||
|
||||
```bash
|
||||
node <skill>/scripts/gate.mjs index.html
|
||||
```
|
||||
|
||||
Fix what it lists and run it again until its last line is `GATE: PASS` or `GATE: OPEN`, three
|
||||
rounds at most (in Claude Code it also runs when you finish; a stop before your first gate run is
|
||||
no round, and to ask the user something after a block, just stop again). When the user tells you
|
||||
to stop, or to ship the page as it is, run `gate.mjs --done "<what they said>"` (`GATE: DONE`)
|
||||
and report the open FAILs.
|
||||
`GATE: SETUP` (no page yet, a framework's source shell, a page that is not local): arm what a
|
||||
browser can open (step 1) and run it again. `GATE: ERROR` (the checks could not run) or
|
||||
`GATE: OFF`: put the line in the report and finish; do not rerun. Never edit
|
||||
the kit's files or hide anything from the checker. A FAIL you judge false is waived in
|
||||
`.taste-skill/waivers.json` with its reason, never fixed by gaming the check (`references/gate.md`);
|
||||
a word from the client's own copy that copy-lint FAILs is waived hit by hit, the brief as the reason
|
||||
(a word inside the client's name, as the logo or the title writes it, is not counted: one WARN).
|
||||
|
||||
7. **Report:** the `GATE:` line first, then every waiver with its reason and every open FAIL; the
|
||||
kind, the deal (palette and why it fits, pairing, hero, grounds, effect, moments, scene); the
|
||||
voice card in one line and every copy-lint WARN kept, with why; the checker, layout and perf
|
||||
results; what you could not check; every assumed fact (the checker's `assumed` list) and the
|
||||
coined name's search; the form's `.example` stand-in and the canonical domain still to come; the photographs and art the client must supply, every
|
||||
stand-in frame among them (library and live search); every client slot, its file and its stand-in.
|
||||
|
||||
## 5. Checklist
|
||||
|
||||
Each line names its checks: `consistency.mjs` ids in brackets, `check.mjs` findings in braces,
|
||||
`perf.mjs` rows. The last line is for your eyes.
|
||||
|
||||
- **First screen:** says what is sold, for whom or where, with one hard fact [copy-headline,
|
||||
copy-fact]; the hero fills it alone at every width, its main button inside it [hero-screen,
|
||||
hero-fill, fold-action, fold-cut] {hero-fold}.
|
||||
- **One section per screen:** every section 1 or 2 screens, its h2 first and top-aligned, no
|
||||
neighbour's heading or picture in its tile, nothing sparse, no section half empty [section-height, one-section,
|
||||
one-heading, section-air, one-screen, section-gaps, empty-area] {screen-short, screen-height, sparse,
|
||||
neighbour, neighbour-ground}.
|
||||
- **Equal edges:** every block on the `.wrap` lines or the grid's columns, nothing out to one side,
|
||||
no sideways scroll [container-edges, row-lines, split-pair, off-grid, right-edge, col-edge,
|
||||
spill, off-viewport, stagger, top-stagger] {edge-left, edge-right, spill, bleed-one-side,
|
||||
one-sided, lines}.
|
||||
- **Every visual draws** at 1440, 1024, 768 and 390; a refused effect shows its still fallback
|
||||
[visual-empty, empty-visual, gl-fallback, shader-lint, no-visual, photo-weight].
|
||||
- **No caption** explains, apologises for or labels a picture; credits in one footer line
|
||||
[visual-caption, caption, credit-wall, photo-credit]; no placeholder, sample note or printed
|
||||
`.example` address [placeholder, sample-label].
|
||||
- **The right things for the kind:** outside tech no effect ground, print or scene, only the
|
||||
dealt photo- or type-led moments (WebGL only for a product render the brief asked for)
|
||||
[effect-kind, effects-fit, photo-print]; the subject itself
|
||||
shows: the product big, whole in three sections at most, a new picture each [product-shown,
|
||||
same-object, photo-repeat]; each asset the brief names in its slot; a game's name and its world as key art,
|
||||
the diff of a code-review tool, software's `app-frame`;
|
||||
real photographs where they fit, none twice [photo-set, photo-repeat, photo-upscale]; real icons in their own
|
||||
colours; flat grounds on other [grounds]; a place deals two different compositions and one type
|
||||
moment.
|
||||
- **Motion:** every button, link, linked card and linked photograph visibly answers the pointer,
|
||||
nothing tilts [motion-layer, hover, hover-move, tilt]; the hero enters once on load
|
||||
[hero-enter]; one or two dealt moments [moment-none]; card loops cycle in 2.5-4 s [loop-cycle]; nothing moves because the page scrolled
|
||||
except a dealt moment inside its own section (every kind) or the big scene (tech)
|
||||
[scroll-linked, scroll-reveal, pinned: expected only there]; anything
|
||||
moving past 5 s has a pause button [loop-pause]; under reduced motion everything rests and nothing
|
||||
stays hidden [reduced-motion, hidden-content]; at most four live contexts [webgl].
|
||||
- **Contacts** as rule 9 [street-number, postcode, coordinates, company-number, phone-number];
|
||||
Get directions only with a street, "See the area" with a district, no map link with a town only [map-link].
|
||||
- **Keyboard and head:** every control named, alt on pictures, labels you can see, headings one
|
||||
level at a time, no tabindex above 0; Tab reaches the skip link first, its ring shows at once and
|
||||
never under the header; the menu button says its state; lang, title, description and share tags
|
||||
[a11y-name, img-alt, field-label, heading-order, tabindex, skip-link, focus-ring, focus-visible,
|
||||
focus-hidden, menu-toggle, lang, page-meta, share-meta].
|
||||
- **One system:** one button pair, one card style, one radius scale, one link look [button-styles,
|
||||
button-shape, card-family, card-layout, link-styles]; two boxes deep at most [nesting]; one
|
||||
heading scale, its faces loaded [heading-scale, font-load]; text contrast [text-contrast,
|
||||
contrast].
|
||||
- **Copy and links:** headings name things, buttons a verb and an object, at most two hero actions
|
||||
[copy-headings, copy-buttons, copy-actions, action-count]; one action twice a screen at most
|
||||
[action-repeat]; a heading's grey half is its own sentence [run-on]; one fact, one wording
|
||||
[fact-agree]; assumed facts marked, never an open-now line from them [assumed, open-now]; every
|
||||
link lands, forms send, a confirmation only after a real send, each error under its own field
|
||||
[links, forms, form-send, form-fields]; no slop word, stock line, leftover or device used as a
|
||||
tic, a voice of its own (copy-lint's ids: copy-residue, copy-slop-word, copy-stock-phrase,
|
||||
copy-two-fragment, copy-virtue, ...; `copy-voice.md`).
|
||||
- **Fast:** no FAIL row in `perf.mjs` (image weight, contexts, layout shift).
|
||||
- **820, 768, 390 and 320:** sections on their screens [section-height]; on a phone a section is at
|
||||
least its screens and grows with what it holds, so nothing lies on anything else or under a fixed
|
||||
bar [collide]; every link and control outside running text 44px tall [tap-size] (a FAIL when
|
||||
another target is under 8px away); nothing past the screen at 320 [off-viewport], and nothing
|
||||
lies on anything else there either [collide]; the first screen ending whole or on a clear bleed [fold-card]
|
||||
(`layout-v4.md`); splits stack text first, no row half empty; one column at 390, buttons full
|
||||
width, a full-screen menu; the place action bar.
|
||||
- **Your eyes:** beside the figma.com and x.ai frames it reads as a professional company site;
|
||||
nothing tilted, staggered, cut or overlapping; the hero is the work, not a mood.
|
||||
|
||||
## 6. Never
|
||||
|
||||
- A card, row or tile that fades in on scroll; a fade-in on every section; parallax on text;
|
||||
scroll-jacking; random blinking.
|
||||
- Stock photographs passed off as the client's product, people or place (a stand-in is listed in
|
||||
the report as one, never shown with another maker's name or logo); a product page that never
|
||||
shows the product.
|
||||
- A drawn look-alike of a real logo; invented reviews, quotes, customers or figures.
|
||||
- Violet or lavender in the page's own design: accent, ground, gradient, effect colour or tint (a
|
||||
real third-party logo keeps its own colours, `icons.md`).
|
||||
- Copying a reference site's code, copy, pictures or signature effect.
|
||||
- Deleting or editing the kit's own files (`assets/`, `scripts/`) to clear a check: a check that
|
||||
flags a kit file is reported, and the page is checked again after any change.
|
||||
|
||||
## References
|
||||
|
||||
All in `references/`; section 3 names the one each asset needs.
|
||||
@@ -1,216 +0,0 @@
|
||||
/* tasteskill / app.css
|
||||
A neutral foundation for app screens (dashboards, admin, settings, inboxes), set to the defaults
|
||||
measured in the study of real product screens (references/app-screens.md). Copy it into the project,
|
||||
replace the token values with the ones on the app sheet (draw.mjs --app), and build on top.
|
||||
Delete what you do not use. It has no brand of its own: neutral grounds, one quiet line, one accent. */
|
||||
|
||||
/* ---------- tokens (replace with the dealt style) ---------- */
|
||||
:root {
|
||||
/* names match the app sheet printed by draw.mjs --app */
|
||||
--ground: #f9fafb; /* the app chrome and page */
|
||||
--surface: #ffffff; /* panels and the work sheet */
|
||||
--raised: #f4f5f6; /* a filled inner well, hover rows */
|
||||
--active: #eeeeee; /* the active nav pill */
|
||||
--border: #e8e8e8; /* panel edges: about 1.2:1 on white */
|
||||
--rule: #f1f2f3; /* row rules inside tables */
|
||||
--control: #d1d5db; /* inputs and outlined buttons: one step stronger */
|
||||
--text: #111111;
|
||||
--muted: #5f6368; /* secondary text, labels (4.5:1 or more on its ground) */
|
||||
--faint: #6b6b6b; /* meta and placeholders: the lightest grey that holds 4.5:1 on white */
|
||||
--accent: #1f6f5c; /* one hue: data, focus, the selected thing */
|
||||
--primary: #1d1f20; /* the primary button is near-black unless the style says otherwise */
|
||||
--primary-ink: #ffffff;
|
||||
--positive: #247b50; --negative: #c4242a; --warning: #92400e; /* references/app-screens.md §9.5; a green accent is also --positive */
|
||||
--pill-positive-bg: #dcfae5; --pill-positive-ink: #04250e;
|
||||
--pill-negative-bg: #ffedea; --pill-negative-ink: #831c1e;
|
||||
--pill-warning-bg: #fffbeb; --pill-warning-ink: #92400e;
|
||||
--pill-neutral-bg: #eeeeee; --pill-neutral-ink: #3d3d3d;
|
||||
--chart-1: var(--accent); --chart-2: #8fb8ad; --chart-3: #b08a2e; --chart-muted: #cfd3d6;
|
||||
--chart-mark: var(--text); /* a marked period in Charts.diverging: the second tone */
|
||||
--chart-span: 0.06; /* opacity of a shaded span (a selling window, an outage) */
|
||||
--grid: var(--rule); /* chart gridlines (charts.js) */
|
||||
--axis-ink: var(--muted); /* chart tick labels (charts.js) */
|
||||
--ink: var(--text);
|
||||
|
||||
--font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fs-title: 24px; --fs-section: 16px; --fs-kpi: 28px; --fs-body: 14px; --fs-small: 12px;
|
||||
|
||||
--sidebar-width: 232px;
|
||||
--pad: 24px; /* canvas padding */
|
||||
--gap: 16px; /* one gutter, horizontal and vertical */
|
||||
--card-pad: 20px;
|
||||
--radius-card: 8px; /* radius is bimodal: tight 4–8 (controls 6) or soft 16–24 (controls full); never 11–12 */
|
||||
--radius-control: 6px;
|
||||
--control-height: 32px; /* every control in the title and filter rows */
|
||||
--row-height: 48px; /* single-line table row */
|
||||
--nav-row: 32px; /* sidebar item height, on a 34px pitch */
|
||||
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--t-press: 120ms; --t-hover: 150ms; --t-ui: 200ms;
|
||||
}
|
||||
/* dark: one true-neutral ground, elevation by lightness; two greys that read apart (not #aaa beside #969696) */
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #000000; --surface: #111111; --raised: #222222; --active: #222222;
|
||||
--border: #1a1a1a; --rule: #161616; --control: #444444;
|
||||
--text: #ffffff; --muted: #b4b4b4; --faint: #949494;
|
||||
--primary: #ffffff; --primary-ink: #000000;
|
||||
--positive: #3fc07a; --negative: #e5566f; --warning: #e5a93b; /* a green or yellow-green accent is also --positive */
|
||||
--pill-positive-bg: #172a1d; --pill-positive-ink: #3fc07a;
|
||||
--pill-negative-bg: #2c1a20; --pill-negative-ink: #e5566f;
|
||||
--pill-warning-bg: #2b2414; --pill-warning-ink: #e5a93b;
|
||||
--pill-neutral-bg: #222222; --pill-neutral-ink: #ffffff;
|
||||
--chart-muted: #4a4a4a; --grid: #2b2b2b;
|
||||
--chart-span: 0.12;
|
||||
}
|
||||
|
||||
/* ---------- reset ---------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--ground); color: var(--text); font: var(--fs-body)/20px var(--font); -webkit-font-smoothing: antialiased; }
|
||||
h1, h2, h3, h4, p, figure, ul, ol, dl, dd { margin: 0; }
|
||||
ul[class], ol[class] { padding: 0; list-style: none; }
|
||||
img, svg, video, canvas { display: block; max-width: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.num, table, .kpi-value { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- shell: sidebar + canvas ---------- */
|
||||
.app { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100svh; }
|
||||
.sidebar { position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; gap: 2px; padding: 16px 12px; overflow: auto; }
|
||||
.sidebar .group-label { margin: 20px 8px 6px; font-size: var(--fs-small); color: var(--faint); }
|
||||
.nav-item { display: flex; align-items: center; gap: 10px; height: var(--nav-row); padding: 0 8px; border-radius: 8px; color: var(--muted); transition: background-color var(--t-hover) ease, color var(--t-hover) ease; }
|
||||
.nav-item svg { width: 16px; height: 16px; flex: none; stroke-width: 1.5; }
|
||||
.nav-item:hover { background: var(--raised); color: var(--text); }
|
||||
.nav-item[aria-current="page"] { background: var(--active); color: var(--text); }
|
||||
.sidebar .bottom { margin-top: auto; }
|
||||
.canvas { min-width: 0; padding: var(--pad); }
|
||||
|
||||
/* ---------- title block and filters ---------- */
|
||||
.title-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.title-row h1 { font-size: var(--fs-title); line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.title-row .sub { margin-top: 4px; color: var(--muted); }
|
||||
.filters { display: flex; align-items: center; gap: 8px; margin-bottom: var(--gap); flex-wrap: wrap; }
|
||||
.filters .spacer { flex: 1; }
|
||||
.stamp { color: var(--faint); font-size: var(--fs-small); }
|
||||
|
||||
/* ---------- panels and grid ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); padding: var(--card-pad); min-width: 0; }
|
||||
.panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.panel h2 { font-size: var(--fs-section); line-height: 1.3; font-weight: 600; }
|
||||
.panel .meta { color: var(--faint); font-size: var(--fs-small); }
|
||||
/* paired cards in one row: a card stretches to its neighbour only when it has content to fill. Otherwise it keeps
|
||||
its own height (add .top to the row), and only an action or a total may be pinned to a stretched card's bottom */
|
||||
.grid.top { align-items: start; }
|
||||
|
||||
/* a chart's heading row: its title (and legend or period) with a fixed 8px to the svg */
|
||||
.chart-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
|
||||
.chart-head h3 { font-size: var(--fs-body); line-height: 20px; font-weight: 500; }
|
||||
.chart { position: relative; min-width: 0; }
|
||||
|
||||
/* a link at a card's foot or header ("Show all 7", "Sell-out history"): ink, a 12px chevron, an underline on hover,
|
||||
and no inline padding, so its text sits on the card's content edge */
|
||||
.card-link { display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--text); font-weight: 500; cursor: pointer; text-underline-offset: 3px; }
|
||||
.card-link::after { content: ""; width: 12px; height: 12px; flex: none; background: currentColor; -webkit-mask: var(--chevron) center / 12px no-repeat; mask: var(--chevron) center / 12px no-repeat; }
|
||||
.card-link:hover { text-decoration: underline; }
|
||||
:root { --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M4.5 2.5 8 6l-3.5 3.5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
|
||||
/* list rows: a rule between rows and none above the first. A rule on a rounded box curls up at both ends, so a row
|
||||
that carries a rule has square corners */
|
||||
.rows { display: grid; }
|
||||
.row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 0; }
|
||||
.row + .row { border-top: 1px solid var(--rule); border-radius: 0; }
|
||||
|
||||
/* KPIs as one divided strip: a label above a number, no icons, no tiles */
|
||||
.kpis { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-card); }
|
||||
.kpi { padding: 16px var(--card-pad); }
|
||||
.kpi + .kpi { border-left: 1px solid var(--border); }
|
||||
.kpi-label { color: var(--muted); }
|
||||
.kpi-value { margin-top: 6px; font-size: var(--fs-kpi); line-height: 1.15; font-weight: 500; letter-spacing: -0.01em; }
|
||||
.kpi-value small { font-size: 0.55em; color: var(--muted); font-weight: 400; margin-left: 2px; }
|
||||
.delta { margin-top: 4px; font-size: var(--fs-small); color: var(--muted); }
|
||||
.delta .up { color: var(--positive); } .delta .down { color: var(--negative); }
|
||||
/* KPIs as the chart's tabs: the selected one carries a 2px rule and an ink label; the others keep a grey label and
|
||||
their value in ink at a lighter weight (a grey value reads as disabled). When the tabs are the per-entity
|
||||
breakdown the brief asks for (takings by shop), add .entities: nothing changes but the rule */
|
||||
.kpis[role="tablist"] .kpi { position: relative; cursor: pointer; }
|
||||
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
|
||||
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="true"] .kpi-label { color: var(--text); }
|
||||
.kpis[role="tablist"]:not(.entities) .kpi[aria-selected="false"] .kpi-value { color: var(--text); font-weight: 400; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { height: 36px; padding: 0 12px; text-align: left; font-weight: 400; font-size: var(--fs-small); color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
/* cells share one baseline, so a figure never sits 2px below the name beside it; the row height comes from padding */
|
||||
td { padding: calc((var(--row-height) - 20px) / 2) 12px; border-bottom: 1px solid var(--rule); vertical-align: baseline; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
/* no hover that changes the fill; a row that drives a detail pane takes the nav pill's fill when selected */
|
||||
tbody tr[aria-selected="true"] td { background: var(--active); }
|
||||
th.n, td.n { text-align: right; }
|
||||
td .sub { display: block; font-size: var(--fs-small); color: var(--muted); }
|
||||
.truncate { max-width: 28ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* status pill: a pale tint with dark ink of the same hue, one per row */
|
||||
.pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--fs-small); font-weight: 500; background: var(--pill-neutral-bg); color: var(--pill-neutral-ink); white-space: nowrap; }
|
||||
.pill.pos { background: var(--pill-positive-bg); color: var(--pill-positive-ink); }
|
||||
.pill.neg { background: var(--pill-negative-bg); color: var(--pill-negative-ink); }
|
||||
.pill.warn { background: var(--pill-warning-bg); color: var(--pill-warning-ink); }
|
||||
|
||||
/* ---------- controls ---------- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: var(--control-height); padding: 0 12px; border-radius: var(--radius-control); border: 1px solid var(--control); background: var(--surface); font-weight: 500; cursor: pointer; transition: background-color var(--t-hover) ease, transform var(--t-press) var(--ease-out); }
|
||||
.btn:hover { background: var(--raised); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--primary); color: var(--primary-ink); border-color: transparent; }
|
||||
.btn.ghost { border-color: transparent; background: transparent; }
|
||||
.field { display: grid; gap: 8px; }
|
||||
.field label { font-weight: 500; }
|
||||
.field .hint { color: var(--muted); font-size: var(--fs-small); }
|
||||
.input, select.input { height: 38px; padding: 0 12px; border: 1px solid var(--control); border-radius: var(--radius-control); background: var(--surface); }
|
||||
.input::placeholder { color: var(--faint); }
|
||||
.tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); margin-bottom: var(--gap); }
|
||||
.tabs a { position: relative; padding: 10px 0; color: var(--muted); transition: color var(--t-hover) ease; }
|
||||
.tabs a[aria-selected="true"] { color: var(--text); }
|
||||
.tabs a[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
|
||||
/* as wide as its options; full width only on a narrow phone */
|
||||
.segmented { display: inline-flex; width: max-content; max-width: 100%; padding: 2px; border-radius: 8px; background: var(--raised); }
|
||||
.segmented button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
|
||||
.switch { appearance: none; width: 32px; height: 20px; border-radius: 999px; background: var(--control); position: relative; cursor: pointer; transition: background-color var(--t-ui) ease; }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform var(--t-ui) var(--ease-out); }
|
||||
.switch:checked { background: var(--text); } .switch:checked::after { transform: translateX(12px); }
|
||||
|
||||
/* empty and loading states keep the frame; a loading value is a flat grey bar at its real size, with no shimmer */
|
||||
.empty { display: grid; place-items: center; min-height: 96px; padding: 16px; border: 1px dashed var(--control); border-radius: var(--radius-control); color: var(--muted); text-align: center; }
|
||||
.skeleton { background: var(--raised); border-radius: 4px; }
|
||||
|
||||
/* ---------- phone: the navigation folds away and the canvas takes the width ---------- */
|
||||
.menu-toggle { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); }
|
||||
/* the menu opens as a full-screen sheet */
|
||||
.sidebar { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 20; background: var(--surface); transform: translateX(-100%); transition: transform var(--t-ui) var(--ease-out); }
|
||||
.sidebar.open { transform: none; }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
.canvas { padding: 16px; }
|
||||
.grid > * { grid-column: 1 / -1 !important; }
|
||||
.kpis { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.kpi + .kpi { border-left: 0; }
|
||||
.kpi:nth-child(n + 3) { border-top: 1px solid var(--border); }
|
||||
.kpi:nth-child(2n) { border-left: 1px solid var(--border); }
|
||||
/* an odd count leaves no half-empty row: the last cell takes the full width */
|
||||
.kpi:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||
/* KPI tabs in a 2-up grid: the selected cell takes a fill, never an underline floating mid-grid */
|
||||
.kpis[role="tablist"] .kpi[aria-selected="true"] { background: var(--active); }
|
||||
.kpis[role="tablist"] .kpi[aria-selected="true"]::after { content: none; }
|
||||
.title-row { flex-wrap: wrap; align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.segmented { display: flex; width: 100%; }
|
||||
.segmented button { flex: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation: none !important; } }
|
||||
|
||||
/* an empty message spans the block it stands in, never one squeezed column */
|
||||
.rows .empty, .grid > .empty, li > .empty { grid-column: 1 / -1; width: 100%; }
|
||||
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
taste-skill backgrounds.css - 16 section and page grounds, drawn in CSS (no WebGL, no images except the
|
||||
photograph ground's own). Link after system.css and assets/ui/cards.css. Rules: references/backgrounds.md.
|
||||
|
||||
A ground is one class on a <section> (or on <body> / a wrapper for the two page grounds, or on a panel for
|
||||
.bg-stage):
|
||||
<section class="section bg-halo"> ... </section>
|
||||
Every ground sets the page tokens for its own children (--bg --surface --ink --muted --accent --card
|
||||
--action --line, --fx-*), so the system's cards, buttons and fragments work on it unchanged. It draws on
|
||||
the page palette (--p-*) and the lit ground (--ground-*, system.css). Body text keeps 7:1 and muted text
|
||||
4.5:1 on its text zone (index.json "textZone"; check.mjs measures it on every palette). The one
|
||||
exception is .bg-stage, a panel for a visual: words on it sit on a card, never on the stage itself.
|
||||
|
||||
Knobs, all optional, on :root or on one section:
|
||||
--g-hue the colour the tints lean to (default: the accent, hue-guarded: never violet or lavender)
|
||||
--g-hue-b its neighbour, 30 degrees round the wheel (default derived, and guarded the same way)
|
||||
--g-k, --g-kl, --g-kd strength of the tints, of the lines, of the dark grounds: 1 at most; backgrounds.js
|
||||
sets them from the palette's contrast headroom (see there)
|
||||
--tone the second brand tone of .bg-band / .bg-field (default: the lit ground, system.css)
|
||||
--tone-ink its text colour (default: the lit ground's ink)
|
||||
--g-photo url(...) for .bg-photo
|
||||
<html data-mode="light|dark"> says whether the page ink is dark or light; <html data-tint="away"> marks a
|
||||
palette whose bg is a strong colour or a mid tone (tints then move away from the ink); <html
|
||||
data-lowchroma> marks a grey accent (the stage then takes the lit ground's colour). backgrounds.js sets
|
||||
them and --g-k / --g-kl / --g-kd from the palette, unless the page already has (load it plain in <head>).
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------- tokens */
|
||||
:root {
|
||||
/* the hue the tints lean to: the accent, with an oklch hue in 262-338 (violet, lavender, purple) moved
|
||||
to azure (262-300) or pink (300-338), so a cobalt or plum accent never tints a ground violet */
|
||||
--g-hue-src: var(--p-accent);
|
||||
/* the tint's lightness is held between these, so a pale lime on a dark page or a navy on a white one
|
||||
tints as deeply as a mid blue would */
|
||||
--g-L-lo: 0.62; --g-L-hi: 1;
|
||||
--g-hue: oklch(from var(--g-hue-src) clamp(var(--g-L-lo), l, var(--g-L-hi)) c
|
||||
calc(h + clamp(0, (h - 262) * 1000, 1) * clamp(0, (338 - h) * 1000, 1)
|
||||
* (250 + clamp(0, (h - 300) * 1000, 1) * 95 - h)));
|
||||
/* the neighbour: 30 degrees toward the cooler side (toward the warmer side for a red or a pink, hues under 40
|
||||
or over 330, so it never turns rose-violet). Any colour's own hue goes through this:
|
||||
oklch(from X l c calc(h + var(--g-hs))) */
|
||||
--g-hs: calc(30 - 60 * clamp(0, (h - 40) * 1000, 1) * clamp(0, (330 - h) * 1000, 1));
|
||||
--g-hue-b: oklch(from var(--g-hue) l c calc(h + var(--g-hs)));
|
||||
/* the hue as a pale pool, its lightness fixed: for the second-tone grounds, whose ink is dark whatever the page is */
|
||||
--g-pool: oklch(from var(--g-hue) 0.8 calc(c * 0.6) h);
|
||||
/* a second, different tone for two-colour fields: the lit ground (sand, sky, sage, painting) */
|
||||
--g-hue-2: var(--ground-bg, #d9ccb7);
|
||||
/* ...as a tint: its lightness held to the same band as --g-hue, so the contrast headroom (--g-k) counts for it too.
|
||||
The soft grounds take it as their SECOND hue (not the accent's 30-degree neighbour), so a blue page is not blue
|
||||
throughout: a blue wash with a sand light in the corner, as on Figma's colour fields */
|
||||
--g-hue-t: oklch(from var(--g-hue-2) clamp(var(--g-L-lo), l, var(--g-L-hi)) c h);
|
||||
/* the accent's own hue with the same guard: the stage uses it on a strong-colour page (data-tint="away"),
|
||||
where --g-hue is the page's own colour and a stage in it would be a muddy tan on the same colour */
|
||||
--g-acc: oklch(from var(--p-accent) 0.7 c calc(h + clamp(0, (h - 262) * 1000, 1) * clamp(0, (338 - h) * 1000, 1)
|
||||
* (250 + clamp(0, (h - 300) * 1000, 1) * 95 - h)));
|
||||
/* black when the page ink is light, white when it is dark: the way a shade moves away from the ink */
|
||||
--g-away: oklab(from var(--p-ink) calc(1 - round(l)) 0 0);
|
||||
--g-k: 1; --g-kl: 1; --g-kd: 1; --g-kp: 1;
|
||||
|
||||
/* the dark side: the page's inverse on a light page, its own bg on a dark one (data-mode) */
|
||||
--g-dark-bg: var(--p-inv-bg); --g-dark-surface: var(--p-inv-surface);
|
||||
--g-dark-ink: var(--p-inv-ink); --g-dark-muted: var(--p-inv-muted);
|
||||
--g-dark-accent: var(--p-inv-accent); --g-dark-accent-ink: var(--p-inv-accent-ink);
|
||||
--g-dark-action: var(--p-inv-action); --g-dark-action-ink: var(--p-inv-action-ink);
|
||||
--g-light-bg: var(--p-bg); --g-light-ink: var(--p-ink); --g-light-muted: var(--p-muted);
|
||||
--g-light-action: var(--p-action); --g-light-action-ink: var(--p-action-ink);
|
||||
--g-light-accent: var(--p-accent); --g-light-accent-ink: var(--p-accent-ink);
|
||||
--g-card: var(--p-bg); /* a card on a tinted light ground is the page's own white */
|
||||
--g-grain: var(--g-noise-mul); --g-grain-mode: multiply; /* grain on a light page darkens, on a dark page lightens */
|
||||
|
||||
/* a cover over the grain, so it thins out with --g-k (--g-kd on the dark grounds) */
|
||||
--g-cover: color-mix(in oklab, var(--bg) calc(100% - 100% * var(--g-k)), transparent);
|
||||
--g-cover-d: color-mix(in oklab, var(--bg) calc(100% - 100% * var(--g-kd)), transparent);
|
||||
--g-fade: 120px; /* the soft grounds fade into the page over this much at top and bottom */
|
||||
--g-edge: 160px; /* .bg-dusk: how long the tone change takes */
|
||||
|
||||
/* grain: SVG fractal noise as a data URI, 240px tile, two octaves at 1.2 so it is fine, like film, never
|
||||
like sand. soft is mean grey (neutral under soft-light, for the mid-tone grounds), mul sits near white
|
||||
(multiply, for light pages), scr near black (screen, for dark) */
|
||||
--g-noise-soft: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='0.6' intercept='.2'/%3E%3CfeFuncG type='linear' slope='0.6' intercept='.2'/%3E%3CfeFuncB type='linear' slope='0.6' intercept='.2'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
--g-noise-mul: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='.26' intercept='.84'/%3E%3CfeFuncG type='linear' slope='.26' intercept='.84'/%3E%3CfeFuncB type='linear' slope='.26' intercept='.84'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
--g-noise-scr: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='.17' intercept='-.05'/%3E%3CfeFuncG type='linear' slope='.17' intercept='-.05'/%3E%3CfeFuncB type='linear' slope='.17' intercept='-.05'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
html[data-mode="dark"] {
|
||||
--g-L-lo: 0; --g-L-hi: 0.55;
|
||||
--g-dark-bg: var(--p-bg); --g-dark-surface: var(--p-surface);
|
||||
--g-dark-ink: var(--p-ink); --g-dark-muted: var(--p-muted);
|
||||
--g-dark-accent: var(--p-accent); --g-dark-accent-ink: var(--p-accent-ink);
|
||||
--g-dark-action: var(--p-action); --g-dark-action-ink: var(--p-action-ink);
|
||||
--g-light-bg: var(--p-inv-bg); --g-light-ink: var(--p-inv-ink); --g-light-muted: var(--p-inv-muted);
|
||||
--g-light-action: var(--p-inv-action); --g-light-action-ink: var(--p-inv-action-ink);
|
||||
--g-light-accent: var(--p-inv-accent); --g-light-accent-ink: var(--p-inv-accent-ink);
|
||||
--g-card: var(--p-surface); /* a card on a dark ground is one step lighter than it */
|
||||
--g-grain: var(--g-noise-scr); --g-grain-mode: screen;
|
||||
}
|
||||
/* a palette whose bg is a strong colour (cobalt, mustard, pink) or a mid tone (stone, slate): the accent is
|
||||
another hue, and mixing the two turns the ground to mud, so the tints are the bg's own colour pushed
|
||||
away from the ink: a deeper cobalt on white text, a paler mustard on black text */
|
||||
html[data-tint="away"][data-mode="light"] { --g-grain: var(--g-noise-scr); --g-grain-mode: screen; }
|
||||
html[data-tint="away"][data-mode="dark"] { --g-grain: var(--g-noise-mul); --g-grain-mode: multiply; }
|
||||
html[data-tint="away"] { --g-hue-src: color-mix(in oklab, var(--p-bg) 58%, var(--g-away)); --g-hue-2: color-mix(in oklab, color-mix(in oklab, var(--p-bg) 66%, var(--g-away)) 90%, var(--p-accent)); }
|
||||
|
||||
/* ---------------------------------------------------------------- the contract of every ground */
|
||||
.bg-mist, .bg-halo, .bg-grain, .bg-dusk, .bg-ruled, .bg-dots, .bg-arcs,
|
||||
.bg-band, .bg-field, .bg-stage, .bg-photo, .bg-horizon, .bg-deep, .bg-liquid {
|
||||
position: relative; isolation: isolate; overflow: clip;
|
||||
--bg: var(--p-bg); --surface: var(--p-surface); --ink: var(--p-ink);
|
||||
/* muted leans to the ink (60/40), so a tint under it never costs the 4.5:1 (backgrounds.js counts on 60/40) */
|
||||
--muted: color-mix(in oklab, var(--p-ink) 60%, var(--p-muted));
|
||||
--accent: var(--p-accent); --accent-ink: var(--p-accent-ink);
|
||||
--action: var(--p-action); --action-ink: var(--p-action-ink);
|
||||
--card: var(--g-card);
|
||||
--line: color-mix(in oklab, var(--ink) 11%, transparent);
|
||||
--line-strong: color-mix(in oklab, var(--ink) 20%, transparent);
|
||||
--fx-bg: var(--bg); --fx-surface: var(--surface); --fx-ink: var(--ink); --fx-muted: var(--muted);
|
||||
--fx-accent: var(--accent); --fx-accent-ink: var(--accent-ink);
|
||||
--ui-font: var(--font-text); --ui-mono: var(--font-mono); --ui-radius: var(--r-md);
|
||||
background-color: var(--bg); color: var(--ink);
|
||||
}
|
||||
/* the two page grounds (.bg-paper on <body>, .bg-topglow on a wrapper) set NO tokens: the page keeps its own --card,
|
||||
--muted, --line and --accent (a ground that restyled the body would turn every card flat). They paint only
|
||||
background-image and ::before. */
|
||||
.bg-paper, .bg-topglow { position: relative; isolation: isolate; }
|
||||
.bg-paper { background-color: var(--bg); } /* the base the grain blends with */
|
||||
/* the soft grounds are overlays: they paint no colour of their own, so they sit on the page ground (plain or .bg-paper) */
|
||||
:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid) { background-color: transparent; }
|
||||
/* the decorations of the soft grounds sit on ::before, behind the content, and fade into the page at
|
||||
the top and bottom, so a soft ground can neighbour any other */
|
||||
:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs)::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent);
|
||||
}
|
||||
|
||||
/* dark grounds take the dark side's tokens */
|
||||
.bg-horizon, .bg-deep, .bg-photo {
|
||||
--bg: color-mix(in oklab, var(--g-dark-bg) 80%, black);
|
||||
--surface: color-mix(in oklab, var(--g-dark-surface) 80%, black);
|
||||
--ink: var(--g-dark-ink);
|
||||
--muted: color-mix(in oklab, var(--g-dark-ink) 60%, var(--g-dark-muted));
|
||||
--accent: var(--g-dark-accent); --accent-ink: var(--g-dark-accent-ink);
|
||||
--action: var(--g-dark-action); --action-ink: var(--g-dark-action-ink);
|
||||
--card: color-mix(in oklab, var(--bg) 88%, var(--ink));
|
||||
/* the light on a dark ground: the ground's own colour and the hue, then pulled toward the ink */
|
||||
--lit: color-mix(in oklab, color-mix(in oklab, var(--bg) 55%, var(--g-hue)) 68%, var(--ink));
|
||||
}
|
||||
/* the second-tone grounds take the lit ground's tokens (sand, sky, sage, painting), or --tone */
|
||||
.bg-band, .bg-field {
|
||||
/* a little lighter than the lit ground, so its dark ink keeps 7:1 on all four (painting is the darkest) */
|
||||
--bg: var(--tone, color-mix(in oklab, var(--ground-bg) 55%, var(--ground-surface)));
|
||||
--surface: var(--ground-surface);
|
||||
--ink: var(--tone-ink, color-mix(in oklab, var(--ground-ink) 85%, black));
|
||||
--muted: color-mix(in oklab, var(--ink) 60%, var(--ground-muted));
|
||||
--accent: var(--ground-accent); --accent-ink: var(--ground-surface);
|
||||
--action: var(--ink); --action-ink: var(--bg);
|
||||
--card: color-mix(in oklab, var(--ground-surface) 70%, white);
|
||||
/* the tone's own neighbours, so a field is one family of colour and never two that turn to mud: a richer
|
||||
neighbour 30 degrees round the wheel (a peach beside sand, cyan beside sky) and a deeper shade of itself */
|
||||
--t-b: oklch(from var(--bg) l clamp(0, c * 1.6 + 0.022, 0.075) calc(h + var(--g-hs)));
|
||||
--t-c: oklch(from var(--bg) calc(l - 0.032) calc(c * 1.3 + 0.012) h);
|
||||
}
|
||||
|
||||
/* .bg-inset: a hard ground as a rounded panel on the page's grid, its left and right edges on the container's
|
||||
(Figma's colour panels, x.ai's video card). Add it next to the ground class: class="section bg-field bg-inset".
|
||||
On a dark page a band or a field is always inset (a pale full-bleed slab is the brightest thing on a dark page and
|
||||
harsh at a phone's width); .bg-bleed opts out. */
|
||||
.bg-inset, html[data-mode="dark"] :is(.bg-band, .bg-field):not(.bg-bleed) { width: min(calc(100% - 2 * var(--pad)), var(--container)); margin-inline: auto; margin-block: var(--s-5); border-radius: var(--r-lg); }
|
||||
|
||||
/* Spacing. A ground that changes the colour is spaced like the system's own bands, and a little more; a soft one a half
|
||||
step more than a plain section; and each holds a screen of its own (min-height, the words centred), so a shot of one
|
||||
section never shows the next. The system's own variants keep what they set: the hero (.section--hero: its top, its
|
||||
screen-high box), .section--flush-top and .section--rule are never touched. --g-screen on a section sets its minimum
|
||||
height (0px = the words' own height). */
|
||||
.section:is(.bg-band, .bg-field, .bg-grain, .bg-horizon, .bg-deep, .bg-photo):not(.section--hero, .section--flush-top, .section--rule) { padding-block: calc(var(--section-band) * 1.25); }
|
||||
.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid):not(.section--hero, .section--flush-top, .section--rule) { padding-block: calc(var(--section) * 1.5); }
|
||||
/* a hero on a ground keeps its system top and its screen height and gets the ground's room under it */
|
||||
.section--hero:is(.bg-band, .bg-field, .bg-grain, .bg-horizon, .bg-deep, .bg-photo) { padding-bottom: calc(var(--section-band) * 1.25); }
|
||||
.section--hero:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid) { padding-bottom: calc(var(--section) * 1.5); }
|
||||
.section:is(.bg-band, .bg-field, .bg-grain, .bg-dusk, .bg-horizon, .bg-deep):not(.section--hero) { min-height: var(--g-screen, min(88svh, 860px)); display: grid; align-content: center; }
|
||||
.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-liquid):not(.section--hero) { min-height: var(--g-screen, min(76svh, 760px)); display: grid; align-content: center; }
|
||||
/* on a phone the stacked words already fill the screen: the sections keep their padding and no minimum height */
|
||||
@media (max-width: 767px) {
|
||||
.section:is(.bg-band, .bg-field, .bg-grain, .bg-dusk, .bg-horizon, .bg-deep, .bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-liquid):not(.section--hero) { min-height: var(--g-screen, 0px); }
|
||||
}
|
||||
/* system.css forces the last section before the footer to one plain bottom padding (!important): a ground that closes the
|
||||
page keeps its own (the horizon's dome needs its 150px; a band stays even top and bottom) */
|
||||
main > section.section:is(.bg-band, .bg-field, .bg-grain, .bg-deep, .bg-photo):not(.section--hero):last-of-type { padding-bottom: calc(var(--section-band) * 1.25) !important; }
|
||||
main > section.section:is(.bg-mist, .bg-halo, .bg-ruled, .bg-dots, .bg-arcs, .bg-liquid):not(.section--hero):last-of-type { padding-bottom: calc(var(--section) * 1.5) !important; }
|
||||
main > section.section.bg-horizon:last-of-type { padding-bottom: calc(var(--section-band) * 1.25 + 150px) !important; }
|
||||
|
||||
/* ---------------------------------------------------------------- 1 mist: a soft gradient field */
|
||||
.bg-mist::before {
|
||||
background:
|
||||
radial-gradient(54% 70% at 4% 6%, color-mix(in oklab, var(--g-hue) calc(42% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(46% 62% at 98% 16%, color-mix(in oklab, var(--g-hue-t) calc(48% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(64% 64% at 66% 116%, color-mix(in oklab, var(--g-hue) calc(24% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(34% 46% at 22% 104%, color-mix(in oklab, var(--g-hue-b) calc(18% * var(--g-k)), transparent), transparent);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 2 halo: a radial light behind the heading */
|
||||
.bg-halo { --halo-x: 50%; --halo-y: 38%; }
|
||||
.bg-halo::before {
|
||||
background:
|
||||
radial-gradient(34% 40% at var(--halo-x) var(--halo-y), color-mix(in oklab, var(--g-hue) calc(30% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(70% 76% at var(--halo-x) var(--halo-y), color-mix(in oklab, var(--g-hue) calc(15% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(30% 34% at calc(var(--halo-x) + 18%) calc(var(--halo-y) + 16%), color-mix(in oklab, var(--g-hue-t) calc(32% * var(--g-k)), transparent), transparent);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 3 grain: a tint with fine grain on it */
|
||||
.bg-grain {
|
||||
--bg: color-mix(in oklab, var(--p-bg) calc(100% - 11% * var(--g-k)), var(--g-hue));
|
||||
--surface: color-mix(in oklab, var(--p-surface) calc(100% - 11% * var(--g-k)), var(--g-hue));
|
||||
background-image:
|
||||
radial-gradient(90% 100% at 0% 0%, color-mix(in oklab, var(--p-bg) 74%, transparent), transparent 70%),
|
||||
radial-gradient(54% 80% at 100% 0%, color-mix(in oklab, var(--g-hue-b) calc(20% * var(--g-k)), transparent), transparent 72%),
|
||||
radial-gradient(80% 90% at 100% 100%, color-mix(in oklab, var(--g-hue) calc(26% * var(--g-k)), transparent), transparent 70%),
|
||||
radial-gradient(46% 70% at 0% 100%, color-mix(in oklab, var(--g-hue-b) calc(12% * var(--g-k)), transparent), transparent 72%),
|
||||
linear-gradient(var(--g-cover), var(--g-cover)),
|
||||
var(--g-grain);
|
||||
background-blend-mode: normal, normal, normal, normal, normal, var(--g-grain-mode);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 4 paper: the page ground, never flat */
|
||||
.bg-paper {
|
||||
overflow: visible;
|
||||
background-image:
|
||||
radial-gradient(120% 60vh at 50% 0, color-mix(in oklab, var(--g-hue) calc(9% * var(--g-kp)), transparent), transparent),
|
||||
radial-gradient(60% 50vh at 100% 0, color-mix(in oklab, var(--g-hue-b) calc(6% * var(--g-kp)), transparent), transparent),
|
||||
linear-gradient(to bottom, transparent 70%, color-mix(in oklab, var(--g-hue) calc(6% * var(--g-kp)), transparent)),
|
||||
/* the page ground carries every word of the page, so its grain is a quarter of the grain ground's */
|
||||
linear-gradient(color-mix(in oklab, var(--bg) calc(100% - 28% * var(--g-kp)), transparent), color-mix(in oklab, var(--bg) calc(100% - 28% * var(--g-kp)), transparent)),
|
||||
var(--g-grain);
|
||||
background-blend-mode: normal, normal, normal, normal, var(--g-grain-mode);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 5 dusk: one tone moves into the next, no edge */
|
||||
.bg-dusk {
|
||||
/* the two ends are transparent, so the tone melts into whatever the page really paints there (plain, .bg-paper's grain, a
|
||||
neighbouring tint) and never meets a page colour it was not. Set --g-from / --g-next to a colour to melt from or into one. */
|
||||
--g-from: transparent; --g-to: color-mix(in oklab, var(--p-bg) calc(100% - 15% * var(--g-k)), var(--g-hue)); --g-next: transparent;
|
||||
--bg: var(--g-to);
|
||||
--surface: color-mix(in oklab, var(--p-surface) calc(100% - 15% * var(--g-k)), var(--g-hue));
|
||||
background-color: transparent;
|
||||
background-repeat: no-repeat;
|
||||
/* the two melting edges and, between them (a pixel of overlap each side), the solid tone */
|
||||
background-size: 100% var(--g-edge), 100% var(--g-edge), 100% calc(100% - 2 * var(--g-edge) + 2px);
|
||||
background-position: top, bottom, center;
|
||||
/* a smoothstep: 0, .04, .16, .32, .5, .68, .84, .96, 1 of the way */
|
||||
background-image:
|
||||
linear-gradient(to bottom,
|
||||
var(--g-from), color-mix(in oklab, var(--g-to) 4%, var(--g-from)) 12.5%, color-mix(in oklab, var(--g-to) 16%, var(--g-from)) 25%,
|
||||
color-mix(in oklab, var(--g-to) 32%, var(--g-from)) 37.5%, color-mix(in oklab, var(--g-to) 50%, var(--g-from)) 50%,
|
||||
color-mix(in oklab, var(--g-to) 68%, var(--g-from)) 62.5%, color-mix(in oklab, var(--g-to) 84%, var(--g-from)) 75%,
|
||||
color-mix(in oklab, var(--g-to) 96%, var(--g-from)) 87.5%, var(--g-to)),
|
||||
linear-gradient(to top,
|
||||
var(--g-next), color-mix(in oklab, var(--g-to) 4%, var(--g-next)) 12.5%, color-mix(in oklab, var(--g-to) 16%, var(--g-next)) 25%,
|
||||
color-mix(in oklab, var(--g-to) 32%, var(--g-next)) 37.5%, color-mix(in oklab, var(--g-to) 50%, var(--g-next)) 50%,
|
||||
color-mix(in oklab, var(--g-to) 68%, var(--g-next)) 62.5%, color-mix(in oklab, var(--g-to) 84%, var(--g-next)) 75%,
|
||||
color-mix(in oklab, var(--g-to) 96%, var(--g-next)) 87.5%, var(--g-to)),
|
||||
linear-gradient(var(--g-to), var(--g-to));
|
||||
}
|
||||
.section.bg-dusk:not(.section--hero) { padding-block: calc(var(--section) + var(--g-edge) * .5); }
|
||||
|
||||
/* ---------------------------------------------------------------- 6 topglow: light under the nav, the page lifts out of it */
|
||||
/* on <body> (with bg-paper) or a wrapper that holds the nav and the hero: a pool of light and a thin lit line just
|
||||
under the nav bar, fading out by 700px down, a second tone leaning in from the right. It draws on ::before, so it
|
||||
stacks with bg-paper on one element. */
|
||||
.bg-topglow { overflow: visible; }
|
||||
.bg-topglow::before {
|
||||
content: ""; position: absolute; z-index: -1; top: 0; left: 0; right: 0; height: min(100%, 720px); pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, color-mix(in oklab, var(--g-hue) calc(60% * var(--g-kp)), transparent) 50%, transparent) 50% var(--nav-h, 64px) / 62% 1px no-repeat,
|
||||
radial-gradient(62% 470px at 50% var(--nav-h, 64px), color-mix(in oklab, var(--g-hue) calc(27% * var(--g-kp)), transparent), transparent),
|
||||
radial-gradient(42% 380px at 86% calc(var(--nav-h, 64px) + 60px), color-mix(in oklab, var(--g-hue-b) calc(18% * var(--g-kp)), transparent), transparent),
|
||||
radial-gradient(120% 300px at 50% var(--nav-h, 64px), color-mix(in oklab, var(--g-hue) calc(12% * var(--g-kp)), transparent), transparent);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 7 ruled: a fine grid that fades out */
|
||||
/* square rules (Vercel's and Linear's heroes). The glow is ::before, full width; the grid is ::after, exactly as wide as the
|
||||
container: its first rule lies on the container's left edge, the cells are spaced to fit, and its right edge is a rule too, so
|
||||
the grid is the page's own frame and never a second system across it. It is clear behind the heading (a hole in the mask)
|
||||
and fades toward the top and bottom. For a section with no row rules of its own: never over a table, pricing or a list
|
||||
(index.json: ruled is dealt only to the sections that list it). */
|
||||
.bg-ruled::before {
|
||||
background: radial-gradient(50% 44% at 50% 0%, color-mix(in oklab, var(--g-hue) calc(20% * var(--g-k)), transparent), transparent);
|
||||
}
|
||||
.bg-ruled::after {
|
||||
content: ""; position: absolute; z-index: -1; top: 0; bottom: 0; pointer-events: none; box-sizing: border-box;
|
||||
--rule: color-mix(in oklab, var(--ink) calc(8% * var(--g-kl)), transparent);
|
||||
--cell: 72px;
|
||||
left: max(var(--pad), calc((100% - var(--container)) / 2)); right: max(var(--pad), calc((100% - var(--container)) / 2));
|
||||
border-right: 1px solid var(--rule);
|
||||
background:
|
||||
linear-gradient(90deg, var(--rule) 1px, transparent 1px) 0 0 / var(--cell) var(--cell) space repeat,
|
||||
linear-gradient(var(--rule) 1px, transparent 1px) 0 0 / var(--cell) var(--cell) repeat;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 120% 90% at 50% 40%, #000 30%, transparent 92%), radial-gradient(ellipse 46% 34% at 50% 38%, transparent 40%, #000 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 120% 90% at 50% 40%, #000 30%, transparent 92%), radial-gradient(ellipse 46% 34% at 50% 38%, transparent 40%, #000 100%);
|
||||
-webkit-mask-composite: source-in; mask-composite: intersect;
|
||||
}
|
||||
@media (max-width: 767px) { .bg-ruled::after { --cell: 48px; } }
|
||||
|
||||
/* ---------------------------------------------------------------- 8 dots: a dot field that fades out */
|
||||
/* the dots live in the margins and the corners and are clear behind the heading and the words (a hole in the middle of the mask,
|
||||
not a spotlight on it), at 24% of the ink, so no line of body copy ever sits on them */
|
||||
.bg-dots::before {
|
||||
background:
|
||||
radial-gradient(34% 46% at 6% 34%, color-mix(in oklab, var(--g-hue) calc(16% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(30% 40% at 96% 70%, color-mix(in oklab, var(--g-hue-t) calc(24% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(circle at 11px 11px, color-mix(in oklab, var(--ink) calc(26% * var(--g-kl)), transparent) 1.15px, transparent 1.9px) 0 0 / 22px 22px;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 58% 70% at 50% 40%, transparent 24%, #000 78%);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 58% 70% at 50% 40%, transparent 24%, #000 78%);
|
||||
-webkit-mask-composite: source-in; mask-composite: intersect;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 9 arcs: rings that rise from below, x.ai's footer */
|
||||
/* the rings need room under the words: a minimum height (the words centred), not more padding, so the gaps stay the page's */
|
||||
.section.bg-arcs { min-height: clamp(420px, 36vw, 540px); display: grid; align-content: center; }
|
||||
.bg-arcs::before {
|
||||
background:
|
||||
radial-gradient(56% 56% at 50% 100%, color-mix(in oklab, var(--g-hue) calc(22% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(30% 40% at 78% 100%, color-mix(in oklab, var(--g-hue-t) calc(24% * var(--g-k)), transparent), transparent),
|
||||
repeating-radial-gradient(circle at 50% 140%, transparent 0 79px, color-mix(in oklab, var(--ink) calc(22% * var(--g-kl)), transparent) 79px 80px);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 84% 94% at 50% 100%, #000 30%, transparent 82%);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 var(--g-fade), #000 calc(100% - var(--g-fade)), transparent), radial-gradient(ellipse 84% 94% at 50% 100%, #000 30%, transparent 82%);
|
||||
-webkit-mask-composite: source-in; mask-composite: intersect;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 10 band: a coloured band in a second tone */
|
||||
/* the lit ground's tone with a light corner, a richer neighbour of the same colour leaning in from the right and a
|
||||
deeper shade of itself in the far corner, under fine grain: one family of colour, never flat */
|
||||
.bg-band {
|
||||
background-image:
|
||||
var(--g-noise-soft),
|
||||
radial-gradient(66% 120% at 0% 0%, color-mix(in oklab, var(--bg) 42%, var(--g-hi, white)), transparent 72%),
|
||||
radial-gradient(52% 96% at 100% 0%, color-mix(in oklab, var(--t-b) 62%, transparent), transparent 76%),
|
||||
radial-gradient(64% 104% at 100% 100%, color-mix(in oklab, var(--t-c) 70%, transparent), transparent 78%);
|
||||
background-blend-mode: soft-light, normal, normal, normal;
|
||||
}
|
||||
|
||||
/* .bg-tint, with .bg-band or .bg-field: the second tone is a pale tint of the accent's own colour (a mint for a teal accent,
|
||||
an azure for a blue one, Figma's flat mint panel) instead of the lit ground, so the band is coloured on a sand page too */
|
||||
.bg-tint {
|
||||
--tone: oklch(from var(--g-hue) 0.93 clamp(0.035, c * 0.32, 0.075) h);
|
||||
--tone-ink: color-mix(in oklab, var(--g-light-ink) 58%, black);
|
||||
--accent: var(--g-light-accent); --accent-ink: var(--g-light-accent-ink);
|
||||
--surface: color-mix(in oklab, var(--tone) 60%, white);
|
||||
--card: color-mix(in oklab, var(--g-light-bg) 80%, white);
|
||||
}
|
||||
/* on a dark page a pale tinted slab is the brightest thing there: the tint is a deep tone of the accent's colour with light words */
|
||||
html[data-mode="dark"] .bg-tint {
|
||||
--tone: oklch(from var(--g-hue) 0.24 clamp(0.04, c * 0.42, 0.09) h);
|
||||
--tone-ink: color-mix(in oklab, var(--p-ink) 92%, white);
|
||||
--muted: color-mix(in oklab, var(--tone-ink) 82%, var(--tone));
|
||||
--accent: var(--p-accent); --accent-ink: var(--p-accent-ink);
|
||||
--surface: color-mix(in oklab, var(--tone) 80%, white);
|
||||
--card: color-mix(in oklab, var(--tone) 86%, black);
|
||||
--action: var(--tone-ink); --action-ink: var(--tone);
|
||||
--g-hi: color-mix(in oklab, var(--tone) 88%, white); /* the light corner of the band and the field stays deep, under light words */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 11 field: a grainy gradient in the lit tone, Figma's panel */
|
||||
.bg-field {
|
||||
background-image:
|
||||
var(--g-noise-soft),
|
||||
linear-gradient(112deg, transparent 38%, color-mix(in oklab, var(--g-hi, white) 26%, transparent) 50%, transparent 62%),
|
||||
radial-gradient(58% 86% at 8% 0%, color-mix(in oklab, var(--g-hi, white) 58%, transparent), transparent),
|
||||
radial-gradient(54% 80% at 82% 6%, color-mix(in oklab, var(--t-b) 80%, transparent), transparent),
|
||||
radial-gradient(60% 96% at 100% 100%, color-mix(in oklab, var(--t-c) 76%, transparent), transparent),
|
||||
radial-gradient(40% 60% at 0% 100%, color-mix(in oklab, var(--t-b) 50%, transparent), transparent);
|
||||
background-blend-mode: soft-light, normal, normal, normal, normal, normal;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 12 stage: a vivid panel for a visual, Figma's tiles */
|
||||
/* a rounded panel (not a section) behind one card or window: the accent's colour as a mesh with a light corner and a
|
||||
deep one, and grain. It has NO text of its own: every word sits on a card on it (that is what lets it be vivid).
|
||||
<div class="bg-stage"><div class="mini">...</div></div>
|
||||
A grey accent (data-lowchroma) takes the lit ground's colour. --stage-pad is the room round the card. */
|
||||
.bg-stage {
|
||||
--st-src: var(--g-hue);
|
||||
/* yellows go olive at mid lightness, so around hue 90 the mid and deep tones are lifted (+.12, +.1) */
|
||||
--st-1: oklch(from var(--st-src) calc(0.64 + 0.12 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)) clamp(0.07, c * 0.8, 0.14) calc(h - 8 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)));
|
||||
--st-2: oklch(from var(--st-src) 0.8 clamp(0.05, c * 0.6, 0.1) calc(h + var(--g-hs)));
|
||||
--st-3: oklch(from var(--st-src) 0.95 0.035 h);
|
||||
/* a yellow darkened turns olive: its deep tone leans to amber instead (hue -24 at yellow) */
|
||||
--st-4: oklch(from var(--st-src) calc(0.44 + 0.1 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)) clamp(0.05, c * 0.7, 0.12) calc(h - 24 * clamp(0, 1 - max(h - 90, 90 - h) / 45, 1)));
|
||||
--stage-pad: clamp(20px, 4.5vw, 64px);
|
||||
display: grid; place-items: center; padding: var(--stage-pad); border-radius: var(--r-lg);
|
||||
background-color: var(--st-1);
|
||||
background-image:
|
||||
var(--g-noise-soft),
|
||||
radial-gradient(58% 78% at 10% 4%, color-mix(in oklab, var(--st-3) 90%, transparent), transparent 72%),
|
||||
radial-gradient(48% 70% at 90% 12%, color-mix(in oklab, var(--st-2) 92%, transparent), transparent 74%),
|
||||
radial-gradient(70% 90% at 78% 112%, color-mix(in oklab, var(--st-4) 94%, transparent), transparent 74%),
|
||||
radial-gradient(46% 62% at 4% 100%, color-mix(in oklab, var(--st-2) 60%, transparent), transparent 72%);
|
||||
background-blend-mode: soft-light, normal, normal, normal, normal;
|
||||
}
|
||||
html[data-tint="away"] .bg-stage { --st-src: var(--g-acc); }
|
||||
html[data-lowchroma] .bg-stage { --st-src: oklch(from var(--ground-accent) l c h); }
|
||||
.bg-stage > * { position: relative; width: min(100%, var(--stage-w, 520px)); }
|
||||
|
||||
/* ---------------------------------------------------------------- 13 photo: a full-bleed photograph under a calm scrim */
|
||||
/* The photograph is an <img class="bg-photo__img" src alt> child (alt text, lazy loading, found early by the
|
||||
browser), or --g-photo: url(...) when the section cannot hold one. The scrim is the dark side's tone and is at
|
||||
least .76 deep from the bottom edge up to --scrim-h (the top of the words; backgrounds.js measures it from the
|
||||
section's own text and sets it, 66% by default, 86% on a phone), so white words keep 7:1 even on a white
|
||||
photograph (a scrim of .76 over white is darker than #595959). A pale photograph takes .bg-photo--light: the scrim is
|
||||
the light side's tone and the words are dark. The scrim is the section's ::before, above the photograph.
|
||||
--g-photo-pos: where the subject sits (object-position, e.g. "70% 30%"); --g-photo-pos-m the same on a phone, where the
|
||||
words take the lower part and the subject belongs in the upper one. */
|
||||
.bg-photo {
|
||||
--scrim: color-mix(in oklab, var(--g-dark-bg) 70%, black);
|
||||
/* the scrim deepens as the palette's dark side loses headroom (--g-kd): .88 / .80 / .76 at 1, solid at 0 */
|
||||
--scrim-a: color-mix(in oklab, var(--scrim) calc(100% - 12% * var(--g-kd)), transparent);
|
||||
--scrim-b: color-mix(in oklab, var(--scrim) calc(100% - 20% * var(--g-kd)), transparent);
|
||||
--scrim-c: color-mix(in oklab, var(--scrim) calc(100% - 24% * var(--g-kd)), transparent);
|
||||
--scrim-h: 66%; --scrim-w: 56%;
|
||||
min-height: clamp(520px, 52vw, 760px); display: grid; align-content: end;
|
||||
background: var(--g-photo, none) var(--g-photo-pos, center) / cover no-repeat, var(--scrim);
|
||||
}
|
||||
.bg-photo__img { position: absolute; inset: 0; z-index: -2; width: 100%; height: 100%; object-fit: cover; object-position: var(--g-photo-pos, 50% 50%); }
|
||||
.bg-photo::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
|
||||
background: linear-gradient(to top, var(--scrim-a), var(--scrim-b) calc(var(--scrim-h) * .45), var(--scrim-c) var(--scrim-h), transparent calc(var(--scrim-h) + 26%));
|
||||
}
|
||||
.bg-photo--left { align-content: center; }
|
||||
.bg-photo--left::before { background: linear-gradient(to right, var(--scrim-a), var(--scrim-b) calc(var(--scrim-w) * .64), var(--scrim-c) var(--scrim-w), transparent calc(var(--scrim-w) + 26%)); }
|
||||
.bg-photo--light {
|
||||
--scrim: var(--g-light-bg);
|
||||
--scrim-a: color-mix(in oklab, var(--scrim) 94%, transparent);
|
||||
--scrim-b: color-mix(in oklab, var(--scrim) 88%, transparent);
|
||||
--scrim-c: color-mix(in oklab, var(--scrim) 76%, transparent);
|
||||
--bg: color-mix(in oklab, var(--g-light-bg) 90%, white); --surface: var(--p-surface); --ink: var(--g-light-ink);
|
||||
--muted: color-mix(in oklab, var(--g-light-ink) 60%, var(--g-light-muted));
|
||||
--accent: var(--g-light-accent); --accent-ink: var(--g-light-accent-ink);
|
||||
--action: var(--g-light-action); --action-ink: var(--g-light-action-ink);
|
||||
--card: var(--g-light-bg);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bg-photo { --scrim-h: 86%; min-height: 640px; background-position: var(--g-photo-pos-m, var(--g-photo-pos, center)); }
|
||||
.bg-photo__img { object-position: var(--g-photo-pos-m, var(--g-photo-pos, 50% 50%)); }
|
||||
.bg-photo--left { align-content: end; }
|
||||
.bg-photo--left::before { background: linear-gradient(to top, var(--scrim-a), var(--scrim-b) calc(var(--scrim-h) * .45), var(--scrim-c) var(--scrim-h), transparent calc(var(--scrim-h) + 12%)); }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 14 horizon: a dark band with a lit horizon */
|
||||
/* a dome of the page's dark tone rising from the bottom edge, its rim a thin line of light that is brightest at the
|
||||
top and fades to both sides, with the glow above it (the planet's edge in x.ai's dark pages) */
|
||||
.section.bg-horizon:not(.section--flush-top, .section--rule) { padding-bottom: calc(var(--section-band) * 1.25 + 150px); } /* (0,3,0), after the spacing block: keeps the dome's room, hero or not */
|
||||
.bg-horizon::before {
|
||||
content: ""; position: absolute; z-index: -1; left: 0; right: 0; bottom: 0; height: 640px; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(26% 74px at 50% calc(100% - 140px), color-mix(in oklab, var(--lit) calc(70% * var(--g-kd)), transparent), transparent),
|
||||
radial-gradient(54% 76% at 50% 100%, color-mix(in oklab, var(--lit) calc(44% * var(--g-kd)), transparent), transparent 74%),
|
||||
radial-gradient(120% 84% at 50% 100%, color-mix(in oklab, var(--g-hue) calc(22% * var(--g-kd)), transparent), transparent 76%);
|
||||
}
|
||||
.bg-horizon::after {
|
||||
/* a dome as wide as 170% of the section and never less than 1200px, so its curve stays a flat planet's rim on a phone (a
|
||||
dome as wide as the phone is a pointed arch) */
|
||||
content: ""; position: absolute; z-index: -1; left: 50%; translate: -50% 0; width: max(170%, 1200px); bottom: -560px; height: 700px; pointer-events: none;
|
||||
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
|
||||
background: radial-gradient(60% 100% at 50% 0, color-mix(in oklab, var(--lit) 34%, var(--bg)), var(--bg) 72%);
|
||||
border-top: 2px solid color-mix(in oklab, var(--lit) 90%, white);
|
||||
box-shadow: 0 -12px 56px -8px color-mix(in oklab, var(--lit) 62%, transparent), 0 -1px 16px color-mix(in oklab, var(--lit) 70%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 5%, #000 36%, #000 64%, transparent 95%);
|
||||
mask-image: linear-gradient(90deg, transparent 5%, #000 36%, #000 64%, transparent 95%);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 15 deep: near-black with a spotlight and grain, x.ai's dark */
|
||||
.bg-deep {
|
||||
--bg: color-mix(in oklab, var(--g-dark-bg) 70%, black);
|
||||
background-image:
|
||||
radial-gradient(64% 70% at 50% 0%, color-mix(in oklab, var(--g-hue) calc(21% * var(--g-kd)), transparent), transparent 76%),
|
||||
radial-gradient(48% 60% at 100% 100%, color-mix(in oklab, var(--g-hue-t) calc(18% * var(--g-kd)), transparent), transparent 72%),
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--g-dark-surface) calc(40% * var(--g-kd)), var(--bg)), var(--bg) 70%),
|
||||
linear-gradient(var(--g-cover-d), var(--g-cover-d)),
|
||||
var(--g-noise-scr);
|
||||
background-blend-mode: normal, normal, normal, normal, screen;
|
||||
}
|
||||
/* a hairline of light along the top edge, brightest in the middle (a lit lip, as on x.ai's cards) */
|
||||
.bg-deep::before {
|
||||
content: ""; position: absolute; z-index: -1; left: 0; right: 0; top: 0; height: 1px; pointer-events: none;
|
||||
background: linear-gradient(90deg, transparent 6%, color-mix(in oklab, var(--ink) calc(28% * var(--g-kd)), transparent) 50%, transparent 94%);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 16 liquid: a slow mesh of colour, transform only */
|
||||
/* two layers, each wider than the section (160%), each holding two wide ellipses of colour that fade to nothing inside the
|
||||
layer's own height (their radii end before the section's top and bottom, so nothing is clipped into an edge). They drift,
|
||||
skew and stretch against each other (translate, skewX, scaleY: transforms only, nothing repaints), so the mesh keeps
|
||||
changing shape. Three hues: the accent, the lit ground's tone (a second hue, so the wash is not blue all through) and
|
||||
the accent's neighbour, all low in chroma. Held still, it is the same picture. */
|
||||
.bg-liquid::before, .bg-liquid::after {
|
||||
content: ""; position: absolute; z-index: -1; pointer-events: none; top: 0; bottom: 0; left: -30%; right: -30%; will-change: transform;
|
||||
}
|
||||
.bg-liquid::before {
|
||||
background:
|
||||
radial-gradient(ellipse 30% 36% at 30% 44%, color-mix(in oklab, var(--g-hue) calc(40% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(ellipse 22% 24% at 54% 74%, color-mix(in oklab, var(--g-hue-t) calc(44% * var(--g-k)), transparent), transparent);
|
||||
animation: g-liq-a 26s ease-in-out infinite alternate;
|
||||
}
|
||||
.bg-liquid::after {
|
||||
background:
|
||||
radial-gradient(ellipse 28% 30% at 68% 38%, color-mix(in oklab, var(--g-hue-b) calc(32% * var(--g-k)), transparent), transparent),
|
||||
radial-gradient(ellipse 20% 20% at 44% 26%, color-mix(in oklab, var(--g-hue-t) calc(36% * var(--g-k)), transparent), transparent);
|
||||
animation: g-liq-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes g-liq-a { from { transform: translate3d(-7%, 0, 0) skewX(-7deg) scaleY(.94); } to { transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.05); } }
|
||||
@keyframes g-liq-b { from { transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.04); } to { transform: translate3d(-9%, 0, 0) skewX(-6deg) scaleY(.92); } }
|
||||
.bg-liquid.is-off::before, .bg-liquid.is-off::after, .bg-liquid.is-held::before, .bg-liquid.is-held::after { animation-play-state: paused; }
|
||||
/* the pause disc sits on the container's right edge, not the section's corner */
|
||||
.bg-liquid > .loop-pause { right: max(var(--pad), calc((100% - var(--container)) / 2)); bottom: 16px; }
|
||||
|
||||
/* ---------------------------------------------------------------- motion and print */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg-liquid::before, .bg-liquid::after { animation: none; }
|
||||
}
|
||||
@media print { .bg-liquid::before, .bg-liquid::after { display: none; } }
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
taste-skill backgrounds.js - optional, about 2 KB of work. Load it plain (not deferred) at the end of <head>,
|
||||
right after the :root palette block, so a dark page never paints one frame with the light page's grain.
|
||||
It reads the page palette (--p-*) and sets, on <html>, what backgrounds.css cannot know in CSS:
|
||||
data-mode light | dark is the page ink lighter than the page bg?
|
||||
data-tint away (only when) the bg is a strong colour or a mid tone: tints move away from the
|
||||
ink, which only ever adds contrast
|
||||
data-lowchroma (only when) the accent is a grey or near-grey: .bg-stage then takes the lit
|
||||
ground's colour instead of a hue that does not exist
|
||||
--g-k 0.08 to 1 how strongly the tints may show on this palette: the contrast headroom of the
|
||||
ink (7:1) and muted (4.5:1) text, in lightness, over the depth the grounds are
|
||||
drawn at. A palette whose ink is only just 7:1 on its bg gets almost flat grounds
|
||||
(an away palette keeps 1: its tints only add contrast)
|
||||
--g-kl the same headroom, kept for the grounds that draw in the ink's own direction (the rules, dots, rings)
|
||||
--g-kp the same, counted on the raw muted text, for the two page grounds (paper, topglow), which set no tokens
|
||||
--g-kd the same for the dark grounds (horizon, deep, photo)
|
||||
An attribute or variable the page (or start.mjs) already set is kept (--g-k, --g-kl, --g-kd each on its own: inline on
|
||||
<html> or in a stylesheet). It also pauses the .bg-liquid blobs while their section is off screen, and measures each
|
||||
.bg-photo's words to set how high its scrim stays deep (--scrim-h, or --scrim-w for .bg-photo--left on a wide screen).
|
||||
Every other ground is plain CSS.
|
||||
*/
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const get = n => getComputedStyle(root).getPropertyValue(n).trim();
|
||||
const hex = n => {
|
||||
let v = get(n).replace('#', '');
|
||||
if (v.length === 3) v = [...v].map(c => c + c).join('');
|
||||
return /^[0-9a-f]{6}$/i.test(v) ? [0, 2, 4].map(i => parseInt(v.slice(i, i + 2), 16) / 255) : null;
|
||||
};
|
||||
const lin = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const Y = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
const oklab = rgb => {
|
||||
const [r, g, b] = rgb.map(lin);
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
||||
};
|
||||
const REF = 0.22; // the deepest a tint is drawn at k = 1, in oklab lightness
|
||||
/* how far (oklab L) a ground of colour bg may move toward the ink and keep ink 7:1 and the muted
|
||||
text a ground uses (60% ink, 40% palette muted) 4.5:1 */
|
||||
const room = (ink, muted, bg, bgScale = 1, mixInk = 0.6) => {
|
||||
const Li = oklab(ink)[0], Lm = mixInk * Li + (1 - mixInk) * oklab(muted)[0], Lb = oklab(bg)[0] * bgScale, Yi = Y(ink), Ym = Lm ** 3;
|
||||
if (Li > Lb) {
|
||||
const max = Math.min((Yi + 0.05) / 7 - 0.05, (Ym + 0.05) / 4.5 - 0.05) * 0.8;
|
||||
return max > 0 ? Math.cbrt(max) - Lb : 0;
|
||||
}
|
||||
const min = Math.max(7 * (Yi + 0.05) - 0.05, 4.5 * (Ym + 0.05) - 0.05) * 1.15;
|
||||
return min < 1 ? Lb - Math.cbrt(min) : 0;
|
||||
};
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
|
||||
const detect = force => {
|
||||
const bg = hex('--p-bg'), ink = hex('--p-ink'), muted = hex('--p-muted');
|
||||
if (!bg || !ink) return;
|
||||
const a = oklab(bg), b = oklab(ink);
|
||||
if (force || !root.dataset.mode) root.dataset.mode = b[0] > a[0] ? 'dark' : 'light';
|
||||
if (force) { root.removeAttribute('data-tint'); ['--g-k', '--g-kl', '--g-kd', '--g-kp'].forEach(p => root.style.removeProperty(p)); }
|
||||
const ac = hex('--p-accent');
|
||||
if (ac) root.toggleAttribute('data-lowchroma', Math.hypot(oklab(ac)[1], oklab(ac)[2]) < 0.045);
|
||||
const away = Math.hypot(a[1], a[2]) > 0.09 || (a[0] > 0.3 && a[0] < 0.84);
|
||||
if (away && !root.hasAttribute('data-tint')) root.setAttribute('data-tint', 'away');
|
||||
const m = muted || ink;
|
||||
/* a strength the page already set (inline on <html>, or in a stylesheet: anything but the CSS default of 1) is kept, each on its own */
|
||||
const mine = n => force || (!root.dataset.k && root.style.getPropertyValue(n).trim() === '' && ['', '1'].includes(get(n)));
|
||||
const kl = clamp(room(ink, m, bg) / REF, 0.08, 1);
|
||||
if (mine('--g-kl')) root.style.setProperty('--g-kl', kl.toFixed(3));
|
||||
if (mine('--g-k')) root.style.setProperty('--g-k', root.dataset.tint === 'away' ? '1' : kl.toFixed(3));
|
||||
/* the page grounds (paper, topglow) sit under the page's own muted text, which they do not re-mix: their headroom is counted on the palette's raw muted */
|
||||
if (mine('--g-kp')) root.style.setProperty('--g-kp', root.dataset.tint === 'away' ? '1' : clamp(room(ink, m, bg, 1, 0) / REF, 0.08, 1).toFixed(3));
|
||||
const db = hex('--g-dark-bg'), di = hex('--g-dark-ink'), dm = hex('--g-dark-muted');
|
||||
if (db && di && dm && mine('--g-kd')) root.style.setProperty('--g-kd', clamp(room(di, dm, db, 0.8) / REF, 0.08, 1).toFixed(3));
|
||||
};
|
||||
detect(false);
|
||||
window.tasteBackgrounds = { detect: () => detect(true) };
|
||||
|
||||
/* the pause disc (WCAG 2.2.2: it moves for more than 5 s): the system's own .loop-pause, same icons, in the section's
|
||||
bottom-right corner; it holds the blobs with .is-held. Nothing under prefers-reduced-motion, where they hold still. */
|
||||
const ICON_PAUSE = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.25" y="3.25" width="2.75" height="9.5" rx="0.9"/><rect x="9" y="3.25" width="2.75" height="9.5" rx="0.9"/></svg>';
|
||||
const ICON_PLAY = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.25 3.7v8.6c0 .62.68 1 1.2.66l6.5-4.3c.47-.31.47-1 0-1.32l-6.5-4.3c-.52-.34-1.2.04-1.2.66Z"/></svg>';
|
||||
const hold = el => {
|
||||
if (el.hasAttribute('data-no-pause') || matchMedia('(prefers-reduced-motion: reduce)').matches || el.querySelector(':scope > .loop-pause')) return;
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'loop-pause';
|
||||
const show = () => { const held = el.classList.contains('is-held'), label = (held ? 'Play' : 'Pause') + ' animation'; b.innerHTML = held ? ICON_PLAY : ICON_PAUSE; b.setAttribute('aria-label', label); b.title = label; };
|
||||
b.addEventListener('click', () => { el.classList.toggle('is-held'); show(); });
|
||||
show(); el.append(b);
|
||||
};
|
||||
const watch = () => {
|
||||
const live = document.querySelectorAll('.bg-liquid');
|
||||
live.forEach(hold);
|
||||
if (live.length && 'IntersectionObserver' in window) {
|
||||
const io = new IntersectionObserver(es => es.forEach(e => e.target.classList.toggle('is-off', !e.isIntersecting)), { rootMargin: '80px' });
|
||||
live.forEach(el => { el.classList.add('is-off'); io.observe(el); });
|
||||
}
|
||||
};
|
||||
/* the scrim of a photograph must stay deep under every word: measure where the words start (their top edge, as a share of the
|
||||
section's height, from the bottom; their right edge for .bg-photo--left on a wide screen) and keep the deep part of the
|
||||
scrim 8% past it. A value the section sets itself (inline) is kept. */
|
||||
const fit = el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.height || !r.width) return;
|
||||
const words = [...el.querySelectorAll('h1, h2, h3, h4, p, a, button, li, blockquote, figcaption, .btn-row')].filter(w => !w.closest('.bg-photo__img') && w.getClientRects().length);
|
||||
if (!words.length) return;
|
||||
const top = Math.min(...words.map(w => w.getBoundingClientRect().top)), right = Math.max(...words.map(w => w.getBoundingClientRect().right));
|
||||
if (!el.dataset.scrimHSet) {
|
||||
const h = Math.min(100, Math.max(40, ((r.bottom - top) / r.height) * 100 + 8));
|
||||
el.style.setProperty('--scrim-h', h.toFixed(1) + '%');
|
||||
}
|
||||
if (!el.dataset.scrimWSet) el.style.setProperty('--scrim-w', Math.min(100, Math.max(30, ((right - r.left) / r.width) * 100 + 8)).toFixed(1) + '%');
|
||||
};
|
||||
const photos = () => {
|
||||
document.querySelectorAll('.bg-photo').forEach(el => {
|
||||
/* a page-set inline value is kept: remember it before the first measure */
|
||||
if (el.dataset.scrimHSet == null) el.dataset.scrimHSet = el.style.getPropertyValue('--scrim-h') ? '1' : '';
|
||||
if (el.dataset.scrimWSet == null) el.dataset.scrimWSet = el.style.getPropertyValue('--scrim-w') ? '1' : '';
|
||||
fit(el);
|
||||
if ('ResizeObserver' in window && !el.__fit) { el.__fit = new ResizeObserver(() => fit(el)); el.__fit.observe(el); }
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { watch(); photos(); }, { once: true }); else { watch(); photos(); }
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(photos);
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-ground="sand">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Backgrounds contrast check</title>
|
||||
<!-- Every ground in a 800px box, for check.mjs: it sets a palette, screenshots the boxes and measures
|
||||
the ink and muted contrast on every pixel of each ground's text zone (index.json). The photograph
|
||||
grounds sit on a flat grey picture: #7c7c7c for the dark scrim, #d4d4d4 for the light one; and the worst case of the dark scrim: a white
|
||||
picture (white words on it). The light scrim is for pale photographs only. -->
|
||||
<link rel="stylesheet" href="../system.css">
|
||||
<link rel="stylesheet" href="backgrounds.css">
|
||||
<style>body { margin: 0; width: 800px; } .box { width: 800px; margin: 0 0 8px; padding: 24px; font-size: 20px; } .pi, .pm { visibility: hidden; } .pi { color: var(--ink); } .pm { color: var(--muted); } /* the liquid blobs held at the start, the middle and the end of their drift (the keyframes in backgrounds.css), so the text zone is measured where they stand under it */
|
||||
.at-start::before { animation: none; transform: translate3d(-7%, 0, 0) skewX(-7deg) scaleY(.94); } .at-start::after { animation: none; transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.04); }
|
||||
.at-mid::before { animation: none; transform: translate3d(.5%, 0, 0) skewX(-.5deg) scaleY(.995); } .at-mid::after { animation: none; transform: translate3d(-.5%, 0, 0) scaleY(.98); }
|
||||
.at-end::before { animation: none; transform: translate3d(8%, 0, 0) skewX(6deg) scaleY(1.05); } .at-end::after { animation: none; transform: translate3d(-9%, 0, 0) skewX(-6deg) scaleY(.92); }
|
||||
</style>
|
||||
<script src="backgrounds.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-mist box" data-g="mist" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-halo box" data-g="halo" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-grain box" data-g="grain" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-paper box" data-g="paper" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-dusk box" data-g="dusk" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-topglow box" data-g="topglow" style="height:560px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-ruled box" data-g="ruled" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-dots box" data-g="dots" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-arcs box" data-g="arcs" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-band box" data-g="band" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-field box" data-g="field" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-band bg-tint box" data-g="band-tint" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-field bg-tint box" data-g="field-tint" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-photo box" data-g="photo" style="height:520px;--g-photo:linear-gradient(#7c7c7c,#7c7c7c)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-photo bg-photo--left box" data-g="photo-left" style="height:520px;--g-photo:linear-gradient(#7c7c7c,#7c7c7c)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-photo bg-photo--light box" data-g="photo-light" style="height:520px;--g-photo:linear-gradient(#d4d4d4,#d4d4d4)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-photo box" data-g="photo-white" style="height:520px;--g-photo:linear-gradient(#fff,#fff)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-photo bg-photo--left box" data-g="photo-left-white" style="height:520px;--g-photo:linear-gradient(#fff,#fff)"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-horizon box" data-g="horizon" style="height:640px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-deep box" data-g="deep" style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-liquid box at-start" data-g="liquid" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-liquid box at-mid" data-g="liquid-mid" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
<div class="bg-liquid box at-end" data-g="liquid-end" data-no-pause style="height:420px"><span class="pi">Ink</span> <span class="pm">Muted</span></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
taste-skill backgrounds check.mjs - measures the contrast of the page ink (7:1) and the muted ink (4.5:1)
|
||||
on every ground, on every palette, from real pixels.
|
||||
|
||||
node assets/backgrounds/check.mjs --profile <profile-dir> [--palettes id,id] [--grounds mist,halo]
|
||||
[--lit sand,painting] [--json out.json] [--save dir] [--min-ink 7] [--min-muted 4.5] [--port 5967]
|
||||
|
||||
It serves the skill folder over http on 127.0.0.1 (5965-5969) and opens check.html in ONE headless Chrome (WebGL on the CPU flags, no window; closed by its own PID),
|
||||
sets each palette's --p-* tokens and the lit ground, screenshots the grounds' boxes, and reads the ink and
|
||||
muted colours the ground really resolves to. Each ground's text zone (index.json, fractions of its box)
|
||||
is cut into 8px blocks; the worst block's ratio is the result. Exit code 1 when a ground fails.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import zlib from 'node:zlib';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILL = path.resolve(HERE, '..', '..');
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf('--' + k); return i < 0 ? d : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true); };
|
||||
// default Chrome profile folder: --profile, else $TASTE_SKILL_TMP/chrome/<tool>-<pid>, else a fresh folder under the system temp dir
|
||||
const defaultProfile = () => process.env.TASTE_SKILL_TMP ? path.join(process.env.TASTE_SKILL_TMP, 'chrome', 'bg-check-' + process.pid) : path.join(os.tmpdir(), 'taste-skill-bg-check-' + process.pid);
|
||||
const profile = (p => (p && p !== true ? p : defaultProfile()))(opt('profile'));
|
||||
const MIN_INK = +opt('min-ink', 7), MIN_MUTED = +opt('min-muted', 4.5), SCALE = 0.5, BLOCK = 4;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/* ---------------------------------------------------------------- data */
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(HERE, 'index.json'), 'utf8')).grounds;
|
||||
const zones = {}; // box id -> text zone
|
||||
for (const g of meta) { zones[g.id] = g.textZone; if (g.variants) for (const v of g.variants) zones[v.id] = v.textZone || g.textZone; if (g.checkFrames) for (const f of g.checkFrames) zones[f] = g.textZone; }
|
||||
const boxIds = Object.keys(zones);
|
||||
const wantGrounds = opt('grounds') ? String(opt('grounds')).split(',') : null;
|
||||
const lits = opt('lit') ? String(opt('lit')).split(',') : ['sand', 'painting'];
|
||||
const tokensOf = id => {
|
||||
const css = execFileSync('node', [path.join(SKILL, 'scripts', 'start.mjs'), 'css', '--page', 'software', '--palette', id, '--type', 'hanken', '--radius', 'soft', '--history', path.join(path.resolve(profile), 'start-history.json')], { encoding: 'utf8' }); // the face does not matter here, only the --p-* colours; the history goes into the profile folder, not the page's .taste-skill
|
||||
const t = {};
|
||||
for (const m of css.matchAll(/--p-([a-z-]+):\s*(#[0-9a-fA-F]{3,8})/g)) t[m[1]] = m[2];
|
||||
return t;
|
||||
};
|
||||
const allIds = JSON.parse(fs.readFileSync(path.join(SKILL, 'library', 'palettes.json'), 'utf8')).map(p => p.id);
|
||||
const palIds = opt('palettes') ? String(opt('palettes')).split(',') : allIds;
|
||||
|
||||
/* ---------------------------------------------------------------- PNG (8-bit RGB/RGBA) */
|
||||
function decodePNG(buf) {
|
||||
let pos = 8, w = 0, h = 0, type = 0; const idat = [];
|
||||
while (pos < buf.length) {
|
||||
const len = buf.readUInt32BE(pos), name = buf.toString('ascii', pos + 4, pos + 8), data = buf.subarray(pos + 8, pos + 8 + len);
|
||||
if (name === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); type = data[9]; } else if (name === 'IDAT') idat.push(data);
|
||||
pos += 12 + len;
|
||||
}
|
||||
const bpp = type === 6 ? 4 : 3, raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * bpp, px = Buffer.alloc(w * h * 3);
|
||||
let prev = Buffer.alloc(stride);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const f = raw[y * (stride + 1)], line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const a = i >= bpp ? line[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0; let v = line[i];
|
||||
if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
|
||||
else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
||||
line[i] = v & 255;
|
||||
}
|
||||
for (let x = 0; x < w; x++) { const o = (y * w + x) * 3; px[o] = line[x * bpp]; px[o + 1] = line[x * bpp + 1]; px[o + 2] = line[x * bpp + 2]; }
|
||||
prev = line;
|
||||
}
|
||||
return { w, h, px };
|
||||
}
|
||||
const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const lum = (r, g, b) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
|
||||
/* ---------------------------------------------------------------- Chrome */
|
||||
const findChrome = () => ['C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find(p => p && fs.existsSync(p));
|
||||
const prof = path.resolve(profile); fs.mkdirSync(prof, { recursive: true });
|
||||
const portFile = path.join(prof, 'DevToolsActivePort'); try { fs.rmSync(portFile); } catch { /* none */ }
|
||||
const child = spawn(findChrome(), ['--headless=new', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check',
|
||||
'--disable-extensions', '--hide-scrollbars', '--mute-audio', '--disable-gpu', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--window-size=800,900', 'about:blank'], { stdio: 'ignore', windowsHide: true });
|
||||
const kill = () => { if (child.exitCode != null) return; try { execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); } catch { try { child.kill(); } catch { /* gone */ } } };
|
||||
// the page is served over http on 127.0.0.1 (5965-5969, --port), never opened from file://
|
||||
const PORT = +opt('port', 5967);
|
||||
if (PORT < 5965 || PORT > 5969) { console.error('check: --port must be 5965-5969'); process.exit(1); }
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
|
||||
const srv = http.createServer((req, res) => {
|
||||
const file = path.join(SKILL, decodeURIComponent(new URL(req.url, 'http://x').pathname));
|
||||
if (!file.startsWith(SKILL) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); }
|
||||
res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
await new Promise((resolve, reject) => { srv.on('error', reject); srv.listen(PORT, '127.0.0.1', resolve); });
|
||||
const guard = setTimeout(() => { console.error('check: timed out'); kill(); process.exit(2); }, 25 * 60 * 1000);
|
||||
|
||||
const results = [];
|
||||
try {
|
||||
let port = null;
|
||||
for (let i = 0; i < 150 && !port; i++) { await sleep(100); try { port = parseInt(fs.readFileSync(portFile, 'utf8'), 10) || null; } catch { /* not yet */ } }
|
||||
if (!port) throw new Error('Chrome did not start');
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const ws = new WebSocket(ver.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.addEventListener('open', r, { once: true }); ws.addEventListener('error', j, { once: true }); });
|
||||
let id = 0; const pend = new Map(), subs = [];
|
||||
ws.addEventListener('message', e => { const m = JSON.parse(e.data); if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); } else subs.forEach(f => f(m)); });
|
||||
const send = (method, params = {}, sessionId) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method, params, sessionId })); });
|
||||
const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' });
|
||||
const { result: { sessionId } } = await send('Target.attachToTarget', { targetId, flatten: true });
|
||||
const S = async (m, p) => { const r = await send(m, p, sessionId); if (r.error) throw new Error(`${m}: ${r.error.message}`); return r.result; };
|
||||
const ev = async x => { const r = await S('Runtime.evaluate', { expression: x, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; };
|
||||
await S('Page.enable'); await S('Runtime.enable');
|
||||
await S('Emulation.setDeviceMetricsOverride', { width: 800, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
const loaded = new Promise(r => subs.push(m => { if (m.method === 'Page.loadEventFired') r(); }));
|
||||
await S('Page.navigate', { url: `http://127.0.0.1:${PORT}/assets/backgrounds/check.html` });
|
||||
await Promise.race([loaded, sleep(15000)]);
|
||||
await sleep(300);
|
||||
|
||||
for (const pid of palIds) {
|
||||
const tk = tokensOf(pid);
|
||||
for (const lit of lits) {
|
||||
const info = await ev(`(async () => {
|
||||
const root = document.documentElement, tk = ${JSON.stringify(tk)};
|
||||
for (const [k, v] of Object.entries(tk)) root.style.setProperty('--p-' + k, v);
|
||||
root.dataset.ground = ${JSON.stringify(lit)};
|
||||
window.tasteBackgrounds.detect();
|
||||
const only = ${JSON.stringify(wantGrounds)};
|
||||
for (const el of document.querySelectorAll('.box')) el.style.display = !only || only.includes(el.dataset.g) || only.includes(el.dataset.g.split('-')[0]) ? '' : 'none';
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cv = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
||||
const rgb = c => { cv.clearRect(0, 0, 1, 1); cv.fillStyle = '#000'; cv.fillStyle = c; cv.fillRect(0, 0, 1, 1); return [...cv.getImageData(0, 0, 1, 1).data.slice(0, 3)]; };
|
||||
const out = { mode: root.dataset.mode, tint: root.dataset.tint || '', boxes: {} };
|
||||
for (const el of document.querySelectorAll('.box')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
out.boxes[el.dataset.g] = { top: r.top + scrollY, left: r.left + scrollX, h: r.height, w: r.width, rad: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, ink: rgb(getComputedStyle(el.querySelector('.pi')).color), muted: rgb(getComputedStyle(el.querySelector('.pm')).color) };
|
||||
}
|
||||
out.k = getComputedStyle(root).getPropertyValue('--g-k').trim(); out.kd = getComputedStyle(root).getPropertyValue('--g-kd').trim();
|
||||
out.height = document.documentElement.scrollHeight;
|
||||
return out;
|
||||
})()`);
|
||||
await sleep(250);
|
||||
const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: 800, height: info.height, scale: SCALE } });
|
||||
if (opt('save') && opt('save') !== true) { fs.mkdirSync(opt('save'), { recursive: true }); fs.writeFileSync(path.join(String(opt('save')), `${pid}-${lit}.png`), Buffer.from(data, 'base64')); }
|
||||
const img = decodePNG(Buffer.from(data, 'base64'));
|
||||
for (const bid of boxIds) {
|
||||
if (wantGrounds && !wantGrounds.includes(bid) && !wantGrounds.includes(bid.split('-')[0])) continue;
|
||||
const b = info.boxes[bid]; if (!b || !b.h) continue;
|
||||
const [zx0, zy0, zx1, zy1] = zones[bid];
|
||||
const ins = b.rad ? Math.ceil(b.rad * 0.3) + 2 : 0; // a rounded box (bg-inset): the four corners outside the curve are the page, not the ground
|
||||
const x0 = Math.floor((b.left + b.w * zx0 + (zx0 === 0 ? ins : 0)) * SCALE), x1 = Math.floor((b.left + b.w * zx1 - (zx1 === 1 ? ins : 0)) * SCALE), y0 = Math.floor((b.top + b.h * zy0 + (zy0 === 0 ? ins : 0)) * SCALE), y1 = Math.floor((b.top + b.h * zy1 - (zy1 === 1 ? ins : 0)) * SCALE);
|
||||
const Li = lum(...b.ink), Lm = lum(...b.muted);
|
||||
let worstInk = 99, worstMuted = 99;
|
||||
for (let by = y0; by + BLOCK <= y1; by += BLOCK) for (let bx = x0; bx + BLOCK <= x1; bx += BLOCK) {
|
||||
let r = 0, g = 0, bl = 0;
|
||||
for (let y = by; y < by + BLOCK; y++) for (let x = bx; x < bx + BLOCK; x++) { const o = (y * img.w + x) * 3; r += img.px[o]; g += img.px[o + 1]; bl += img.px[o + 2]; }
|
||||
const n = BLOCK * BLOCK, Lb = lum(r / n, g / n, bl / n);
|
||||
worstInk = Math.min(worstInk, ratio(Li, Lb)); worstMuted = Math.min(worstMuted, ratio(Lm, Lb));
|
||||
}
|
||||
results.push({ inkRgb: b.ink.join(','), box: [b.left, b.top, b.w, b.h].map(Math.round).join(','), palette: pid, lit, ground: bid, mode: info.mode, tint: info.tint, k: info.k, kd: info.kd, ink: +worstInk.toFixed(2), muted: +worstMuted.toFixed(2), pass: worstInk >= MIN_INK && worstMuted >= MIN_MUTED });
|
||||
}
|
||||
}
|
||||
process.stderr.write(`${pid} `);
|
||||
}
|
||||
try { await Promise.race([send('Browser.close'), sleep(3000)]); } catch { /* closing */ }
|
||||
} catch (e) {
|
||||
console.error('check:', e.message); process.exitCode = 1;
|
||||
} finally {
|
||||
clearTimeout(guard); await sleep(300); kill(); srv.close();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- report */
|
||||
const fails = results.filter(r => !r.pass);
|
||||
const by = {};
|
||||
for (const r of results) { const k = r.ground; (by[k] ||= { n: 0, fail: 0, minInk: 99, minMuted: 99, bad: new Set() }); by[k].n++; by[k].minInk = Math.min(by[k].minInk, r.ink); by[k].minMuted = Math.min(by[k].minMuted, r.muted); if (!r.pass) { by[k].fail++; by[k].bad.add(r.palette); } }
|
||||
console.log('\n');
|
||||
console.log('ground'.padEnd(12), 'runs'.padStart(5), 'fail'.padStart(5), 'min ink'.padStart(8), 'min muted'.padStart(10), ' failing palettes');
|
||||
for (const [k, v] of Object.entries(by)) console.log(k.padEnd(12), String(v.n).padStart(5), String(v.fail).padStart(5), v.minInk.toFixed(2).padStart(8), v.minMuted.toFixed(2).padStart(10), ' ', [...v.bad].slice(0, 12).join(', ') + (v.bad.size > 12 ? ` +${v.bad.size - 12}` : ''));
|
||||
const jf = opt('json'); if (jf && jf !== true) fs.writeFileSync(jf, JSON.stringify(results, null, 1));
|
||||
console.log(`\n${results.length - fails.length}/${results.length} passed (ink >= ${MIN_INK}, muted >= ${MIN_MUTED})`);
|
||||
if (fails.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,376 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-radius="soft" data-ground="sand">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backgrounds demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../system.css">
|
||||
<link rel="stylesheet" href="../ui/cards.css">
|
||||
<link rel="stylesheet" href="backgrounds.css">
|
||||
<style>
|
||||
:root { --font-display: "Hanken Grotesk", system-ui, sans-serif; --font-text: "Hanken Grotesk", system-ui, sans-serif; --display-weight: 500; --display-tracking: -0.035em; }
|
||||
/* the demo's own frame: a tool bar and a small id tag on each ground (both off with ?bar=0&labels=0) */
|
||||
.demo-bar { position: fixed; z-index: 90; right: 12px; bottom: 12px; display: flex; gap: 8px; align-items: center; padding: 8px 10px; border-radius: 12px;
|
||||
background: #111; color: #fff; font: 500 12px/1 ui-monospace, Menlo, Consolas, monospace; box-shadow: 0 8px 30px rgb(0 0 0 / .3); }
|
||||
.demo-bar select { font: inherit; background: #222; color: #fff; border: 1px solid #444; border-radius: 6px; padding: 5px 6px; max-width: 150px; }
|
||||
body.no-bar .demo-bar, body.no-labels .demo-tag { display: none; }
|
||||
.demo-tag { position: absolute; z-index: 3; top: 14px; left: 14px; padding: 4px 8px; border-radius: 6px; font: 500 11px/1 ui-monospace, Menlo, Consolas, monospace;
|
||||
background: rgb(0 0 0 / .72); color: #fff; letter-spacing: .02em; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* small UI pieces, drawn with the ground's tokens so they show how a card reads on each ground */
|
||||
.mini { background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: var(--r-md); padding: 16px; font-size: 14px; line-height: 1.4;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 12px 28px -16px rgb(0 0 0 / .25); }
|
||||
.mini h4 { font: 500 14px/1.3 var(--font-text); letter-spacing: 0; margin: 0 0 12px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.mini h4 span { font-weight: 400; color: var(--muted); font-size: 13px; }
|
||||
.rowline { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.rowline:first-of-type { border-top: 0; }
|
||||
.av { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 500; background: color-mix(in oklab, var(--accent) 16%, var(--card)); color: var(--ink); }
|
||||
.rowline b { font-weight: 500; display: block; }
|
||||
.rowline small { color: var(--muted); font-size: 12.5px; }
|
||||
.tag { font-size: 12px; padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--ink); white-space: nowrap; }
|
||||
.tag.on { background: var(--action); color: var(--action-ink); border-color: transparent; }
|
||||
.sw { display: flex; justify-content: space-between; align-items: center; padding: 11px 0; border-top: 1px solid var(--line); font-size: 14px; }
|
||||
.sw:first-of-type { border-top: 0; }
|
||||
.sw i { width: 34px; height: 20px; border-radius: 99px; background: var(--line-strong); position: relative; flex: none; }
|
||||
.sw i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--card); box-shadow: 0 1px 2px rgb(0 0 0 / .3); }
|
||||
.sw i.on { background: var(--action); } .sw i.on::after { left: 16px; background: var(--action-ink); }
|
||||
.week { display: grid; grid-template-columns: 64px repeat(5, 1fr); gap: 6px; font-size: 12.5px; }
|
||||
.week > div { padding: 8px 8px; border-radius: 8px; background: color-mix(in oklab, var(--ink) 5%, var(--card)); min-height: 34px; }
|
||||
.week > .h { background: none; color: var(--muted); padding: 4px 8px; }
|
||||
.week > .c { background: color-mix(in oklab, var(--accent) 18%, var(--card)); }
|
||||
.week > .n { background: none; color: var(--muted); padding-left: 0; }
|
||||
.bubble { padding: 10px 12px; border-radius: 12px; background: color-mix(in oklab, var(--ink) 6%, var(--card)); max-width: 86%; }
|
||||
.bubble.me { margin-left: auto; background: var(--action); color: var(--action-ink); }
|
||||
.stack > * + * { margin-top: 8px; }
|
||||
.big-stat { font: var(--display-weight) clamp(72px, 9vw, 132px)/.95 var(--font-display); letter-spacing: -0.05em; font-variant-numeric: tabular-nums; }
|
||||
.steps li { display: grid; gap: 10px; align-content: start; padding-top: 18px; border-top: 1px solid var(--line-strong); }
|
||||
.nav .btn { height: 36px; }
|
||||
.vh { min-height: 0; }
|
||||
.center { text-align: center; justify-items: center; }
|
||||
.stats4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }
|
||||
.stats4 b { display: block; font: var(--display-weight) 40px/1 var(--font-display); letter-spacing: -0.04em; margin-bottom: 6px; }
|
||||
@media (max-width: 767px) { .stats4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .week { grid-template-columns: 44px repeat(3, 1fr); } .week > :nth-child(6n+5), .week > :nth-child(6n+6) { display: none; } }
|
||||
</style>
|
||||
<script>
|
||||
const PALETTES = {
|
||||
"white-link-blue": {"bg":"#ffffff","surface":"#f4f4f4","ink":"#111111","muted":"#6b6b6b","accent":"#1a4be0","accent-ink":"#ffffff","action":"#1a4be0","action-ink":"#ffffff","inv-bg":"#111111","inv-surface":"#1e1e1e","inv-ink":"#ffffff","inv-muted":"#9a9a9a","inv-accent":"#ffffff","inv-accent-ink":"#111111","inv-action":"#ffffff","inv-action-ink":"#111111"},
|
||||
"cool-white-slate": {"bg":"#f7f9fa","surface":"#ecf0f2","ink":"#1f3038","muted":"#5d6b72","accent":"#0d7480","accent-ink":"#ffffff","action":"#0d7480","action-ink":"#ffffff","inv-bg":"#1f3038","inv-surface":"#2c3c44","inv-ink":"#f7f9fa","inv-muted":"#9ea7ab","inv-accent":"#f7f9fa","inv-accent-ink":"#1f3038","inv-action":"#f7f9fa","inv-action-ink":"#1f3038"},
|
||||
"rose-grey-green": {"bg":"#ebe5e3","surface":"#e1dad7","ink":"#2b2622","muted":"#655c58","accent":"#0f7d45","accent-ink":"#ffffff","action":"#0f7d45","action-ink":"#ffffff","inv-bg":"#2b2622","inv-surface":"#36312d","inv-ink":"#ebe5e3","inv-muted":"#a29c99","inv-accent":"#ebe5e3","inv-accent-ink":"#2b2622","inv-action":"#ebe5e3","inv-action-ink":"#2b2622"},
|
||||
"paper-grey-brass": {"bg":"#e8e8e3","surface":"#dcdcd6","ink":"#181715","muted":"#5c5b56","accent":"#bfb21a","accent-ink":"#181715","action":"#181715","action-ink":"#e8e8e3","inv-bg":"#181715","inv-surface":"#242321","inv-ink":"#e8e8e3","inv-muted":"#91908d","inv-accent":"#bfb21a","inv-accent-ink":"#181715","inv-action":"#bfb21a","inv-action-ink":"#181715"},
|
||||
"stone-blueprint": {"bg":"#dfdedb","surface":"#d3d2ce","ink":"#26251e","muted":"#5a5850","accent":"#2456d6","accent-ink":"#ffffff","action":"#2456d6","action-ink":"#ffffff","inv-bg":"#26251e","inv-surface":"#313029","inv-ink":"#dfdedb","inv-muted":"#9e9d99","inv-accent":"#dfdedb","inv-accent-ink":"#26251e","inv-action":"#dfdedb","inv-action-ink":"#26251e"},
|
||||
"white-navy-tab": {"bg":"#ffffff","surface":"#f4f4f2","ink":"#1f2b4d","muted":"#646b81","accent":"#f7941d","accent-ink":"#1f2b4d","action":"#f7941d","action-ink":"#1f2b4d","inv-bg":"#05070a","inv-surface":"#111417","inv-ink":"#ffffff","inv-muted":"#939597","inv-accent":"#f7941d","inv-accent-ink":"#1f2b4d","inv-action":"#f7941d","inv-action-ink":"#1f2b4d"},
|
||||
"three-stones": {"bg":"#eeecea","surface":"#e4e1dd","ink":"#333231","muted":"#625f5b","accent":"#5c6b54","accent-ink":"#ffffff","action":"#5c6b54","action-ink":"#ffffff","inv-bg":"#333231","inv-surface":"#3e3d3c","inv-ink":"#eeecea","inv-muted":"#adaba9","inv-accent":"#eeecea","inv-accent-ink":"#333231","inv-action":"#eeecea","inv-action-ink":"#333231"},
|
||||
"putty-black-teal": {"bg":"#dedad3","surface":"#d3cec6","ink":"#0a0a0a","muted":"#55514c","accent":"#256f82","accent-ink":"#ffffff","action":"#256f82","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#151515","inv-ink":"#dedad3","inv-muted":"#84827e","inv-accent":"#256f82","inv-accent-ink":"#ffffff","inv-action":"#256f82","inv-action-ink":"#ffffff"},
|
||||
"moss-sand": {"bg":"#ddd6c6","surface":"#d2cab8","ink":"#2a2a26","muted":"#55534b","accent":"#4f6238","accent-ink":"#ffffff","action":"#4f6238","action-ink":"#ffffff","inv-bg":"#2a2a26","inv-surface":"#353430","inv-ink":"#ddd6c6","inv-muted":"#a39e92","inv-accent":"#ddd6c6","inv-accent-ink":"#2a2a26","inv-action":"#ddd6c6","inv-action-ink":"#2a2a26"},
|
||||
"green-mat-chocolate": {"bg":"#e5e1d8","surface":"#dad5ca","ink":"#2a1d17","muted":"#65564c","accent":"#456f3a","accent-ink":"#ffffff","action":"#456f3a","action-ink":"#ffffff","inv-bg":"#2a1d17","inv-surface":"#352822","inv-ink":"#e5e1d8","inv-muted":"#9e968e","inv-accent":"#e5e1d8","inv-accent-ink":"#2a1d17","inv-action":"#e5e1d8","inv-action-ink":"#2a1d17"},
|
||||
"lunar-grey-bronze": {"bg":"#111111","surface":"#1c1c1c","ink":"#f2f2f2","muted":"#8f8f8f","accent":"#a08672","accent-ink":"#111111","action":"#a08672","action-ink":"#111111","inv-bg":"#f2f2f2","inv-surface":"#e0e0e0","inv-ink":"#111111","inv-muted":"#5c5c5c","inv-accent":"#a08672","inv-accent-ink":"#111111","inv-action":"#a08672","inv-action-ink":"#111111"},
|
||||
"blue-black-cornflower": {"bg":"#0a0d14","surface":"#131824","ink":"#e9edf3","muted":"#8891a1","accent":"#5ab9fc","accent-ink":"#0a0d14","action":"#5ab9fc","action-ink":"#0a0d14","inv-bg":"#e9edf3","inv-surface":"#d7dbe1","inv-ink":"#0a0d14","inv-muted":"#54575f","inv-accent":"#0a0d14","inv-accent-ink":"#e9edf3","inv-action":"#0a0d14","inv-action-ink":"#e9edf3"},
|
||||
"green-black-bone": {"bg":"#161c1a","surface":"#1f2725","ink":"#e3e2da","muted":"#8e948d","accent":"#9fc7b8","accent-ink":"#161c1a","action":"#9fc7b8","action-ink":"#161c1a","inv-bg":"#e3e2da","inv-surface":"#d3d2cb","inv-ink":"#161c1a","inv-muted":"#555a55","inv-accent":"#161c1a","inv-accent-ink":"#e3e2da","inv-action":"#161c1a","inv-action-ink":"#e3e2da"},
|
||||
"black-crimson": {"bg":"#0a0a0a","surface":"#151515","ink":"#efefef","muted":"#8c8c8c","accent":"#b11226","accent-ink":"#ffffff","action":"#b11226","action-ink":"#ffffff","inv-bg":"#efefef","inv-surface":"#dcdcdc","inv-ink":"#0a0a0a","inv-muted":"#565656","inv-accent":"#b11226","inv-accent-ink":"#ffffff","inv-action":"#b11226","inv-action-ink":"#ffffff"},
|
||||
"forest-pale-lime": {"bg":"#0f1f10","surface":"#162a17","ink":"#ffffff","muted":"#9aa39a","accent":"#c6f19d","accent-ink":"#0f1f10","action":"#c6f19d","action-ink":"#0f1f10","inv-bg":"#ffffff","inv-surface":"#ecedec","inv-ink":"#0f1f10","inv-muted":"#606c60","inv-accent":"#0f1f10","inv-accent-ink":"#ffffff","inv-action":"#0f1f10","inv-action-ink":"#ffffff"},
|
||||
"charcoal-room-sage": {"bg":"#1c1c1c","surface":"#262626","ink":"#f1f1ef","muted":"#9a9b96","accent":"#8fa68e","accent-ink":"#1c1c1c","action":"#8fa68e","action-ink":"#1c1c1c","inv-bg":"#c8c5ba","inv-surface":"#bab8ae","inv-ink":"#1c1c1c","inv-muted":"#454442","inv-accent":"#1c1c1c","inv-accent-ink":"#c8c5ba","inv-action":"#1c1c1c","inv-action-ink":"#c8c5ba"},
|
||||
"warm-black-gold": {"bg":"#0c0a08","surface":"#181512","ink":"#ffffff","muted":"#8f8a84","accent":"#c9a45a","accent-ink":"#0c0a08","action":"#c9a45a","action-ink":"#0c0a08","inv-bg":"#ffffff","inv-surface":"#ebebeb","inv-ink":"#0c0a08","inv-muted":"#5c5b59","inv-accent":"#0c0a08","inv-accent-ink":"#ffffff","inv-action":"#0c0a08","inv-action-ink":"#ffffff"},
|
||||
"graphite-safety-orange": {"bg":"#18191b","surface":"#222427","ink":"#eff0f1","muted":"#9c9ea3","accent":"#ff4a1f","accent-ink":"#18191b","action":"#ff4a1f","action-ink":"#18191b","inv-bg":"#eff0f1","inv-surface":"#dedfe0","inv-ink":"#18191b","inv-muted":"#5b5c5e","inv-accent":"#18191b","inv-accent-ink":"#eff0f1","inv-action":"#18191b","inv-action-ink":"#eff0f1"},
|
||||
"soot-pale-lime": {"bg":"#0f0f0f","surface":"#1a1a1a","ink":"#ffffff","muted":"#8f8f8f","accent":"#dafa9c","accent-ink":"#0f0f0f","action":"#dafa9c","action-ink":"#0f0f0f","inv-bg":"#ffffff","inv-surface":"#ececec","inv-ink":"#0f0f0f","inv-muted":"#5f5f5f","inv-accent":"#0f0f0f","inv-accent-ink":"#ffffff","inv-action":"#0f0f0f","inv-action-ink":"#ffffff"},
|
||||
"plum-black-pink": {"bg":"#0d060b","surface":"#1b0e17","ink":"#ffffff","muted":"#9a8f97","accent":"#ff488b","accent-ink":"#0d060b","action":"#ff488b","action-ink":"#0d060b","inv-bg":"#ffffff","inv-surface":"#ebebeb","inv-ink":"#0d060b","inv-muted":"#5e575c","inv-accent":"#ff488b","inv-accent-ink":"#0d060b","inv-action":"#ff488b","inv-action-ink":"#0d060b"},
|
||||
"white-signal-red": {"bg":"#ffffff","surface":"#f2f2f2","ink":"#0a0a0a","muted":"#6b6b6b","accent":"#d92b21","accent-ink":"#ffffff","action":"#d92b21","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#d92b21","inv-accent-ink":"#ffffff","inv-action":"#d92b21","inv-action-ink":"#ffffff"},
|
||||
"white-mint-tiles": {"bg":"#ffffff","surface":"#f3f3f3","ink":"#232323","muted":"#686868","accent":"#a1ffcb","accent-ink":"#232323","action":"#232323","action-ink":"#ffffff","inv-bg":"#232323","inv-surface":"#303030","inv-ink":"#ffffff","inv-muted":"#a4a4a4","inv-accent":"#a1ffcb","inv-accent-ink":"#232323","inv-action":"#a1ffcb","inv-action-ink":"#232323"},
|
||||
"cool-grey-cobalt": {"bg":"#eef0f3","surface":"#e2e5ea","ink":"#0b0c0e","muted":"#5d6470","accent":"#1f2de6","accent-ink":"#ffffff","action":"#1f2de6","action-ink":"#ffffff","inv-bg":"#0b0c0e","inv-surface":"#17181a","inv-ink":"#eef0f3","inv-muted":"#8e8f92","inv-accent":"#eef0f3","inv-accent-ink":"#0b0c0e","inv-action":"#eef0f3","inv-action-ink":"#0b0c0e"},
|
||||
"offwhite-bottle-yellow": {"bg":"#f7f7f7","surface":"#ebebeb","ink":"#0d2a1c","muted":"#5f6b64","accent":"#f6e016","accent-ink":"#0d2a1c","action":"#0d2a1c","action-ink":"#f7f7f7","inv-bg":"#0d2a1c","inv-surface":"#1c3629","inv-ink":"#f7f7f7","inv-muted":"#97a39c","inv-accent":"#f6e016","inv-accent-ink":"#0d2a1c","inv-action":"#f6e016","inv-action-ink":"#0d2a1c"},
|
||||
"teal-on-paper": {"bg":"#f7f9f8","surface":"#ebf0ee","ink":"#06525c","muted":"#2f737d","accent":"#06525c","accent-ink":"#f7f9f8","action":"#06525c","action-ink":"#f7f9f8","inv-bg":"#06525c","inv-surface":"#205d66","inv-ink":"#f7f9f8","inv-muted":"#c1d0d2","inv-accent":"#f7f9f8","inv-accent-ink":"#06525c","inv-action":"#f7f9f8","inv-action-ink":"#06525c"},
|
||||
"navy-pink": {"bg":"#1a2039","surface":"#212846","ink":"#f9a3c0","muted":"#b9879c","accent":"#f9a3c0","accent-ink":"#1a2039","action":"#f9a3c0","action-ink":"#1a2039","inv-bg":"#f9a3c0","inv-surface":"#e899b6","inv-ink":"#1a2039","inv-muted":"#483c56","inv-accent":"#1a2039","inv-accent-ink":"#f9a3c0","inv-action":"#1a2039","inv-action-ink":"#f9a3c0"},
|
||||
"blush-aubergine": {"bg":"#edccbe","surface":"#e8c2b2","ink":"#351a2b","muted":"#6c4857","accent":"#351a2b","accent-ink":"#edccbe","action":"#351a2b","action-ink":"#edccbe","inv-bg":"#351a2b","inv-surface":"#402534","inv-ink":"#edccbe","inv-muted":"#b2938f","inv-accent":"#edccbe","inv-accent-ink":"#351a2b","inv-action":"#edccbe","inv-action-ink":"#351a2b"},
|
||||
"cobalt-on-sand": {"bg":"#e6dbc7","surface":"#ddd0b9","ink":"#062b8a","muted":"#3253a4","accent":"#062b8a","accent-ink":"#e6dbc7","action":"#062b8a","action-ink":"#e6dbc7","inv-bg":"#062b8a","inv-surface":"#153a90","inv-ink":"#e6dbc7","inv-muted":"#aab0bc","inv-accent":"#e6dbc7","inv-accent-ink":"#062b8a","inv-action":"#e6dbc7","inv-action-ink":"#062b8a"},
|
||||
"oxblood-grey": {"bg":"#e2e2e2","surface":"#d6d6d6","ink":"#7d0c14","muted":"#7a4a4e","accent":"#7d0c14","accent-ink":"#e2e2e2","action":"#7d0c14","action-ink":"#e2e2e2","inv-bg":"#7d0c14","inv-surface":"#862323","inv-ink":"#e2e2e2","inv-muted":"#d0b3af","inv-accent":"#e2e2e2","inv-accent-ink":"#7d0c14","inv-action":"#e2e2e2","inv-action-ink":"#7d0c14"},
|
||||
"olive-on-bone": {"bg":"#ecebe7","surface":"#e1dfd9","ink":"#38450f","muted":"#5a6537","accent":"#38450f","accent-ink":"#ecebe7","action":"#38450f","action-ink":"#ecebe7","inv-bg":"#38450f","inv-surface":"#435020","inv-ink":"#ecebe7","inv-muted":"#bbbfaf","inv-accent":"#ecebe7","inv-accent-ink":"#38450f","inv-action":"#ecebe7","inv-action-ink":"#38450f"},
|
||||
"burgundy-bubblegum": {"bg":"#3d0012","surface":"#4d0519","ink":"#ff99d3","muted":"#c77aa0","accent":"#ff99d3","accent-ink":"#3d0012","action":"#ff99d3","action-ink":"#3d0012","inv-bg":"#ff99d3","inv-surface":"#f08dc4","inv-ink":"#3d0012","inv-muted":"#6a263c","inv-accent":"#3d0012","inv-accent-ink":"#ff99d3","inv-action":"#3d0012","inv-action-ink":"#ff99d3"},
|
||||
"bottle-green-gold": {"bg":"#133621","surface":"#1a4429","ink":"#f4d96c","muted":"#b8a95f","accent":"#f4d96c","accent-ink":"#133621","action":"#f4d96c","action-ink":"#133621","inv-bg":"#f4d96c","inv-surface":"#e3cd67","inv-ink":"#133621","inv-muted":"#495c33","inv-accent":"#133621","inv-accent-ink":"#f4d96c","inv-action":"#133621","inv-action-ink":"#f4d96c"},
|
||||
"cobalt-field": {"bg":"#0034eb","surface":"#002fd6","ink":"#ffffff","muted":"#dfe4f2","accent":"#e9e34a","accent-ink":"#111111","action":"#e9e34a","action-ink":"#111111","inv-bg":"#ffffff","inv-surface":"#edf3ff","inv-ink":"#0034eb","inv-muted":"#265ff1","inv-accent":"#0034eb","inv-accent-ink":"#ffffff","inv-action":"#0034eb","inv-action-ink":"#ffffff"},
|
||||
"mustard-field": {"bg":"#e1ac10","surface":"#cd9d0f","ink":"#0b0b0b","muted":"#42350d","accent":"#24338f","accent-ink":"#ffffff","action":"#24338f","action-ink":"#ffffff","inv-bg":"#0b0b0b","inv-surface":"#17140f","inv-ink":"#e1ac10","inv-muted":"#9f7b1f","inv-accent":"#e1ac10","inv-accent-ink":"#0b0b0b","inv-action":"#e1ac10","inv-action-ink":"#0b0b0b"},
|
||||
"carmine-field": {"bg":"#9c1220","surface":"#8a101c","ink":"#ffffff","muted":"#f0b9bd","accent":"#ffd9dc","accent-ink":"#9c1220","action":"#ffd9dc","action-ink":"#9c1220","inv-bg":"#ffffff","inv-surface":"#faefee","inv-ink":"#9c1220","inv-muted":"#b34846","inv-accent":"#9c1220","inv-accent-ink":"#ffffff","inv-action":"#9c1220","inv-action-ink":"#ffffff"},
|
||||
"pink-field-lime": {"bg":"#ff87ab","surface":"#ff739d","ink":"#0a0a0a","muted":"#631e36","accent":"#def915","accent-ink":"#0a0a0a","action":"#0a0a0a","action-ink":"#ff87ab","inv-bg":"#0a0a0a","inv-surface":"#181213","inv-ink":"#ff87ab","inv-muted":"#b9657d","inv-accent":"#def915","inv-accent-ink":"#0a0a0a","inv-action":"#def915","inv-action-ink":"#0a0a0a"},
|
||||
"sky-field-navy": {"bg":"#6bb4ee","surface":"#7dbdf0","ink":"#0e1822","muted":"#1b3d5c","accent":"#ff5a1f","accent-ink":"#0e1822","action":"#0e1822","action-ink":"#6bb4ee","inv-bg":"#0e1822","inv-surface":"#14212e","inv-ink":"#6bb4ee","inv-muted":"#558fbe","inv-accent":"#ff5a1f","inv-accent-ink":"#0e1822","inv-action":"#ff5a1f","inv-action-ink":"#0e1822"},
|
||||
"orange-field-black": {"bg":"#ff7a1f","surface":"#ff8633","ink":"#050f16","muted":"#5a2306","accent":"#050f16","accent-ink":"#ff7a1f","action":"#050f16","action-ink":"#ff7a1f","inv-bg":"#050f16","inv-surface":"#14171a","inv-ink":"#ff7a1f","inv-muted":"#cc672a","inv-accent":"#ff7a1f","inv-accent-ink":"#050f16","inv-action":"#ff7a1f","inv-action-ink":"#050f16"},
|
||||
"tobacco-olive": {"bg":"#472913","surface":"#55321a","ink":"#e4e0cf","muted":"#b7a48a","accent":"#525e1b","accent-ink":"#e4e0cf","action":"#e4e0cf","action-ink":"#472913","inv-bg":"#c79a3e","inv-surface":"#b88f3c","inv-ink":"#111111","inv-muted":"#332a1e","inv-accent":"#111111","inv-accent-ink":"#c79a3e","inv-action":"#111111","inv-action-ink":"#c79a3e"},
|
||||
"poster-chapters": {"bg":"#e6e0d2","surface":"#dcd5c5","ink":"#1a1a1a","muted":"#5f5b52","accent":"#18bc42","accent-ink":"#1a1a1a","action":"#1a1a1a","action-ink":"#e6e0d2","inv-bg":"#1a1a1a","inv-surface":"#262525","inv-ink":"#e6e0d2","inv-muted":"#918d85","inv-accent":"#18bc42","inv-accent-ink":"#1a1a1a","inv-action":"#18bc42","inv-action-ink":"#1a1a1a"},
|
||||
"blue-red-black": {"bg":"#ffffff","surface":"#f3f3f3","ink":"#0a0a0a","muted":"#6b6b6b","accent":"#1f30e0","accent-ink":"#ffffff","action":"#1f30e0","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#171717","inv-ink":"#ffffff","inv-muted":"#979797","inv-accent":"#ffffff","inv-accent-ink":"#0a0a0a","inv-action":"#ffffff","inv-action-ink":"#0a0a0a"},
|
||||
"charcoal-primaries": {"bg":"#191919","surface":"#242424","ink":"#ffffff","muted":"#9a9a9a","accent":"#0c7866","accent-ink":"#ffffff","action":"#0c7866","action-ink":"#ffffff","inv-bg":"#ffe927","inv-surface":"#edd92c","inv-ink":"#191919","inv-muted":"#5f5a2d","inv-accent":"#0c7866","inv-accent-ink":"#ffffff","inv-action":"#0c7866","inv-action-ink":"#ffffff"},
|
||||
"ultramarine-yellow": {"bg":"#1423b8","surface":"#1220a6","ink":"#ffffff","muted":"#e4e2d6","accent":"#1fc27a","accent-ink":"#0a0a0a","action":"#1fc27a","action-ink":"#0a0a0a","inv-bg":"#f7dd47","inv-surface":"#e5d35f","inv-ink":"#1423b8","inv-muted":"#2c52b4","inv-accent":"#1423b8","inv-accent-ink":"#f7dd47","inv-action":"#1423b8","inv-action-ink":"#f7dd47"},
|
||||
"candy-chapters": {"bg":"#ffd249","surface":"#f5c63c","ink":"#0a0a0a","muted":"#5e4a0a","accent":"#f53fd6","accent-ink":"#0a0a0a","action":"#f53fd6","action-ink":"#0a0a0a","inv-bg":"#0a0a0a","inv-surface":"#171510","inv-ink":"#ffd249","inv-muted":"#977e34","inv-accent":"#f53fd6","inv-accent-ink":"#0a0a0a","inv-action":"#f53fd6","inv-action-ink":"#0a0a0a"},
|
||||
"orange-sky-lime": {"bg":"#ff8126","surface":"#ff8d3a","ink":"#111111","muted":"#5c2a05","accent":"#de194b","accent-ink":"#ffffff","action":"#111111","action-ink":"#ff8126","inv-bg":"#161615","inv-surface":"#241d18","inv-ink":"#ff8126","inv-muted":"#d06d29","inv-accent":"#de194b","inv-accent-ink":"#ffffff","inv-action":"#de194b","inv-action-ink":"#ffffff"},
|
||||
"post-red-mustard": {"bg":"#e2dccf","surface":"#d7d0c1","ink":"#2a1110","muted":"#6b524c","accent":"#4a6f84","accent-ink":"#ffffff","action":"#4a6f84","action-ink":"#ffffff","inv-bg":"#2a1110","inv-surface":"#361d1b","inv-ink":"#e2dccf","inv-muted":"#9e8e85","inv-accent":"#4a6f84","inv-accent-ink":"#ffffff","inv-action":"#4a6f84","inv-action-ink":"#ffffff"},
|
||||
"mint-orange-grey": {"bg":"#ffffff","surface":"#f5f5f5","ink":"#000000","muted":"#666666","accent":"#a7ff9c","accent-ink":"#000000","action":"#000000","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#a7ff9c","inv-accent-ink":"#000000","inv-action":"#a7ff9c","inv-action-ink":"#000000"},
|
||||
"report-blocks": {"bg":"#ffffff","surface":"#f6f6f6","ink":"#000000","muted":"#5f5f5f","accent":"#fe7141","accent-ink":"#000000","action":"#fe7141","action-ink":"#000000","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#ffffff","inv-muted":"#868686","inv-accent":"#fe7141","inv-accent-ink":"#000000","inv-action":"#fe7141","inv-action-ink":"#000000"},
|
||||
"card-pastels": {"bg":"#f7f7f5","surface":"#ebebe8","ink":"#000000","muted":"#666662","accent":"#d0106f","accent-ink":"#ffffff","action":"#d0106f","action-ink":"#ffffff","inv-bg":"#000000","inv-surface":"#010101","inv-ink":"#f7f7f5","inv-muted":"#828280","inv-accent":"#d0106f","inv-accent-ink":"#ffffff","inv-action":"#d0106f","inv-action-ink":"#ffffff"},
|
||||
"mint-pink-accounts": {"bg":"#f8f8f8","surface":"#eeeeee","ink":"#0a0a0a","muted":"#6a6a6a","accent":"#1f6b3a","accent-ink":"#ffffff","action":"#1f6b3a","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#171717","inv-ink":"#f8f8f8","inv-muted":"#939393","inv-accent":"#1f6b3a","inv-accent-ink":"#ffffff","inv-action":"#1f6b3a","inv-action-ink":"#ffffff"},
|
||||
"swap-pastels": {"bg":"#c3d7e5","surface":"#d0e0eb","ink":"#141414","muted":"#4a5068","accent":"#0f5257","accent-ink":"#ffffff","action":"#0f5257","action-ink":"#ffffff","inv-bg":"#141414","inv-surface":"#1e1f20","inv-ink":"#c3d7e5","inv-muted":"#808b94","inv-accent":"#c3d7e5","inv-accent-ink":"#141414","inv-action":"#c3d7e5","inv-action-ink":"#141414"},
|
||||
"blush-sage-slate": {"bg":"#a6b3bc","surface":"#b2bdc5","ink":"#1c1c1c","muted":"#38424a","accent":"#2c3620","accent-ink":"#e3e2da","action":"#2c3620","action-ink":"#e3e2da","inv-bg":"#2c3620","inv-surface":"#39422d","inv-ink":"#ffffff","inv-muted":"#a8ada3","inv-accent":"#ffffff","inv-accent-ink":"#2c3620","inv-action":"#ffffff","inv-action-ink":"#2c3620"},
|
||||
"candy-on-black": {"bg":"#000000","surface":"#0d0d0d","ink":"#ffffff","muted":"#999999","accent":"#93f1f7","accent-ink":"#000000","action":"#93f1f7","action-ink":"#000000","inv-bg":"#9ae187","inv-surface":"#8bcc7a","inv-ink":"#000000","inv-muted":"#24391e","inv-accent":"#000000","inv-accent-ink":"#9ae187","inv-action":"#000000","inv-action-ink":"#9ae187"},
|
||||
"sky-sand": {"bg":"#cbe4f2","surface":"#bcdcee","ink":"#0a0a0a","muted":"#44606e","accent":"#b0298f","accent-ink":"#ffffff","action":"#b0298f","action-ink":"#ffffff","inv-bg":"#0a0a0a","inv-surface":"#151616","inv-ink":"#cbe4f2","inv-muted":"#79878f","inv-accent":"#b0298f","inv-accent-ink":"#ffffff","inv-action":"#b0298f","inv-action-ink":"#ffffff"},
|
||||
"studio-grey": {"bg":"#a8a8aa","surface":"#b4b4b6","ink":"#0e0e0e","muted":"#3a3a3c","accent":"#1f5c49","accent-ink":"#ffffff","action":"#1f5c49","action-ink":"#ffffff","inv-bg":"#1b1b1c","inv-surface":"#232324","inv-ink":"#a8a8aa","inv-muted":"#8c8c8e","inv-accent":"#a8a8aa","inv-accent-ink":"#1b1b1c","inv-action":"#a8a8aa","inv-action-ink":"#1b1b1c"},
|
||||
"walnut-tan": {"bg":"#2b2216","surface":"#382d1e","ink":"#e4e2da","muted":"#a39a8b","accent":"#bda38b","accent-ink":"#2b2216","action":"#bda38b","action-ink":"#2b2216","inv-bg":"#bda38b","inv-surface":"#af9781","inv-ink":"#111111","inv-muted":"#36312c","inv-accent":"#111111","inv-accent-ink":"#bda38b","inv-action":"#111111","inv-action-ink":"#bda38b"}
|
||||
};
|
||||
(() => {
|
||||
const q = new URLSearchParams(location.search), root = document.documentElement;
|
||||
const id = q.get('palette') || 'white-link-blue';
|
||||
for (const [k, v] of Object.entries(PALETTES[id] || PALETTES['white-link-blue'])) root.style.setProperty('--p-' + k, v);
|
||||
root.dataset.ground = q.get('ground') || 'sand';
|
||||
if (q.get('radius')) root.dataset.radius = q.get('radius');
|
||||
if (q.get('tone')) root.style.setProperty('--tone', '#' + q.get('tone')); // e.g. ?tone=c8f2e6&toneink=0f2d27: a second brand tone
|
||||
if (q.get('toneink')) root.style.setProperty('--tone-ink', '#' + q.get('toneink'));
|
||||
root.dataset.palette = id;
|
||||
addEventListener('DOMContentLoaded', () => {
|
||||
if (q.get('bar') === '0') document.body.classList.add('no-bar');
|
||||
if (q.get('labels') === '0') document.body.classList.add('no-labels');
|
||||
const only = q.get('only');
|
||||
if (only) { const list = only.split(','); const main = document.querySelector('main'); main.style.display = 'flex'; main.style.flexDirection = 'column'; document.querySelectorAll('[data-g]').forEach(el => { const i = list.indexOf(el.dataset.g); if (i < 0) el.hidden = true; else el.style.order = i; }); }
|
||||
const bar = document.querySelector('.demo-bar');
|
||||
if (bar) {
|
||||
const sel = bar.querySelector('select[name=palette]');
|
||||
Object.keys(PALETTES).forEach(k => sel.add(new Option(k, k, false, k === id)));
|
||||
const gsel = bar.querySelector('select[name=ground]');
|
||||
gsel.value = root.dataset.ground;
|
||||
bar.addEventListener('change', () => { const u = new URL(location); u.searchParams.set('palette', sel.value); u.searchParams.set('ground', gsel.value); location = u; });
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="backgrounds.js"></script>
|
||||
</head>
|
||||
<body class="bg-paper">
|
||||
<div class="demo-bar"><span>palette</span><select name="palette"></select><span>lit</span><select name="ground"><option>sand</option><option>sky</option><option>sage</option><option>painting</option></select></div>
|
||||
|
||||
<main>
|
||||
<!-- 6 topglow: on a wrapper here (on <body> with bg-paper in a real page): the first screen, under the nav -->
|
||||
<div class="bg-topglow" data-g="topglow">
|
||||
<span class="demo-tag">topglow</span>
|
||||
<header class="nav">
|
||||
<div class="container nav__bar">
|
||||
<a class="nav__logo" href="#">Northgate</a>
|
||||
<nav class="nav__links" aria-label="Main">
|
||||
<div class="nav__item"><a class="nav__link" href="#">Product</a></div>
|
||||
<div class="nav__item"><a class="nav__link" href="#">Solutions</a></div>
|
||||
<div class="nav__item"><a class="nav__link" href="#">Pricing</a></div>
|
||||
<div class="nav__item"><a class="nav__link" href="#">Docs</a></div>
|
||||
</nav>
|
||||
<div class="nav__actions"><a class="btn btn--secondary btn--sm" href="#">Log in</a><a class="btn btn--primary btn--sm" href="#">Start free</a></div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="section section--hero" style="padding-bottom: 72px">
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center" style="margin-bottom: 48px">
|
||||
<span class="pill">New <span class="muted">Shift swaps are here</span></span>
|
||||
<h1>Schedules your team can trust</h1>
|
||||
<p>Build the weekly rota in minutes, let people swap shifts on their phone, and see who is on at any hour.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Book a demo</a></div>
|
||||
</div>
|
||||
<div class="mini" style="max-width: 980px; margin-inline: auto; padding: 20px">
|
||||
<h4>Week 14 <span>Kitchen, 12 people</span></h4>
|
||||
<div class="week">
|
||||
<div class="h"></div><div class="h">Mon</div><div class="h">Tue</div><div class="h">Wed</div><div class="h">Thu</div><div class="h">Fri</div>
|
||||
<div class="n">Ana</div><div class="c">07:00 – 15:00</div><div class="c">07:00 – 15:00</div><div></div><div class="c">11:00 – 19:00</div><div class="c">11:00 – 19:00</div>
|
||||
<div class="n">Jonas</div><div></div><div class="c">15:00 – 23:00</div><div class="c">15:00 – 23:00</div><div class="c">15:00 – 23:00</div><div></div>
|
||||
<div class="n">Mira</div><div class="c">11:00 – 19:00</div><div></div><div class="c">07:00 – 15:00</div><div></div><div class="c">07:00 – 15:00</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 1 mist -->
|
||||
<section class="section bg-mist" data-g="mist">
|
||||
<span class="demo-tag">mist</span>
|
||||
<div class="container">
|
||||
<div class="section-head"><h2>One place for the rota, the swaps and the hours</h2><p>Managers plan the week. Staff see their shifts, trade them and clock in. Payroll gets the numbers without anyone retyping them.</p></div>
|
||||
<div class="grid">
|
||||
<article class="card span-4"><h3>Plan the week</h3><p>Drag people into shifts. Northgate warns you about overtime and missing cover before you publish.</p>
|
||||
<div class="mini" style="margin-top: 20px"><div class="rowline"><span class="av">A</span><div><b>Ana</b><small>Mon to Wed, 07:00</small></div><span class="tag on">Set</span></div><div class="rowline"><span class="av">J</span><div><b>Jonas</b><small>Tue to Thu, 15:00</small></div><span class="tag">Open</span></div></div></article>
|
||||
<article class="card span-4"><h3>Swap in two taps</h3><p>Staff ask a colleague to take a shift. The manager only approves what breaks a rule.</p>
|
||||
<div class="mini stack" style="margin-top: 20px"><div class="bubble">Can anyone take my Thursday close?</div><div class="bubble me">I can. Mira</div></div></article>
|
||||
<article class="card span-4"><h3>Hours that add up</h3><p>Clock-in times roll into one sheet per person, ready for payroll at month end.</p>
|
||||
<div class="mini" style="margin-top: 20px"><h4>March <span>Total</span></h4><div class="rowline"><span class="av">M</span><div><b>Mira</b></div><span>148 h</span></div><div class="rowline"><span class="av">A</span><div><b>Ana</b></div><span>162 h</span></div></div></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 grain -->
|
||||
<section class="section bg-grain" data-g="grain">
|
||||
<span class="demo-tag">grain</span>
|
||||
<div class="container grid grid--middle">
|
||||
<div class="span-5">
|
||||
<div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Rules</span><h2>Say the rules once, and the rota follows them</h2><p>Minimum rest, maximum hours, who may close alone. Northgate checks every change against them.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Set up rules</a><a class="link" href="#">Read the guide</a></div></div>
|
||||
</div>
|
||||
<div class="span-6 start-7">
|
||||
<div class="mini" style="max-width: 440px; margin-left: auto"><h4>Rest rules <span>Kitchen</span></h4>
|
||||
<div class="sw">Minimum 11 hours between shifts<i class="on"></i></div><div class="sw">No more than 5 days in a row<i class="on"></i></div><div class="sw">Two people to close<i></i></div><div class="sw">Warn about overtime<i class="on"></i></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 halo -->
|
||||
<section class="section bg-halo" data-g="halo">
|
||||
<span class="demo-tag">halo</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center" style="margin-bottom: 0">
|
||||
<span class="t-label muted">Shift swaps</span>
|
||||
<h2 style="max-width: 18ch">Cover a missed shift before the day starts</h2>
|
||||
<p>When someone calls in sick, Northgate messages the people who can take the shift and fills it with the first yes.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary btn--arrow" href="#">See how swaps work</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 13 horizon -->
|
||||
<section class="section bg-horizon" data-g="horizon">
|
||||
<span class="demo-tag">horizon</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center" style="margin-bottom: 0">
|
||||
<span class="pill">Security</span>
|
||||
<h2 style="max-width: 20ch">Staff data stays where it belongs</h2>
|
||||
<p>Hosted in the EU, encrypted at rest, and every change to a rota is logged with who made it.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Read the security page</a><a class="btn btn--secondary" href="#">Contact sales</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 paper: the page ground is on <body> (class bg-paper) and sets no tokens; this wrapper only carries the label -->
|
||||
<div data-g="paper" style="position: relative">
|
||||
<span class="demo-tag">paper (on body)</span>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-head"><h2>From first rota to full payroll in a week</h2></div>
|
||||
<ol class="grid steps">
|
||||
<li class="span-4"><span class="step-num">1</span><h3 class="t-card">Add your people</h3><p>Paste a list or import it from your payroll tool. Roles and contracts come with it.</p></li>
|
||||
<li class="span-4"><span class="step-num">2</span><h3 class="t-card">Build the first week</h3><p>Start from last month's pattern, change what is different, and publish.</p></li>
|
||||
<li class="span-4"><span class="step-num">3</span><h3 class="t-card">Send hours to payroll</h3><p>Approved hours export as one file, in the format your payroll tool takes.</p></li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 5 dusk -->
|
||||
<section class="section bg-dusk" data-g="dusk">
|
||||
<span class="demo-tag">dusk</span>
|
||||
<div class="container grid grid--middle">
|
||||
<figure class="quote span-7" style="margin: 0"><blockquote>"We used to lose a Sunday every month to the rota. Now it takes twenty minutes, and nobody argues about the hours."</blockquote>
|
||||
<figcaption class="person" style="margin-top: 24px"><span class="avatar">LM</span><span>Lena Moser, operations lead at a restaurant group</span></figcaption></figure>
|
||||
<div class="span-4 start-9 stat-aside"><div class="t-stat">64%</div><p class="muted t-small" style="margin-top: 8px">fewer scheduling messages after the first month, across 2,400 teams</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 ruled -->
|
||||
<section class="section bg-ruled" data-g="ruled">
|
||||
<span class="demo-tag">ruled</span>
|
||||
<div class="container grid grid--middle">
|
||||
<div class="span-5"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Hours</span><h2>One sheet per person, checked every night</h2><p>Clock-in times roll into a sheet for each person, and anything off the rules waits for a manager before payroll sees it.</p></div></div>
|
||||
<div class="span-6 start-7"><div class="mini" style="max-width: 460px; margin-left: auto"><h4>Mira <span>March</span></h4><div class="rowline"><span class="av">W</span><div><b>Week 10</b><small>5 shifts, no flags</small></div><span>38 h</span></div><div class="rowline"><span class="av">W</span><div><b>Week 11</b><small>5 shifts, 1 flag</small></div><span>41 h</span></div><div class="rowline"><span class="av">W</span><div><b>Week 12</b><small>4 shifts, no flags</small></div><span>31 h</span></div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 band -->
|
||||
<section class="section bg-band" data-g="band">
|
||||
<span class="demo-tag">band</span>
|
||||
<div class="container grid grid--middle">
|
||||
<div class="span-6"><div class="big-stat">11 h</div><h2 style="margin-top: 20px; max-width: 16ch">saved on every rota, every month</h2><p class="t-lead" style="margin-top: 16px; max-width: 44ch">The average across the first hundred teams on Northgate. They plan once and let the rules do the checking.</p><div class="btn-row" style="margin-top: 28px"><a class="btn btn--primary btn--arrow" href="#">Read the study</a></div></div>
|
||||
<div class="span-5 start-8"><div class="mini"><h4>This month <span>Saved</span></h4><div class="rowline"><span class="av">K</span><div><b>Kitchen</b><small>12 people</small></div><span>9 h</span></div><div class="rowline"><span class="av">B</span><div><b>Bar</b><small>7 people</small></div><span>6 h</span></div><div class="rowline"><span class="av">F</span><div><b>Front of house</b><small>14 people</small></div><span>14 h</span></div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 dots -->
|
||||
<section class="section bg-dots" data-g="dots">
|
||||
<span class="demo-tag">dots</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center"><h2>Used every day by teams of every size</h2><p>From a single kitchen to a hospital with forty wards.</p></div>
|
||||
<div class="stats4 center" style="text-align: center">
|
||||
<div><b>2,400</b><span class="muted t-small">teams</span></div><div><b>61,000</b><span class="muted t-small">people on a rota</span></div><div><b>1.9 M</b><span class="muted t-small">shifts a month</span></div><div><b>4.8</b><span class="muted t-small">average app rating</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 11 field -->
|
||||
<section class="section bg-field bg-inset" data-g="field">
|
||||
<span class="demo-tag">field</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center"><h2>Built for the shift you actually run</h2><p>Split shifts, on-call days, a floating pool. Set each one up the way your team works.</p></div>
|
||||
<div class="grid grid--middle" style="max-width: 920px; margin-inline: auto">
|
||||
<div class="mini span-6"><h4>Shift pattern <span>Bar</span></h4><div class="sw">Split shift<i class="on"></i></div><div class="sw">On-call weekends<i></i></div><div class="sw">Floating pool<i class="on"></i></div></div>
|
||||
<div class="mini span-6"><h4>This week <span>Bar</span></h4><div class="rowline"><span class="av">L</span><div><b>Leo</b><small>Split, 11:00 and 18:00</small></div><span class="tag on">On</span></div><div class="rowline"><span class="av">S</span><div><b>Sofia</b><small>On call, Sat</small></div><span class="tag">Off</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 12 stage: a vivid panel for a visual; every word on it sits on a card -->
|
||||
<section class="section" data-g="stage">
|
||||
<span class="demo-tag">stage</span>
|
||||
<div class="container grid grid--middle">
|
||||
<div class="span-5"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Time clock</span><h2>Clock in from the phone, at the door</h2><p>People clock in where they work. The time lands on the rota next to the shift they were planned for.</p><div class="btn-row"><a class="btn btn--primary" href="#">See the app</a><a class="link" href="#">Download</a></div></div></div>
|
||||
<div class="span-6 start-7"><div class="bg-stage" style="min-height: 400px">
|
||||
<div class="mini" style="--stage-w: 420px"><h4>Today <span>Kitchen</span></h4><div class="rowline"><span class="av">A</span><div><b>Ana</b><small>In at 06:58</small></div><span class="tag on">On</span></div><div class="rowline"><span class="av">J</span><div><b>Jonas</b><small>Starts 15:00</small></div><span class="tag">Later</span></div><div class="rowline"><span class="av">M</span><div><b>Mira</b><small>Day off</small></div><span class="tag">Off</span></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 band, tinted: the second tone is a pale tint of the accent -->
|
||||
<section class="section bg-band bg-tint" data-g="band-tint">
|
||||
<span class="demo-tag">band + tint</span>
|
||||
<div class="container grid grid--middle">
|
||||
<div class="span-6"><div class="section-head" style="margin-bottom: 0"><span class="t-label muted">Payroll</span><h2>Hours go to payroll without a spreadsheet</h2><p>Approved hours export in the format your payroll tool takes: one file, checked against every contract.</p><div class="btn-row"><a class="btn btn--primary" href="#">See the export</a></div></div></div>
|
||||
<div class="span-5 start-8"><div class="mini"><h4>March export <span>Ready</span></h4><div class="rowline"><span class="av">K</span><div><b>Kitchen</b><small>12 people, 1,480 h</small></div><span class="tag on">Sent</span></div><div class="rowline"><span class="av">B</span><div><b>Bar</b><small>7 people, 760 h</small></div><span class="tag">Check</span></div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 12 photo -->
|
||||
<section class="section bg-photo bg-inset" data-g="photo" style="--g-photo-pos-m: 88% 40%">
|
||||
<img class="bg-photo__img" src="https://picsum.photos/id/853/2000/1300" alt="A lone rock in a calm blue sea beneath a pastel sky" loading="lazy" decoding="async">
|
||||
<span class="demo-tag">photo</span>
|
||||
<div class="container">
|
||||
<div style="max-width: 560px; display: grid; gap: 16px; justify-items: start">
|
||||
<h2>Made by a small team on the coast</h2>
|
||||
<p class="t-lead">Fourteen people in two offices write Northgate, answer every message themselves and take their own rota seriously.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Meet the team</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 15 liquid -->
|
||||
<section class="section bg-liquid" data-g="liquid">
|
||||
<span class="demo-tag">liquid</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center" style="margin-bottom: 0">
|
||||
<h2 style="max-width: 16ch">Try this week's rota for free</h2>
|
||||
<p>No card, no setup call. Add your team and publish the first week today.</p>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Talk to us</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 14 deep -->
|
||||
<section class="section bg-deep" data-g="deep">
|
||||
<span class="demo-tag">deep</span>
|
||||
<div class="container">
|
||||
<div class="section-head section-head--center"><h2>Ask the rota a question</h2><p>"Who can close on Friday?" Northgate answers from the rules and the calendar, and offers to fill the gap.</p></div>
|
||||
<div class="grid" style="max-width: 920px; margin-inline: auto">
|
||||
<div class="mini span-6 stack"><h4>Assistant <span>Today</span></h4><div class="bubble me">Who can close the bar on Friday?</div><div class="bubble">Leo and Sofia. Leo has 11 h rest, Sofia is on call. Want me to offer it to Leo?</div></div>
|
||||
<div class="mini span-6"><h4>Friday <span>Bar</span></h4><div class="rowline"><span class="av">L</span><div><b>Leo</b><small>Rested, 11 h</small></div><span class="tag on">Offer</span></div><div class="rowline"><span class="av">S</span><div><b>Sofia</b><small>On call</small></div><span class="tag">Skip</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 9 arcs: the close -->
|
||||
<section class="section bg-arcs" data-g="arcs">
|
||||
<span class="demo-tag">arcs</span>
|
||||
<div class="container">
|
||||
<div class="cta-type">
|
||||
<h2>Plan the next week in ten minutes</h2>
|
||||
<div class="btn-row"><a class="btn btn--primary" href="#">Start free</a><a class="btn btn--secondary" href="#">Book a demo</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="footer"><div class="container"><p class="credits">Photograph: Stephen Radford, Unsplash License.</p></div></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,794 @@
|
||||
{
|
||||
"about": "16 section and page grounds drawn in CSS (backgrounds.css). One class on a section, a wrapper, body or a visual's panel. The twelve section grounds set the page tokens for their children; the two page grounds (paper, topglow) set none and only paint. Each works on light and dark palettes and keeps ink 7:1 and muted 4.5:1 on its text zone (check.mjs measures it). pick.mjs deals a page's grounds (at most 2 beyond the flat tokens, no page ground; paper and grain are marked dealt:false and never dealt, since a ground with grain is the page's one effect; --effect also skips the grounds marked texture), for software, tech and gaming pages only (VI-41, VI-42): a product, service or place page is flat and no ground is listed for it (a product page with a technical subject asks with --kind tech and is dealt as software). Grounds list the sections they fit (sections): pick.mjs deals those; strict ones go nowhere else, byRole ones (band, field) also fit by role when the plan has none of their ids. Grounds marked coloured (band, field, stage; grain is never dealt) count toward the one coloured ground every page of five sections or more holds.",
|
||||
"version": 6,
|
||||
"weights": {
|
||||
"1": "calm: reads as the page",
|
||||
"2": "medium: a visible change of ground",
|
||||
"3": "loud: the one section a screen remembers"
|
||||
},
|
||||
"kinds": {
|
||||
"soft": "paints no colour of its own and fades into the page at the top and bottom; neighbours anything",
|
||||
"hard": "a band with its own colour and edges (add bg-inset for a rounded panel on the grid)",
|
||||
"page": "a ground for the whole page (body or one wrapper), not for one section",
|
||||
"panel": "a rounded panel behind one visual (bg-stage): vivid, with no text of its own; every word on it sits on a card"
|
||||
},
|
||||
"roles": [
|
||||
"hero",
|
||||
"body",
|
||||
"proof",
|
||||
"band",
|
||||
"close",
|
||||
"footer"
|
||||
],
|
||||
"modifiers": {
|
||||
"bg-inset": "a hard ground as a rounded panel whose left and right edges sit on the container's",
|
||||
"bg-photo--left": "the photograph's scrim from the left, for words in the middle of the left side",
|
||||
"bg-photo--light": "the scrim from the light side and dark words, for a pale photograph",
|
||||
"bg-tint": "with bg-band or bg-field: the second tone is a pale tint of the accent's own colour (a mint for a teal accent) instead of the lit ground, so the band is coloured on a sand page",
|
||||
"bg-bleed": "on a dark page a band or a field is inset automatically (a pale slab is the brightest thing there); bg-bleed keeps it full-bleed"
|
||||
},
|
||||
"knobs": {
|
||||
"--g-hue": "the colour the tints lean to; default the accent with a hue guard (never violet or lavender)",
|
||||
"--g-k": "strength of the tints, 1 by default",
|
||||
"--tone / --tone-ink": "the second brand tone and its text colour for bg-band and bg-field; default the lit ground",
|
||||
"--g-photo": "url(...) for bg-photo when no <img class=\"bg-photo__img\"> child is used",
|
||||
"--g-screen": "a ground section's minimum height (default min(88svh, 860px) for a hard ground, min(76svh, 760px) for a soft one); 0px = the words' own height",
|
||||
"--g-photo-pos": "where the subject of the photograph sits (object-position); --g-photo-pos-m on a phone",
|
||||
"--scrim-h": "how high the photograph's scrim stays deep: backgrounds.js measures the words and sets it; a value the section sets is kept"
|
||||
},
|
||||
"grounds": [
|
||||
{
|
||||
"id": "mist",
|
||||
"class": "bg-mist",
|
||||
"name": "Mist",
|
||||
"kind": "soft",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "airy, soft colour fields like a wash of light in two neighbouring hues",
|
||||
"where": "a section head over a row of three open cards; a feature list; any section that needs a little colour",
|
||||
"roles": [
|
||||
"hero",
|
||||
"body"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"grain",
|
||||
"band",
|
||||
"horizon",
|
||||
"photo",
|
||||
"stage"
|
||||
],
|
||||
"avoids": [
|
||||
"halo",
|
||||
"topglow",
|
||||
"liquid",
|
||||
"dusk"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "halo",
|
||||
"class": "bg-halo",
|
||||
"name": "Halo",
|
||||
"kind": "soft",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "calm, one light behind one thought",
|
||||
"where": "a centred statement or a closing line: a heading, a few lines and a button. Move it with --halo-x and --halo-y to where the heading sits",
|
||||
"roles": [
|
||||
"hero",
|
||||
"body",
|
||||
"close"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"ruled",
|
||||
"dots",
|
||||
"grain",
|
||||
"horizon",
|
||||
"photo",
|
||||
"stage"
|
||||
],
|
||||
"avoids": [
|
||||
"mist",
|
||||
"liquid",
|
||||
"arcs"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "grain",
|
||||
"class": "bg-grain",
|
||||
"name": "Grain",
|
||||
"kind": "hard",
|
||||
"weight": 2,
|
||||
"animated": false,
|
||||
"mood": "tactile, a tinted field with a fine film grain, a light corner and two neighbouring hues",
|
||||
"where": "a split with text on the left and one UI card on the right (the Figma tile); a feature on a field",
|
||||
"roles": [
|
||||
"body",
|
||||
"band"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"mist",
|
||||
"ruled",
|
||||
"halo"
|
||||
],
|
||||
"avoids": [
|
||||
"band",
|
||||
"field",
|
||||
"dusk",
|
||||
"stage"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"coloured": true,
|
||||
"dealt": false
|
||||
},
|
||||
{
|
||||
"id": "paper",
|
||||
"class": "bg-paper",
|
||||
"name": "Paper",
|
||||
"kind": "page",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "quiet, a page ground that is never flat",
|
||||
"where": "on body or one wrapper: fine grain and a faint tint at the top and bottom. Soft grounds are transparent, so it shows through them; hard grounds cover it",
|
||||
"roles": [
|
||||
"page"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"mist",
|
||||
"halo",
|
||||
"grain",
|
||||
"dusk",
|
||||
"ruled",
|
||||
"dots",
|
||||
"band",
|
||||
"photo",
|
||||
"stage"
|
||||
],
|
||||
"avoids": [],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"dealt": false
|
||||
},
|
||||
{
|
||||
"id": "dusk",
|
||||
"class": "bg-dusk",
|
||||
"name": "Dusk",
|
||||
"kind": "hard",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "gentle, one tone becomes the next with no edge: both ends melt into whatever the page paints there",
|
||||
"where": "a quote with one big number, a proof section, any section between two plain ones. Set --g-to for the tone, --g-from and --g-next to melt from or into a colour instead of the page",
|
||||
"roles": [
|
||||
"body",
|
||||
"proof"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"ruled",
|
||||
"halo"
|
||||
],
|
||||
"avoids": [
|
||||
"mist",
|
||||
"grain"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "topglow",
|
||||
"class": "bg-topglow",
|
||||
"name": "Top glow",
|
||||
"kind": "page",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "clean, light under the nav that the page lifts out of",
|
||||
"where": "a wrapper around the nav and the hero: a glow from the top edge and a thin lit line. The hero's ground for software and service pages",
|
||||
"roles": [
|
||||
"hero"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"dusk",
|
||||
"ruled",
|
||||
"grain",
|
||||
"band",
|
||||
"horizon"
|
||||
],
|
||||
"avoids": [
|
||||
"mist",
|
||||
"liquid"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ruled",
|
||||
"class": "bg-ruled",
|
||||
"name": "Ruled",
|
||||
"kind": "soft",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "precise, a grid that is the page's own frame: its first and last rules lie on the container's edges, it fades toward the top and bottom and is clear behind the heading",
|
||||
"where": "a centred hero or statement, a section with one big visual. Never over a table, pricing, a list or any section that draws row rules of its own",
|
||||
"roles": [
|
||||
"hero",
|
||||
"body"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"dusk",
|
||||
"grain",
|
||||
"band",
|
||||
"stage"
|
||||
],
|
||||
"avoids": [
|
||||
"dots",
|
||||
"arcs"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"sections": [
|
||||
"hero-centred",
|
||||
"hero-split",
|
||||
"hero-editorial",
|
||||
"hero-product",
|
||||
"product-statement",
|
||||
"chapter",
|
||||
"features-2x2",
|
||||
"bento",
|
||||
"tabs-tour",
|
||||
"integrations-grid",
|
||||
"calendar-frame",
|
||||
"split-text-left",
|
||||
"split-text-right",
|
||||
"closing-type"
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"id": "dots",
|
||||
"class": "bg-dots",
|
||||
"name": "Dots",
|
||||
"kind": "soft",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "quiet texture: a dot field in the margins and corners, clear behind the heading and the words",
|
||||
"where": "a centred section with a row of figures, a logo row, an integrations section: a dot field and a pool of light behind the heading",
|
||||
"roles": [
|
||||
"body",
|
||||
"proof",
|
||||
"hero"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"band",
|
||||
"horizon",
|
||||
"dusk",
|
||||
"stage"
|
||||
],
|
||||
"avoids": [
|
||||
"ruled",
|
||||
"arcs"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arcs",
|
||||
"class": "bg-arcs",
|
||||
"name": "Arcs",
|
||||
"kind": "soft",
|
||||
"weight": 1,
|
||||
"animated": false,
|
||||
"mood": "open, rings that rise from below like x.ai's footer",
|
||||
"where": "the closing call to action, or the section above the footer: a heading and a button over rings",
|
||||
"roles": [
|
||||
"close",
|
||||
"footer"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"closing-type"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"mist",
|
||||
"band",
|
||||
"grain",
|
||||
"photo"
|
||||
],
|
||||
"avoids": [
|
||||
"ruled",
|
||||
"dots",
|
||||
"halo",
|
||||
"horizon",
|
||||
"liquid"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"id": "band",
|
||||
"class": "bg-band",
|
||||
"name": "Band",
|
||||
"kind": "hard",
|
||||
"weight": 3,
|
||||
"animated": false,
|
||||
"mood": "warm, a coloured band in the page's second tone, lit from one corner and deeper in the other",
|
||||
"where": "one big number and a UI card; one statement with a button. The tone is the lit ground (sand, sky, sage, painting) or --tone. On a dark page it is the one light band",
|
||||
"roles": [
|
||||
"band"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"quote-stat",
|
||||
"product-statement",
|
||||
"split-text-left",
|
||||
"split-text-right",
|
||||
"chapter",
|
||||
"customer-proof-row",
|
||||
"steps",
|
||||
"pricing-three",
|
||||
"pricing-two",
|
||||
"comparison-table",
|
||||
"timeline-rail",
|
||||
"services-list",
|
||||
"people-row",
|
||||
"features-2x2",
|
||||
"integrations-grid",
|
||||
"rooms-list"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"mist",
|
||||
"halo",
|
||||
"dusk",
|
||||
"ruled",
|
||||
"dots"
|
||||
],
|
||||
"avoids": [
|
||||
"field",
|
||||
"grain",
|
||||
"stage"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "band-tint",
|
||||
"class": "bg-band bg-tint",
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"byRole": true,
|
||||
"coloured": true
|
||||
},
|
||||
{
|
||||
"id": "field",
|
||||
"class": "bg-field",
|
||||
"name": "Field",
|
||||
"kind": "hard",
|
||||
"weight": 3,
|
||||
"animated": false,
|
||||
"mood": "rich, a grainy gradient in one family of colour (a peach beside sand, a cyan beside sky) with a light sweep: Figma's sand tile",
|
||||
"where": "a centred heading over one or two UI cards; best as a rounded inset panel (bg-inset)",
|
||||
"roles": [
|
||||
"band",
|
||||
"hero"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"features-2x2",
|
||||
"bento",
|
||||
"tabs-tour",
|
||||
"split-text-left",
|
||||
"split-text-right",
|
||||
"calendar-frame",
|
||||
"integrations-grid",
|
||||
"steps",
|
||||
"pricing-three",
|
||||
"pricing-two",
|
||||
"comparison-table",
|
||||
"timeline-rail",
|
||||
"services-list",
|
||||
"people-row",
|
||||
"hero-centred",
|
||||
"hero-editorial",
|
||||
"chapter",
|
||||
"product-statement"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"ruled",
|
||||
"dots",
|
||||
"dusk"
|
||||
],
|
||||
"avoids": [
|
||||
"band",
|
||||
"grain",
|
||||
"stage"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "field-tint",
|
||||
"class": "bg-field bg-tint",
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"byRole": true,
|
||||
"coloured": true,
|
||||
"texture": true
|
||||
},
|
||||
{
|
||||
"id": "stage",
|
||||
"class": "bg-stage",
|
||||
"name": "Stage",
|
||||
"kind": "panel",
|
||||
"weight": 2,
|
||||
"animated": false,
|
||||
"mood": "vivid, the accent as a grainy mesh that one card floats on: Figma's colour tiles",
|
||||
"where": "the panel behind the one UI card or window beside a section's text (it is a panel, not a section: <div class=\"bg-stage\"><div class=\"mini\">). Never behind a heading: every word on it is on a card. On a strong-colour page (data-tint away) it takes the accent's hue, on a grey accent (data-lowchroma) the lit ground's colour. --stage-w is the card's width",
|
||||
"roles": [
|
||||
"body",
|
||||
"hero"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"hero-split",
|
||||
"hero-cycle",
|
||||
"hero-product",
|
||||
"split-text-left",
|
||||
"split-text-right",
|
||||
"tabs-tour",
|
||||
"calendar-frame",
|
||||
"hero-editorial",
|
||||
"chapter",
|
||||
"product-statement",
|
||||
"integrations-grid"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"mist",
|
||||
"halo",
|
||||
"dusk",
|
||||
"ruled",
|
||||
"dots"
|
||||
],
|
||||
"avoids": [
|
||||
"field",
|
||||
"grain",
|
||||
"band",
|
||||
"liquid"
|
||||
],
|
||||
"textZone": null,
|
||||
"coloured": true,
|
||||
"strict": true,
|
||||
"texture": true
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"class": "bg-photo",
|
||||
"name": "Photo",
|
||||
"kind": "hard",
|
||||
"weight": 3,
|
||||
"animated": false,
|
||||
"mood": "calm and large, a photograph under a quiet scrim",
|
||||
"where": "a media band, the hero of a place or product page, a closing photo. Put the photograph in as <img class=\"bg-photo__img\" src alt> (or --g-photo: url(...) where no img fits); words sit lower left (bg-photo--left: left side). A pale photograph takes bg-photo--light (dark words on a light scrim). Credits go in the footer line",
|
||||
"roles": [
|
||||
"hero",
|
||||
"band",
|
||||
"close"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"product-statement",
|
||||
"quote-stat",
|
||||
"chapter",
|
||||
"closing-type"
|
||||
],
|
||||
"needs": "a vetted photograph with a calm area (scripts/photos.mjs pick)",
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"mist",
|
||||
"dusk"
|
||||
],
|
||||
"avoids": [
|
||||
"horizon",
|
||||
"deep",
|
||||
"liquid"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0.6,
|
||||
0.6,
|
||||
1
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "photo-left",
|
||||
"class": "bg-photo bg-photo--left",
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
0.45,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "photo-light",
|
||||
"class": "bg-photo bg-photo--light",
|
||||
"textZone": [
|
||||
0,
|
||||
0.6,
|
||||
0.6,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "photo-white",
|
||||
"class": "bg-photo",
|
||||
"textZone": [
|
||||
0,
|
||||
0.6,
|
||||
0.6,
|
||||
1
|
||||
],
|
||||
"note": "a white photograph under the dark scrim: the worst case for white words"
|
||||
},
|
||||
{
|
||||
"id": "photo-left-white",
|
||||
"class": "bg-photo bg-photo--left",
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
0.45,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"id": "horizon",
|
||||
"class": "bg-horizon",
|
||||
"name": "Horizon",
|
||||
"kind": "hard",
|
||||
"weight": 3,
|
||||
"animated": false,
|
||||
"mood": "dramatic, a dark band with a lit curve like a planet's rim",
|
||||
"where": "a statement the page is sure of (security, scale, a promise) with a button above the horizon; the page's one dark band. Words stay above the lit curve, whose rim is brightest at the top",
|
||||
"roles": [
|
||||
"band",
|
||||
"close"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"product-statement",
|
||||
"quote-stat",
|
||||
"chapter",
|
||||
"closing-type"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"dusk",
|
||||
"mist",
|
||||
"dots"
|
||||
],
|
||||
"avoids": [
|
||||
"deep",
|
||||
"photo",
|
||||
"arcs"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.58
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"id": "deep",
|
||||
"class": "bg-deep",
|
||||
"name": "Deep",
|
||||
"kind": "hard",
|
||||
"weight": 2,
|
||||
"animated": false,
|
||||
"mood": "quiet and dark, near-black with a cool pool at the top, a lit lip and grain like x.ai",
|
||||
"where": "a feature section with two or three UI cards (chat, code, voice): the dark ground UI reads best on",
|
||||
"roles": [
|
||||
"band",
|
||||
"hero",
|
||||
"close"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"hero-split",
|
||||
"hero-centred",
|
||||
"hero-cycle",
|
||||
"features-2x2",
|
||||
"bento",
|
||||
"tabs-tour",
|
||||
"split-text-left",
|
||||
"split-text-right",
|
||||
"calendar-frame",
|
||||
"integrations-grid",
|
||||
"closing-type"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"halo",
|
||||
"dots",
|
||||
"mist"
|
||||
],
|
||||
"avoids": [
|
||||
"horizon",
|
||||
"photo"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"strict": true,
|
||||
"texture": true
|
||||
},
|
||||
{
|
||||
"id": "liquid",
|
||||
"class": "bg-liquid",
|
||||
"name": "Liquid",
|
||||
"kind": "soft",
|
||||
"weight": 2,
|
||||
"animated": true,
|
||||
"mood": "alive, two slow blobs of two neighbouring hues drifting; transforms only",
|
||||
"where": "a call to action or a hero with a heading and two buttons. One a page; it is the page's moving thing, so no other looping effect in view",
|
||||
"roles": [
|
||||
"hero",
|
||||
"close"
|
||||
],
|
||||
"types": [
|
||||
"software"
|
||||
],
|
||||
"sections": [
|
||||
"hero-centred",
|
||||
"hero-editorial",
|
||||
"hero-split",
|
||||
"closing-type"
|
||||
],
|
||||
"pairs": [
|
||||
"paper",
|
||||
"dusk",
|
||||
"ruled",
|
||||
"dots"
|
||||
],
|
||||
"avoids": [
|
||||
"mist",
|
||||
"halo",
|
||||
"topglow",
|
||||
"arcs",
|
||||
"photo",
|
||||
"stage"
|
||||
],
|
||||
"textZone": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"checkFrames": [
|
||||
"liquid-mid",
|
||||
"liquid-end"
|
||||
],
|
||||
"strict": true,
|
||||
"texture": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
taste-skill backgrounds pick.mjs - deals a page's grounds at random, in a calm rhythm.
|
||||
|
||||
node <skill>/assets/backgrounds/pick.mjs --type software --sections hero-split,logo-row,features-2x2,bento,quote-stat,pricing-three,faq,closing-band
|
||||
[--roles hero,body,body,proof,close] [--effect] [--seed S] [--mode light|dark] [--history .taste-skill/bg-history.json] [--json]
|
||||
|
||||
Grounds are dealt to software, tech and gaming pages only (VI-41, VI-42). --type product, service or place
|
||||
(site-kind other) is answered with plain for every section and no page ground: those pages are flat colour and
|
||||
photographs. A product page with a technical subject says so with --kind tech (or software, gaming) and is dealt as software.
|
||||
|
||||
--sections takes the plan's section ids (nav and footer ids are skipped); a section the rhythm already
|
||||
gives a band keeps it: bento:dark (.ground-inverse), bento:band (lit ground), bento:surface (.ground-surface),
|
||||
and is dealt nothing; --roles takes roles instead
|
||||
(hero, body, proof, close). No page ground is dealt: the page keeps its flat tokens, and every section
|
||||
that is not given a ground stays plain on them (VI-42: at most 2 CSS grounds a page beyond the flat tokens).
|
||||
The grain ground and the paper page ground are never dealt (a ground with grain is the page's one effect,
|
||||
chosen by hand under effects-usage.md); --effect says another effect is already dealt, and the textured
|
||||
grounds (field, stage, deep, liquid) are skipped too. The result is a list: one ground or "plain"
|
||||
per section. A section that brings its own ground (hero-photo, photo-band, closing-photo, closing-band)
|
||||
is kept as it is and counts as a strong neighbour. Also an ES module: import { deal } from './pick.mjs'.
|
||||
|
||||
The rhythm rules (references/backgrounds.md) are checked on every deal, by random attempts until one
|
||||
holds: 2 grounds a page (the cap, VI-42); each ground once; two grounds that list each other in "avoids" never
|
||||
share a page; no two neighbours both strong (a hard ground, or weight 2 or 3); never three dealt
|
||||
grounds in a row (moot at two); at most one loud ground (weight 3) up to six sections, two up to ten; at most two
|
||||
dark ones; a page of five sections or more holds at least one strong ground and at least one coloured
|
||||
one (band, field or stage: never a page of pale washes and dark bands only). A ground used in
|
||||
the last 20 deals is less likely. Grounds that list their sections (index.json) go to those; band and
|
||||
field also fit by role when the plan has none of their ids, so every plan gets its colour. Dusk
|
||||
melts into the page at both ends, so it needs no particular neighbour.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const TYPES = ['software', 'product', 'place', 'service'];
|
||||
const FLAT_TYPES = new Set(['product', 'place', 'service']); // flat pages: no ground is dealt (VI-41)
|
||||
const TECH_KINDS = new Set(['tech', 'software', 'gaming']);
|
||||
const DARK = new Set(['horizon', 'deep', 'photo']);
|
||||
|
||||
/* a section id -> its role, or null when it is skipped, or 'own' when it brings its own ground */
|
||||
export function roleOf(id) {
|
||||
if (/^(nav|footer)/.test(id)) return null;
|
||||
if (/^(hero-photo|photo-band|media-band|closing-photo|closing-band)$/.test(id)) return 'own';
|
||||
if (/^hero/.test(id)) return 'hero';
|
||||
if (/^(closing|close)/.test(id)) return 'close';
|
||||
if (/^(quote|stat|logo|customer-proof|press)/.test(id)) return 'proof';
|
||||
return 'body';
|
||||
}
|
||||
const OWN_LOUD = new Set(['hero-photo', 'photo-band', 'media-band', 'closing-photo']);
|
||||
|
||||
function strHash(s) { let h = 2166136261 >>> 0; for (const ch of String(s)) { h ^= ch.codePointAt(0); h = Math.imul(h, 16777619); } return h >>> 0; }
|
||||
export function random(seed) {
|
||||
let a = strHash(seed) || 1;
|
||||
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
}
|
||||
|
||||
const strong = g => !!g && (g.kind === 'hard' || g.weight >= 2);
|
||||
|
||||
/**
|
||||
index: backgrounds index.json (its "grounds"). opts: { type, kind, sections: [ids] | roles: [roles], seed, history: [ids] }.
|
||||
Returns { seed, page: null, picks: [{ section, role, ground | null, own? }], notes }.
|
||||
*/
|
||||
export function deal(index, opts = {}) {
|
||||
let type = opts.type;
|
||||
if (!TYPES.includes(type)) throw new Error(`pick: --type is one of ${TYPES.join(', ')}`);
|
||||
if (type === 'product' && TECH_KINDS.has(opts.kind)) type = 'software'; // only a technical product page is dealt as tech (VI-41); place and service stay flat
|
||||
const flat = FLAT_TYPES.has(type);
|
||||
const ids = opts.sections && opts.sections.length ? opts.sections : (opts.roles || []);
|
||||
// an id may carry a flag for a section the page's rhythm already gives a ground (sections.md): id:dark is a
|
||||
// .ground-inverse band (strong, dark), id:band a lit-ground band (strong), id:surface a .ground-surface band (calm).
|
||||
// Such a section keeps that ground and is dealt none.
|
||||
const slots = ids.map(id => {
|
||||
const [name, flag = ''] = String(id).split(':'), r = opts.sections ? roleOf(name) : name;
|
||||
return { section: id, name, flag, role: r && ['dark', 'band', 'surface'].includes(flag) ? 'own' : r };
|
||||
}).filter(s => s.role);
|
||||
if (!slots.length) throw new Error('pick: give --sections (the plan\'s ids) or --roles');
|
||||
for (const s of slots) if (!['hero', 'body', 'proof', 'close', 'own'].includes(s.role)) throw new Error(`pick: unknown role "${s.role}" (hero, body, proof, close)`);
|
||||
const n = slots.length;
|
||||
if (flat) { // product, service and place stay flat: plain for every section, no page ground (VI-41)
|
||||
return { seed: String(opts.seed ?? ''), page: null, picks: slots.map(s => ({ section: s.section, role: s.role, ground: null, ...(s.role === 'own' ? { own: true } : {}) })), notes: ['pick: a ' + opts.type + ' page is flat (VI-41): every section stays plain, no page ground.' + (opts.type === 'product' ? ' A technical subject: --kind tech' : '')] };
|
||||
}
|
||||
const byId = new Map(index.grounds.map(g => [g.id, g]));
|
||||
// never dealt: paper (the page ground) and grain (a grain ground is the page's one effect, VI-42); with opts.effect the textured grounds go too
|
||||
const pool = index.grounds.filter(g => g.dealt !== false && g.id !== 'paper' && g.id !== 'grain' && !(opts.effect && g.texture) && g.types.includes(type));
|
||||
const seed = opts.seed != null && opts.seed !== '' ? String(opts.seed) : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const rand = random(seed);
|
||||
const recent = (opts.history || []).slice(-20);
|
||||
const maxLoud = n <= 6 ? 1 : 2;
|
||||
const notes = [];
|
||||
|
||||
// a ground that lists its sections (index.json "sections") goes to those ids. A "strict" one (photo, horizon, deep, liquid,
|
||||
// arcs, stage, ruled) goes nowhere else. A byRole one (band, field) also fits by role when NONE of its listed sections is in
|
||||
// the plan, so a plan without those ids still gets its colour.
|
||||
const listedIn = g => slots.some(s => g.sections.includes(s.name));
|
||||
const fits = (g, slot) => {
|
||||
if (!(opts.sections && g.sections)) return fitsRole(g, slot.role);
|
||||
if (g.sections.includes(slot.name)) return true;
|
||||
return !!g.byRole && !g.strict && !listedIn(g) && fitsRole(g, slot.role);
|
||||
};
|
||||
const fitsRole = (g, role) => role === 'hero' ? g.roles.includes('hero')
|
||||
: role === 'close' ? g.roles.includes('close')
|
||||
: g.roles.some(r => r === 'body' || r === 'band' || r === 'proof') && (role !== 'proof' || g.roles.includes('proof') || g.roles.includes('body'));
|
||||
const weightOf = (g, nbrs, used) => {
|
||||
const uses = recent.filter(x => x === g.id).length;
|
||||
let w = Math.pow(0.35, uses);
|
||||
if (recent.lastIndexOf(g.id) >= recent.length - 5 && recent.includes(g.id)) w *= 0.5;
|
||||
for (const nb of nbrs) if (nb && (g.pairs || []).includes(nb.id)) w *= 1.6;
|
||||
return w;
|
||||
};
|
||||
const choose = (cands, nbrs, used) => {
|
||||
const ws = cands.map(g => weightOf(g, nbrs, used)), tot = ws.reduce((s, x) => s + x, 0);
|
||||
let r = rand() * tot, i = 0;
|
||||
while (i < ws.length - 1 && (r -= ws[i]) > 0) i++;
|
||||
return cands[i];
|
||||
};
|
||||
|
||||
const valid = pl => { // pl: ground or null per slot
|
||||
const dealt = pl.filter(Boolean);
|
||||
if (dealt.length < Math.min(2, n) || dealt.length > 2) return false;
|
||||
if (new Set(dealt.map(g => g.id)).size !== dealt.length) return false;
|
||||
for (const a of dealt) for (const b of dealt) if (a !== b && (a.avoids || []).includes(b.id)) return false;
|
||||
const strongAt = pl.map((g, i) => strong(g) || (slots[i].role === 'own' && slots[i].flag !== 'surface'));
|
||||
for (let i = 1; i < n; i++) if (strongAt[i] && strongAt[i - 1]) return false;
|
||||
// a section with its own ground (not a calm surface band) counts as grounded for the run of three and the page total
|
||||
const occ = pl.map((g, i) => !!g || (slots[i].role === 'own' && slots[i].flag !== 'surface'));
|
||||
for (let i = 2; i < n; i++) if (occ[i] && occ[i - 1] && occ[i - 2]) return false;
|
||||
if (occ.filter(Boolean).length > 5) return false;
|
||||
const loud = pl.filter((g, i) => (g && g.weight === 3) || OWN_LOUD.has(slots[i].name)).length;
|
||||
if (loud > maxLoud) return false;
|
||||
if (dealt.filter(g => DARK.has(g.id)).length + slots.filter(s => s.flag === 'dark').length > 2) return false;
|
||||
if (n >= 5 && !strongAt.some(Boolean)) return false;
|
||||
if (needColour && !hasColour(pl)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// a page of five sections or more holds a coloured ground (band, field, stage) besides the dark ones: the page is
|
||||
// never one pale wash. A band the rhythm already gives (id:band) counts. Relaxed after 450 failed attempts.
|
||||
const COLOURED = new Set(index.grounds.filter(g => g.coloured && g.dealt !== false && g.id !== 'grain').map(g => g.id));
|
||||
let needColour = n >= 5;
|
||||
const hasColour = pl => pl.some(g => g && COLOURED.has(g.id)) || slots.some(s => s.flag === 'band');
|
||||
let plan = null;
|
||||
for (let t = 0; t < 600 && !plan; t++) {
|
||||
if (t === 450 && needColour) { needColour = false; notes.push('pick: no deal with a coloured ground found; the page may be all pale'); }
|
||||
const free = slots.map((s, i) => (s.role === 'own' ? -1 : i)).filter(i => i >= 0);
|
||||
if (free.length < 2) { notes.push('pick: fewer than two sections can take a ground; the page ground only'); break; }
|
||||
const want = Math.min(free.length, 2);
|
||||
const at = new Set();
|
||||
const hero = free.find(i => slots[i].role === 'hero'), close = [...free].reverse().find(i => slots[i].role === 'close');
|
||||
if (hero != null && rand() < 0.8) at.add(hero);
|
||||
if (close != null && close !== hero && rand() < 0.5) at.add(close);
|
||||
while (at.size < want) at.add(free[Math.floor(rand() * free.length)]);
|
||||
const pl = new Array(n).fill(null);
|
||||
let ok = true;
|
||||
for (const i of [...at].sort((a, b) => a - b)) {
|
||||
const used = pl.filter(Boolean);
|
||||
const cands = pool.filter(g => fits(g, slots[i]) &&!used.some(u => u.id === g.id || (u.avoids || []).includes(g.id) || (g.avoids || []).includes(u.id)));
|
||||
if (!cands.length) { ok = false; break; }
|
||||
pl[i] = choose(cands, [pl[i - 1], pl[i + 1]], used);
|
||||
}
|
||||
if (ok && valid(pl)) plan = pl;
|
||||
}
|
||||
if (!plan) { notes.push('pick: no calm deal found; every section stays plain on the page ground'); plan = new Array(n).fill(null); }
|
||||
return { seed, page: null, picks: slots.map((s, i) => ({ section: s.section, role: s.role, ground: plan[i] ? plan[i].id : null, ...(s.role === 'own' ? { own: true } : {}) })), notes };
|
||||
}
|
||||
|
||||
export function readIndex() { return JSON.parse(fs.readFileSync(path.join(HERE, 'index.json'), 'utf8')); }
|
||||
|
||||
/* ---------------------------------------------------------------- command line */
|
||||
// a path compared by its real location, so a symlinked install (install.sh) is the same file
|
||||
const realOf = (p) => { try { return fs.realpathSync(p).toLowerCase(); } catch { return path.resolve(p).toLowerCase(); } };
|
||||
if (process.argv[1] && realOf(process.argv[1]) === realOf(fileURLToPath(import.meta.url))) {
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = args.indexOf('--' + k); return i < 0 ? d : (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true); };
|
||||
const list = k => (opt(k) && opt(k) !== true ? String(opt(k)).split(',').map(s => s.trim()).filter(Boolean) : null);
|
||||
const die = m => { console.error(m); process.exit(1); };
|
||||
const histFile = opt('history') && opt('history') !== true ? opt('history') : null;
|
||||
let hist = {};
|
||||
if (histFile && fs.existsSync(histFile)) { try { hist = JSON.parse(fs.readFileSync(histFile, 'utf8')); } catch { hist = {}; } }
|
||||
if (Array.isArray(hist)) hist = { picks: hist };
|
||||
hist.grounds = Array.isArray(hist.grounds) ? hist.grounds : [];
|
||||
let out;
|
||||
try {
|
||||
out = deal(readIndex(), { type: opt('type'), kind: opt('kind') === true ? undefined : opt('kind'), sections: list('sections'), roles: list('roles'), effect: !!opt('effect'), seed: opt('seed') === true ? undefined : opt('seed'), history: hist.grounds.map(x => x.id) });
|
||||
} catch (e) { die(e.message); }
|
||||
for (const x of out.notes) console.error(x);
|
||||
if (histFile) {
|
||||
const at = new Date().toISOString();
|
||||
hist.grounds.push(...out.picks.filter(p => p.ground).map(p => ({ id: p.ground, at, seed: out.seed })));
|
||||
hist.grounds = hist.grounds.slice(-200);
|
||||
fs.mkdirSync(path.dirname(path.resolve(histFile)), { recursive: true });
|
||||
fs.writeFileSync(histFile, JSON.stringify(hist, null, 1) + '\n');
|
||||
}
|
||||
if (opt('json')) console.log(JSON.stringify(out));
|
||||
else {
|
||||
const dark = opt('mode') === 'dark';
|
||||
const label = p => p.own ? '(its own ground)' : !p.ground ? 'plain'
|
||||
: p.ground === 'stage' ? 'bg-stage (a panel behind the visual: <div class="bg-stage"> round the card, never on the <section>)'
|
||||
: 'bg-' + p.ground + (dark && (p.ground === 'band' || p.ground === 'field') ? ' (inset by itself on a dark page; bg-bleed keeps it full-bleed)' : '');
|
||||
console.log(out.page ? `page ${out.page} seed ${out.seed}` : `page flat (no page ground; the flat tokens only) seed ${out.seed}`);
|
||||
out.picks.forEach((p, i) => console.log(`${String(i + 1).padStart(2)}. ${p.section.padEnd(18)} ${label(p)}`));
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/* tasteskill / base.css
|
||||
A neutral foundation: reset, token names, fluid type steps, a 12-column grid, states and the motion
|
||||
helpers from references/motion.md. It has no look of its own. Copy it into the project, replace the
|
||||
token values with the ones on the direction sheet, and build on top. Delete what you do not use. */
|
||||
|
||||
/* ---------- tokens (replace with the dealt palette and type system) ---------- */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f2f2f2;
|
||||
--ink: #111111;
|
||||
--muted: #5c5c5c;
|
||||
--accent: #111111;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--font-display: system-ui, sans-serif;
|
||||
--font-text: system-ui, sans-serif;
|
||||
|
||||
--step-small: clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem);
|
||||
--step-body: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
|
||||
--step-lead: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);
|
||||
--step-head: clamp(2rem, 1.3rem + 3.2vw, 4rem);
|
||||
--step-display: clamp(3rem, 1rem + 9vw, 9rem); /* set from the hero blueprint */
|
||||
|
||||
--margin: clamp(1rem, 2.2vw, 2rem); /* page side margin: tight, so content reaches the edges */
|
||||
--gutter: clamp(1rem, 2vw, 2rem); /* column gap */
|
||||
--section: clamp(5rem, 12vw, 13rem); /* space between sections */
|
||||
--radius: 0px; /* one radius for the whole page: 0, or large, decided once */
|
||||
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, moving hovers, UI under 600ms */
|
||||
--ease-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals of 800ms and longer, line rises, openings */
|
||||
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1); /* wipes, panels, things that travel while visible */
|
||||
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow scale and drift on photographs */
|
||||
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* something leaving for good, short */
|
||||
--t-press: 140ms;
|
||||
--t-hover: 180ms;
|
||||
--t-ui: 240ms;
|
||||
--t-reveal: 800ms;
|
||||
--t-open: 1200ms; /* picture openings and clip wipes */
|
||||
--stagger-line: 90ms;
|
||||
--stagger-item: 70ms;
|
||||
}
|
||||
|
||||
/* ---------- reset ---------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-behavior: auto; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font-text); font-size: var(--step-body); line-height: 1.5; font-kerning: normal; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||
h1, h2, h3, h4, p, figure, blockquote, ul, ol, dl, dd { margin: 0; }
|
||||
ul[class], ol[class] { padding: 0; list-style: none; }
|
||||
img, video, svg, canvas { display: block; max-width: 100%; }
|
||||
img, video { height: auto; background: var(--surface); }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
a { color: inherit; }
|
||||
:target { scroll-margin-top: 6rem; }
|
||||
::selection { background: var(--accent); color: var(--accent-ink); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
|
||||
/* ---------- type ---------- */
|
||||
h1, h2, h3 { font-family: var(--font-display); font-weight: inherit; text-wrap: balance; hyphens: none; }
|
||||
.display { font-size: var(--step-display); line-height: 0.95; letter-spacing: -0.035em; }
|
||||
.head { font-size: var(--step-head); line-height: 1.02; letter-spacing: -0.025em; }
|
||||
.lead { font-size: var(--step-lead); line-height: 1.35; max-width: 40ch; }
|
||||
.small { font-size: var(--step-small); line-height: 1.45; }
|
||||
.muted { color: var(--muted); }
|
||||
p { max-width: 62ch; text-wrap: pretty; }
|
||||
p + p { margin-top: 0.9em; }
|
||||
.nums { font-variant-numeric: tabular-nums; }
|
||||
.only-wide { display: none; }
|
||||
@media (min-width: 48em) { .only-wide { display: inline; } }
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.wrap { padding-inline: var(--margin); }
|
||||
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gutter); }
|
||||
.section { padding-block: calc(var(--section) / 2); }
|
||||
.section + .section { margin-top: 0; }
|
||||
.bleed { margin-inline: calc(var(--margin) * -1); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
|
||||
/* ---------- controls ---------- */
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: 0 1.4rem; background: var(--accent); color: var(--accent-ink); border-radius: var(--radius); text-decoration: none; white-space: nowrap; transition: transform var(--t-press) var(--ease-out), background-color var(--t-hover) ease, color var(--t-hover) ease; }
|
||||
.button:active { transform: scale(0.97); }
|
||||
.link { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 0.2em; text-decoration-thickness: 0.07em; transition: text-decoration-color var(--t-hover) ease; }
|
||||
.link-plain { text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 0.07em; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link:hover { text-decoration-color: currentColor; }
|
||||
}
|
||||
.field { width: 100%; min-height: 3rem; padding: 0.7rem 0.9rem; background: var(--surface); border: 0; border-radius: var(--radius); font-size: max(1rem, var(--step-body)); }
|
||||
|
||||
/* ---------- motion helpers (content stays visible without JavaScript) ---------- */
|
||||
.lines .line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
|
||||
.lines [data-line] { display: block; }
|
||||
.shutter { overflow: hidden; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.js [data-rise] { opacity: 0; transform: translateY(18px); animation: ad-rise var(--t-reveal) var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 90ms + 120ms); }
|
||||
.js .lines [data-line] { transform: translateY(105%); animation: ad-line 900ms var(--ease-out) forwards; animation-delay: calc(var(--i, 0) * 110ms + 150ms); }
|
||||
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity 700ms var(--ease-out), transform 900ms var(--ease-out); }
|
||||
.js [data-reveal].is-in { opacity: 1; transform: none; }
|
||||
.js .shutter[data-reveal] { opacity: 1; transform: none; clip-path: inset(0 0 100% 0); transition: clip-path 1100ms var(--ease-in-out); }
|
||||
.js .shutter[data-reveal] img { transform: scale(1.12); transition: transform 1500ms var(--ease-soft); }
|
||||
.js .shutter[data-reveal].is-in { clip-path: inset(0); }
|
||||
.js .shutter[data-reveal].is-in img { transform: none; }
|
||||
}
|
||||
@keyframes ad-rise { to { opacity: 1; transform: none; } }
|
||||
@keyframes ad-line { to { transform: none; } }
|
||||
@@ -1,37 +0,0 @@
|
||||
// tasteskill / base.js — the small amount of script the motion helpers in base.css need.
|
||||
// Put this line in <head> so the hidden states only apply when scripting works:
|
||||
// <script>document.documentElement.classList.add('js')</script>
|
||||
// Then load this file at the end of <body> (or with defer).
|
||||
|
||||
// Reveal once when an element enters the viewport. Never replays, never leaves content hidden.
|
||||
(() => {
|
||||
const targets = document.querySelectorAll('[data-reveal]');
|
||||
if (!targets.length) return;
|
||||
if (!('IntersectionObserver' in window)) { targets.forEach((el) => el.classList.add('is-in')); return; }
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
entry.target.classList.add('is-in');
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.1 });
|
||||
targets.forEach((el) => io.observe(el));
|
||||
// safety net: if anything is still hidden a few seconds after load (observer never fired), show it
|
||||
window.addEventListener('load', () => setTimeout(() => {
|
||||
targets.forEach((el) => { const r = el.getBoundingClientRect(); if (r.top < innerHeight && r.bottom > 0) el.classList.add('is-in'); });
|
||||
}, 2500));
|
||||
})();
|
||||
|
||||
// Optional, for "colour by chapter": each chapter paints its own full-bleed ground in CSS
|
||||
// (style="background: var(--field-2); color: var(--field-2-ink)"), so the colour changes exactly at
|
||||
// section edges. This only lets a fixed header take the ink of the chapter under it. Give each chapter
|
||||
// data-ink="#hex" and style the header with color: var(--header-ink, var(--ink)).
|
||||
// Never recolour the whole page from an observer: mid-change, a paragraph sits on two colours.
|
||||
(() => {
|
||||
const chapters = document.querySelectorAll('[data-ink]');
|
||||
if (!chapters.length || !('IntersectionObserver' in window)) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) if (entry.isIntersecting) document.documentElement.style.setProperty('--header-ink', entry.target.dataset.ink);
|
||||
}, { rootMargin: '0px 0px -92% 0px', threshold: 0 });
|
||||
chapters.forEach((el) => io.observe(el));
|
||||
})();
|
||||
@@ -1,617 +0,0 @@
|
||||
// tasteskill / charts.js — small, dependency-free charts for app screens, drawn in SVG from your data.
|
||||
// They follow references/app-screens.md: one question per chart, one series that matters, 3–5 quiet
|
||||
// horizontal gridlines, round ticks that top out above the largest value, 2px straight lines with no
|
||||
// markers, square bars, no gradients, a bare tooltip, tabular figures. Colours come from CSS variables:
|
||||
// --chart-1 … --chart-4 (series), --chart-muted (other series), --chart-mark (a marked period; ink by default),
|
||||
// --chart-span (the opacity of a shaded span: about .06 on light, .12 on dark), --chart-track (a time strip's track),
|
||||
// --grid, --axis-ink, --ink, --surface, --raised, --rule, --negative
|
||||
// Usage (after this file loads):
|
||||
// Charts.line(el, { labels: ['1 Oct', …], series: [{ name: 'Requests', values: [...] }], format: Charts.fmt.compact })
|
||||
// Charts.bars(el, { labels, values, highlight: 6, format }) one bar (or [indices]) may carry the accent; dim: [indices]
|
||||
// Charts.diverging(el, { labels, up, down, above: 'Sold', below: 'Bought', mark: [3, 9], markLabel: 'Car charged' })
|
||||
// Charts.ring(el, { value: 0.72, label: '72%', sub: 'of capacity' }) a progress ring with its total inside
|
||||
// Charts.barList(el, [{ label: 'Berlin', value: 412 }, …], { format, thin }) a ranked list where the row is the bar
|
||||
// Charts.plan(el, [{ label: 'Groceries', planned: 600, spent: 412, committed: 60 }, …], { scale: 'row' | 'plan' }) planned against spent
|
||||
// Charts.bullet(el, [{ label: 'Bonds', value: 0.31, target: 0.25 }, …]) share against a target
|
||||
// Charts.timeline(el, rows, kinds, { days, today, weekends }) who is away when, each kind of booking its own mark
|
||||
// Charts.matrix(el, rows, { cols, comfort: 2, today }) slack against a minimum, team by day
|
||||
// Charts.timeStrip(el, rows, { open: '08:00', close: '15:00', compareLabel: 'Last Sunday' }) when, within the day
|
||||
// line, bars and diverging take annotations: [{ at: index, label: 'Deploy a3f09c1', short: 'a3f09c1' }] or spans
|
||||
// [{ from, to, label: 'Cloud' }]. Their labels sit in rows above the plot, measured once the fonts have loaded, and no
|
||||
// leader line crosses another label; the rows are reserved only when a label exists. reference: { value, label }, its
|
||||
// label set where no series passes.
|
||||
// line takes step: true for a balance that only changes on given days; today: index (the forecast after it is dashed)
|
||||
// with todayLabel: 'Now 20:34' (set clear of the line); endLabel: true (the last value at the line's end); max: caps a
|
||||
// flat series with one spike (the peak is labelled); yTicks: fixed ticks for a bounded quantity (0–100%); tickFormat:
|
||||
// axis labels apart from the tooltip format; xAt: the indices to label. Without xAt the x labels keep one step.
|
||||
// diverging, timeline, matrix and timeStrip return { svg, legend }, and line returns { legend } for two series or more:
|
||||
// the legend is drawn from the same style objects as the marks, so a swatch is always the mark scaled down. Put it in
|
||||
// the card's header, or pass legend: element (or a selector) to have it filled.
|
||||
// Numbers follow the page's language (<html lang>). Every chart gets role="img" and an aria-label that states what it shows.
|
||||
(() => {
|
||||
const NS = 'http://www.w3.org/2000/svg', MINUS = '\u2212', ROW = 14, LANE = (rows) => 8 + rows * ROW + (rows ? 4 : 0);
|
||||
const css = (el, name, fb) => getComputedStyle(el).getPropertyValue(name).trim() || fb;
|
||||
const cssNum = (el, name, fb) => { const v = parseFloat(css(el, name, '')); return Number.isFinite(v) ? v : fb; };
|
||||
const svg = (tag, attrs = {}, parent) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, v); if (parent) parent.appendChild(n); return n; };
|
||||
const text = (parent, str, attrs) => { const t = svg('text', attrs, parent); t.textContent = str; return t; };
|
||||
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
|
||||
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
|
||||
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
|
||||
const fmt = {
|
||||
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
|
||||
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
||||
pct: (d = 1) => (v) => nf({ style: 'percent', minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
||||
money: (cur = 'EUR', d = 0) => (v) => nf({ style: 'currency', currency: cur, minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
|
||||
compact: (v) => nf({ notation: 'compact', maximumFractionDigits: 1 }).format(v),
|
||||
ms: (v) => `${nf({ maximumFractionDigits: 0 }).format(v)} ms`,
|
||||
// a sign on every change, with a true minus: fmt.signed() → '+2', '0', '−1'; fmt.signed(fmt.dec(1)) → '+0.4'
|
||||
signed: (f = (v) => nf({ maximumFractionDigits: 0 }).format(v)) => (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + f(Math.abs(v)),
|
||||
// minutes since midnight as a 24-hour time: 680 → '11:20'
|
||||
clock: (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(Math.round(m % 60)).padStart(2, '0')}`, // taste-skill-ignore: a clock, not a counter
|
||||
};
|
||||
const minutes = (t) => (t == null || t === '' ? null : typeof t === 'number' ? t : (([hh, mm]) => hh * 60 + (mm || 0))(String(t).split(':').map(Number)));
|
||||
// Round ticks (steps of 1, 2, 2.5 or 5 × 10^n) whose top sits above the largest value.
|
||||
function ticks(min, max, count = 4) {
|
||||
if (max === min) max = min + 1;
|
||||
const raw = (max - min) / count, mag = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag;
|
||||
const lo = Math.floor(min / step) * step; let hi = Math.ceil(max / step) * step; if (hi <= max) hi += step;
|
||||
const out = []; for (let v = lo; v <= hi + step / 2; v += step) out.push(+v.toFixed(10));
|
||||
return out;
|
||||
}
|
||||
// x labels keep one step all the way: every k-th label, with k chosen so the labels fit the width, and a step that
|
||||
// lands on the last label when one gives enough labels. Never a forced end label that breaks the step. Clock labels
|
||||
// ('08:30') step in whole hours (or halves and quarters) from a round time, so a half-hourly axis reads 06:00, 09:00.
|
||||
function xPicks(labels, span, at) {
|
||||
const n = labels.length;
|
||||
if (at) return at.filter((i) => i >= 0 && i < n);
|
||||
if (n <= 1) return n ? [0] : [];
|
||||
const longest = Math.max(...labels.map((l) => String(l).length));
|
||||
const cap = Math.max(2, Math.min(8, Math.floor(span / (longest * 7 + 18))));
|
||||
if (n <= cap) return [...Array(n).keys()];
|
||||
const mins = labels.every((l) => /^\d{1,2}:\d{2}$/.test(String(l))) ? labels.map(minutes) : null;
|
||||
const d = mins ? mins[1] - mins[0] : 0;
|
||||
let first = null, land = null;
|
||||
for (let k = 1; k < n; k++) {
|
||||
const stepM = k * d, clock = mins && d > 0;
|
||||
if (clock && (stepM >= 60 ? ![1, 2, 3, 4, 6, 12].includes(stepM / 60) : 60 % stepM !== 0)) continue;
|
||||
// start on a round time: a multiple of the step (09:00 for three hours), or a whole hour for shorter steps
|
||||
let s = clock ? mins.findIndex((m) => m % Math.max(60, stepM) === 0) : 0;
|
||||
if (s < 0) s = Math.max(0, mins.findIndex((m) => m % 60 === 0));
|
||||
const count = Math.floor((n - 1 - s) / k) + 1;
|
||||
if (count > cap) continue;
|
||||
if (first == null) first = { k, s };
|
||||
if ((n - 1 - s) % k === 0) { land = { k, s, count }; break; }
|
||||
}
|
||||
const pick = land && land.count >= Math.max(3, Math.ceil(cap * 0.6)) ? land : first || { k: n - 1, s: 0 };
|
||||
const out = []; for (let i = pick.s; i < n; i += pick.k) out.push(i);
|
||||
return out;
|
||||
}
|
||||
// colours: tokens resolved to rgb, so a mark can be mixed with the surface (a pale fill, a darker step)
|
||||
const swatchProbe = document.createElement('span'); swatchProbe.style.display = 'none';
|
||||
const rgb = (el, value) => {
|
||||
el.appendChild(swatchProbe); swatchProbe.style.color = ''; swatchProbe.style.color = value;
|
||||
const c = getComputedStyle(swatchProbe).color; swatchProbe.remove();
|
||||
const m = c.match(/-?[\d.]+/g); if (!m || m.length < 3) return null;
|
||||
return c.startsWith('color(') ? m.slice(0, 3).map((v) => Math.round(+v * 255)) : m.slice(0, 3).map(Number);
|
||||
};
|
||||
const mix = (el, a, b, t) => { const p = rgb(el, a), q = rgb(el, b); return p && q ? `rgb(${p.map((v, i) => Math.round(v * t + q[i] * (1 - t))).join(',')})` : a; };
|
||||
// text width, measured in the chart's own face (falls back to an estimate when the chart is hidden)
|
||||
const measure = (s, str, size = 11, weight = 400) => { const t = text(s, str, { x: -999, y: -999, 'font-size': size, 'font-weight': weight, visibility: 'hidden' }); let wd = 0; try { wd = t.getComputedTextLength(); } catch {} t.remove(); return wd > 0 ? wd : String(str).length * size * 0.56; };
|
||||
const fit = (s, str, max, size, weight) => { str = String(str); if (measure(s, str, size, weight) <= max) return str; while (str.length > 1 && measure(s, str + '\u2026', size, weight) > max) str = str.slice(0, -1); return str.trimEnd() + '\u2026'; };
|
||||
function frame(el, h) {
|
||||
el.innerHTML = '';
|
||||
const w = Math.max(240, el.clientWidth || 600);
|
||||
const s = svg('svg', { viewBox: `0 0 ${w} ${h}`, width: '100%', height: h, role: 'img', style: 'display:block;overflow:visible;font-variant-numeric:tabular-nums' }, el);
|
||||
return { s, w, h };
|
||||
}
|
||||
function tooltip(el) {
|
||||
let t = el.querySelector('.chart-tip');
|
||||
if (!t) { t = document.createElement('div'); t.className = 'chart-tip'; t.style.cssText = 'position:absolute;pointer-events:none;padding:6px 8px;font:inherit;font-size:12px;line-height:1.35;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity 120ms ease;background:var(--surface,#fff);color:var(--ink,#111);border:1px solid var(--grid,#e5e7eb)'; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; el.appendChild(t); }
|
||||
return t;
|
||||
}
|
||||
function axes(g, el, { w, h, padL, padB, yt, y, labels, x, band = false, at = null, tick, avoid = null }) {
|
||||
const grid = css(el, '--grid', '#e8e8e8'), ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
|
||||
for (const v of yt) {
|
||||
const yy = y(v);
|
||||
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: grid, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
||||
text(g, tick(v), { x: padL - 8, y: yy + 4, 'text-anchor': 'end', 'font-size': 12, fill: ink });
|
||||
}
|
||||
const n = labels.length;
|
||||
for (const i of xPicks(labels, w - padL, at)) {
|
||||
const lab = String(labels[i]), tw = measure(s, lab, 12), xx = x(i);
|
||||
// the first and last labels are pulled inside the plot; none runs past either edge
|
||||
let left = band ? xx - tw / 2 : i === 0 ? xx : i === n - 1 ? xx - tw : xx - tw / 2;
|
||||
left = Math.max(band ? 0 : padL, Math.min(w - tw, left));
|
||||
if (avoid && left < avoid[1] + 8 && left + tw > avoid[0] - 8) continue;
|
||||
text(g, lab, { x: left, y: h - padB + 18, 'font-size': 12, fill: ink });
|
||||
}
|
||||
}
|
||||
// Annotation labels sit in rows above the plot, never on the data. A label sits beside the top of its own leader
|
||||
// line, right of it or left of it, in one of three rows. A place is free when no other label in that row overlaps
|
||||
// it, no leader line passes through it, and its own leader passes through no label below it. The layout is searched
|
||||
// (close markers often need the first label on the left so the next leader runs clear): full labels first, then
|
||||
// short forms (`short`), and only then is a label dropped to its marker's tooltip, with a note in the console.
|
||||
function lane(s, items, w, maxRows = 3) {
|
||||
const gap = 6, rank = (it) => (it.fixed != null ? 0 : it.span ? 1 : 2);
|
||||
// fixed captions first, then spans, then markers from left to right
|
||||
const order = [...items].sort((a, b) => rank(a) - rank(b) || (a.x ?? a.x0) - (b.x ?? b.x0));
|
||||
const opts = order.map((it) => {
|
||||
const out = [];
|
||||
for (const str of [it.label, it.short].filter(Boolean)) {
|
||||
const tw = measure(s, str, 11) + (it.glyph ? 9 : 0), mid = it.span ? (it.x0 + it.x1) / 2 : 0;
|
||||
const cands = it.fixed != null ? [[it.fixed, it.fixed + tw]] : it.span ? [[mid - tw / 2, mid + tw / 2], [it.x0, it.x0 + tw], [it.x1 - tw, it.x1]] : [[it.x + 4, it.x + 4 + tw], [it.x - 4 - tw, it.x - 4]];
|
||||
for (let row = 0; row < maxRows; row++) for (const [x0, x1] of cands) if (x0 >= 0 && x1 <= w) out.push({ ...it, text: str, isShort: str !== it.label, row, x0, x1 });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const free = (c, placed) => placed.every((p) => p.dropped || (!(p.row === c.row && c.x0 < p.x1 + gap && c.x1 > p.x0 - gap)
|
||||
&& !(p.leader != null && p.row >= c.row && p.leader > c.x0 - 3 && p.leader < c.x1 + 3)
|
||||
&& !(c.leader != null && p.row <= c.row && c.leader > p.x0 - 3 && c.leader < p.x1 + 3)));
|
||||
let best = null, steps = 0;
|
||||
const solve = (i, placed, allowShort, allowDrop) => {
|
||||
if (++steps > 20000) return false;
|
||||
if (i === order.length) { best = placed.slice(); return true; }
|
||||
for (const c of opts[i]) { if ((c.isShort && !allowShort) || !free(c, placed)) continue; placed.push(c); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
|
||||
if (allowDrop) { placed.push({ ...order[i], dropped: true }); if (solve(i + 1, placed, allowShort, allowDrop)) return true; placed.pop(); }
|
||||
return false;
|
||||
};
|
||||
for (const [sh, dr] of [[false, false], [true, false], [true, true]]) { steps = 0; if (solve(0, [], sh, dr)) break; }
|
||||
const placed = (best || []).filter((p) => !p.dropped), dropped = (best || order.map((it) => ({ ...it, dropped: true }))).filter((p) => p.dropped);
|
||||
return { placed, dropped, rows: placed.length ? Math.max(...placed.map((p) => p.row)) + 1 : 0 };
|
||||
}
|
||||
function prepNotes(s, annotations = [], x, w, extra = []) {
|
||||
const items = [];
|
||||
for (const a of annotations || []) {
|
||||
if (a.from != null) items.push({ span: true, x0: Math.min(x(a.from), x(a.to)), x1: Math.max(x(a.from), x(a.to)), label: a.label, short: a.short, colour: a.colour });
|
||||
else if (a.at != null) items.push({ x: x(a.at), at: a.at, leader: x(a.at), label: a.label, short: a.short, colour: a.colour });
|
||||
}
|
||||
items.push(...extra);
|
||||
return { items, ...lane(s, items.filter((it) => it.label), w) };
|
||||
}
|
||||
// shaded spans go under the data; marker lines and the label rows over it
|
||||
function notesUnder(g, el, N, { padT, h, padB }) {
|
||||
const ink = css(el, '--axis-ink', '#6b6b6b'), op = cssNum(el, '--chart-span', 0.07);
|
||||
for (const it of N.items) if (it.span) svg('rect', { x: it.x0, y: padT, width: it.x1 - it.x0, height: h - padT - padB, fill: ink, opacity: op }, g);
|
||||
}
|
||||
function notesOver(g, el, N, { padT, h, padB }) {
|
||||
const ink = css(el, '--axis-ink', '#6b6b6b');
|
||||
// a marker line runs to the plot's foot, or stops above the bar it points at (it.end)
|
||||
for (const it of N.items) if (it.leader != null && !it.drawn) svg('line', { x1: it.leader, x2: it.leader, y1: padT, y2: Math.max(padT, it.end ?? h - padB), stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
|
||||
for (const p of N.placed) {
|
||||
const base = padT - 9 - p.row * ROW;
|
||||
if (p.leader != null) svg('line', { x1: p.leader, x2: p.leader, y1: base - 8, y2: padT, stroke: ink, 'stroke-width': 1, opacity: 0.45, 'shape-rendering': 'crispEdges' }, g);
|
||||
if (p.glyph) svg('path', { d: p.glyph === 'up' ? `M${p.x0} ${base - 1}l3 -6l3 6z` : `M${p.x0} ${base - 7}l3 6l3 -6z`, fill: ink }, g);
|
||||
text(g, p.text, { x: p.x0 + (p.glyph ? 9 : 0), y: base, 'font-size': 11, fill: p.colour || ink });
|
||||
}
|
||||
const el2 = g.ownerSVGElement.parentNode; el2._warned = el2._warned || new Set();
|
||||
for (const d of N.dropped) {
|
||||
if (d.leader != null) { const hit = svg('line', { x1: d.leader, x2: d.leader, y1: padT, y2: h - padB, stroke: 'transparent', 'stroke-width': 8 }, g); titled(hit, d.label, {}); }
|
||||
if (!el2._warned.has(d.label)) { el2._warned.add(d.label); console.warn(`Charts: no room above the plot for "${d.label}". Give it a short form (short) or label fewer markers.`); }
|
||||
}
|
||||
}
|
||||
// does a label box touch a drawn line? polylines are [[x, y], …]; a box is { x0, x1, y0, y1 }
|
||||
function hits(b, lines) {
|
||||
for (const pts of lines) for (let k = 1; k < pts.length; k++) {
|
||||
const a = pts[k - 1], c = pts[k]; if (!a || !c) continue;
|
||||
if (a[0] === c[0]) { if (a[0] >= b.x0 - 3 && a[0] <= b.x1 + 3 && Math.max(a[1], c[1]) >= b.y0 - 3 && Math.min(a[1], c[1]) <= b.y1 + 3) return true; continue; }
|
||||
const lo = Math.max(b.x0 - 3, Math.min(a[0], c[0])), hi = Math.min(b.x1 + 3, Math.max(a[0], c[0]));
|
||||
for (let xx = lo; xx <= hi; xx += 2) { const yy = a[1] + ((xx - a[0]) / (c[0] - a[0])) * (c[1] - a[1]); if (yy >= b.y0 - 3 && yy <= b.y1 + 3) return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const boxHits = (b, boxes) => boxes.some((r) => b.x0 < r.x1 + 3 && b.x1 > r.x0 - 3 && b.y0 < r.y1 + 3 && b.y1 > r.y0 - 3);
|
||||
// a reference line (a budget, an SLO) with its label at an end where no series passes
|
||||
function references(g, el, refs, { y, padL, w, lines = [], boxes = [] }) {
|
||||
const ink = css(el, '--axis-ink', '#6b6b6b'), s = g.ownerSVGElement;
|
||||
for (const r of [].concat(refs || [])) {
|
||||
const yy = y(r.value);
|
||||
svg('line', { x1: padL, x2: w, y1: yy, y2: yy, stroke: ink, 'stroke-width': 1, 'stroke-dasharray': '4 4', opacity: 0.7 }, g);
|
||||
if (!r.label) continue;
|
||||
const tw = measure(s, r.label, 11), right = r.at !== 'left';
|
||||
const cand = [[right, -5], [right, 13], [!right, -5], [!right, 13]].map(([rt, dy]) => ({ x0: rt ? w - tw : padL + 4, x1: rt ? w : padL + 4 + tw, y0: yy + dy - 9, y1: yy + dy + 2, base: yy + dy }));
|
||||
const b = cand.find((c) => !hits(c, lines) && !boxHits(c, boxes)) || cand[0];
|
||||
text(g, r.label, { x: b.x0, y: b.base, 'font-size': 11, fill: ink });
|
||||
}
|
||||
}
|
||||
// One style object per kind of mark draws both the mark and its legend swatch, so the two cannot drift apart.
|
||||
function paintRect(parent, st, x, y, w, h, r = 0) {
|
||||
let fill = st.fill || 'none';
|
||||
if (st.hatch) {
|
||||
const s = parent.ownerSVGElement || parent, id = 'h' + Math.random().toString(36).slice(2, 8);
|
||||
const p = svg('pattern', { id, width: 5, height: 5, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, svg('defs', {}, s));
|
||||
svg('rect', { width: 5, height: 5, fill: st.fill }, p); svg('line', { x1: 0, y1: 0, x2: 0, y2: 5, stroke: st.hatch, 'stroke-width': 1.2 }, p);
|
||||
fill = `url(#${id})`;
|
||||
}
|
||||
const inset = st.stroke ? (st.width || 1) / 2 : 0;
|
||||
const n = svg('rect', { x: x + inset, y: y + inset, width: Math.max(0, w - inset * 2), height: Math.max(0, h - inset * 2), rx: r, fill, stroke: st.stroke, 'stroke-width': st.stroke ? st.width || 1 : null, 'stroke-dasharray': st.dash, opacity: st.opacity, 'shape-rendering': r ? null : 'crispEdges' }, parent);
|
||||
if (st.edge) svg('rect', { x, y, width: 3, height: h, rx: Math.min(r, 1.5), fill: st.edge }, parent);
|
||||
return n;
|
||||
}
|
||||
const legendCss = ':where(.chart-legend){list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;line-height:16px;color:var(--muted,#5f6368)}:where(.chart-legend li){display:inline-flex;align-items:center;gap:6px;white-space:nowrap}:where(.chart-legend svg){flex:none;overflow:visible}@media (max-width:480px){:where(.chart-legend){display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}';
|
||||
function legendEl(items, target) {
|
||||
if (!document.getElementById('charts-legend-css')) { const st = document.createElement('style'); st.id = 'charts-legend-css'; st.textContent = legendCss; document.head.appendChild(st); }
|
||||
const ul = document.createElement('ul'); ul.className = 'chart-legend';
|
||||
for (const it of items) {
|
||||
const li = document.createElement('li'), sw = svg('svg', { width: it.w || 14, height: 12, 'aria-hidden': 'true' });
|
||||
it.draw(sw); li.append(sw, document.createTextNode(it.label)); ul.appendChild(li);
|
||||
}
|
||||
const t = typeof target === 'string' ? document.querySelector(target) : target;
|
||||
if (t) t.replaceChildren(ul);
|
||||
return ul;
|
||||
}
|
||||
const rectSwatch = (st, w = 14, h = 10, r = 2) => (sw) => paintRect(sw, st, 0, (12 - h) / 2, w, h, r);
|
||||
// charts are laid out with measured text: draw again once the page's fonts have arrived
|
||||
function settle(draw) { if (document.fonts) { document.fonts.ready.then(draw); document.fonts.addEventListener?.('loadingdone', draw, { once: true }); } }
|
||||
const heightOf = (h) => (typeof h === 'function' ? h() : h);
|
||||
|
||||
function line(el, { labels, series, format = fmt.int, tickFormat = null, height = 220, fill = false, min, max, step = false, annotations, reference, today = null, todayLabel = 'Today', endLabel = false, yTicks = null, xAt = null, legend = null }) {
|
||||
el._fmt = format;
|
||||
const colourOf = (sr, k) => sr.colour || (k === 0 ? css(el, '--chart-1', '#2f6fdf') : css(el, `--chart-${k + 1}`, css(el, '--chart-muted', '#a3a3a3')));
|
||||
const draw = () => {
|
||||
const { s, w, h } = frame(el, heightOf(height));
|
||||
const all = series.flatMap((x) => x.values.filter((v) => v != null)).concat([].concat(reference || []).map((r) => r.value));
|
||||
// max caps the range (a flat series with one spike): values above it are clipped and their peak labelled
|
||||
const top = max ?? Math.max(...all);
|
||||
const yt = yTicks || ticks(min ?? Math.min(0, ...all), top, 4);
|
||||
const tick = tickFormat || format;
|
||||
const lastVal = series[0].values.filter((v) => v != null).at(-1);
|
||||
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26, padR = endLabel ? 10 + measure(s, format(lastVal), 12, 600) : 0;
|
||||
const x = (i) => padL + (i * (w - padL - padR)) / Math.max(1, labels.length - 1);
|
||||
// the peaks clipped by max are labelled in the rows above the plot, where the line leaves it
|
||||
const peaks = [];
|
||||
series.forEach((sr, k) => sr.values.forEach((v, i) => { if (v != null && v > yt.at(-1) && (i === 0 || sr.values[i - 1] == null || sr.values[i - 1] <= yt.at(-1))) { let j = i, peak = v; while (j < sr.values.length && sr.values[j] != null && sr.values[j] > yt.at(-1)) { peak = Math.max(peak, sr.values[j]); j++; } peaks.push({ x: x(i), label: format(peak) + ' at ' + labels[i], colour: colourOf(sr, k) }); } }));
|
||||
const scale = (N) => { const padT = LANE(N.rows); return { padT, y: (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0])) }; };
|
||||
const linesAt = (y) => series.map((sr) => { const out = []; sr.values.forEach((v, i) => { if (v == null) { out.push(null); return; } const p = [x(i), y(Math.min(v, yt.at(-1)))]; const prev = out.at(-1); if (step && prev) out.push([p[0], prev[1]]); out.push(p); }); return out; }).concat([].concat(reference || []).map((r) => [[padL, y(r.value)], [w, y(r.value)]]));
|
||||
let N = prepNotes(s, annotations, x, w, peaks), { padT, y } = scale(N), todayBox = null;
|
||||
// the today label goes inside the plot on whichever side of its line no series crosses; failing that, above
|
||||
if (today != null && todayLabel) {
|
||||
const cut = x(today), tw = measure(s, todayLabel, 11), lines = linesAt(y);
|
||||
const cand = [[cut + 4, h - padB - 6], [cut - 4 - tw, h - padB - 6], [cut + 4, padT + 11], [cut - 4 - tw, padT + 11]].map(([x0, b]) => ({ x0, x1: x0 + tw, y0: b - 9, y1: b + 2, base: b }));
|
||||
todayBox = cand.find((b) => b.x0 >= padL && b.x1 <= w && !hits(b, lines)) || null;
|
||||
if (!todayBox) { N = prepNotes(s, annotations, x, w, [...peaks, { x: cut, leader: cut, drawn: true, label: todayLabel }]); ({ padT, y } = scale(N)); }
|
||||
}
|
||||
const g = svg('g', {}, s);
|
||||
axes(g, el, { w, h, padL, padB, yt, y, labels, x, at: xAt, tick });
|
||||
notesUnder(g, el, N, { padT, h, padB });
|
||||
series.forEach((sr, k) => {
|
||||
const col = colourOf(sr, k);
|
||||
const pts = sr.values.map((v, i) => (v == null ? null : [x(i), y(Math.min(v, yt.at(-1)))]));
|
||||
// step: a balance that only changes on given days holds its value until the next one
|
||||
const d = pts.reduce((acc, p, i) => (p ? acc + (acc && pts[i - 1] ? (step ? `L${p[0].toFixed(1)} ${pts[i - 1][1].toFixed(1)}L` : 'L') : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1) : acc), '');
|
||||
if (fill && k === 0) { const first = pts.find(Boolean), last = [...pts].reverse().find(Boolean); svg('path', { d: `${d}L${last[0]} ${y(yt[0])}L${first[0]} ${y(yt[0])}Z`, fill: col, opacity: 0.12 }, g); }
|
||||
if (today == null) svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }, g);
|
||||
else {
|
||||
// a forecast: the line after today is dashed, so a projection never reads as a fact
|
||||
const cut = x(today), id = 'c' + k + Math.random().toString(36).slice(2, 8);
|
||||
const defs = svg('defs', {}, g);
|
||||
const a = svg('clipPath', { id: id + 'a' }, defs); svg('rect', { x: 0, y: 0, width: cut, height: h }, a);
|
||||
const b = svg('clipPath', { id: id + 'b' }, defs); svg('rect', { x: cut, y: 0, width: w, height: h }, b);
|
||||
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-linejoin': 'round', 'clip-path': 'url(#' + id + 'a)' }, g);
|
||||
svg('path', { d, fill: 'none', stroke: col, 'stroke-width': 2, 'stroke-dasharray': '5 4', opacity: 0.8, 'clip-path': 'url(#' + id + 'b)' }, g);
|
||||
if (k === 0) { svg('line', { x1: cut, x2: cut, y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0.4 }, g); if (todayBox) text(g, todayLabel, { x: todayBox.x0, y: todayBox.base, 'font-size': 11, fill: css(el, '--axis-ink', '#6b6b6b') }); }
|
||||
}
|
||||
if (endLabel && k === 0) { const li = sr.values.map((v, i) => (v == null ? -1 : i)).filter((i) => i >= 0).at(-1); text(g, format(sr.values[li]), { x: x(li) + 6, y: y(Math.min(sr.values[li], yt.at(-1))) + 4, 'font-size': 12, 'font-weight': 600, fill: css(el, '--ink', '#111') }); }
|
||||
});
|
||||
references(g, el, reference, { y, padL, w, lines: linesAt(y).slice(0, series.length) });
|
||||
notesOver(g, el, N, { padT, h, padB });
|
||||
// hover: a rule, a dot per series and a bare tooltip
|
||||
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
|
||||
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
|
||||
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
|
||||
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
|
||||
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
|
||||
const last = series[0].values.filter((v) => v != null).at(-1);
|
||||
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
const lg = series.length > 1 ? legendEl(series.map((sr, k) => ({ label: sr.name, w: 16, draw: (sw) => svg('line', { x1: 0, x2: 16, y1: 6, y2: 6, stroke: colourOf(sr, k), 'stroke-width': 2, 'stroke-dasharray': sr.dash ? '4 3' : null }, sw) })), legend) : null;
|
||||
return { legend: lg };
|
||||
}
|
||||
function bars(el, { labels, values, format = fmt.int, tickFormat = null, height = 200, highlight = null, dim = [], colour, mutedColour, annotations, reference, xAt = null }) {
|
||||
el._fmt = format;
|
||||
const lit = (i) => highlight == null || (Array.isArray(highlight) ? highlight.includes(i) : highlight === i);
|
||||
const draw = () => {
|
||||
const { s, w, h } = frame(el, heightOf(height));
|
||||
const yt = ticks(0, Math.max(...values, ...[].concat(reference || []).map((r) => r.value)), 4), tick = tickFormat || format;
|
||||
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
|
||||
const band = (w - padL) / values.length, bw = Math.max(2, band * 0.62);
|
||||
const x = (i) => padL + band * i + band / 2;
|
||||
const N = prepNotes(s, annotations, x, w), padT = LANE(N.rows);
|
||||
const y = (v) => padT + (h - padT - padB) * (1 - v / yt.at(-1));
|
||||
const g = svg('g', {}, s);
|
||||
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick });
|
||||
notesUnder(g, el, N, { padT, h, padB });
|
||||
for (const it of N.items) if (it.at != null && values[it.at] != null) it.end = y(values[it.at]) - 4;
|
||||
const main = colour || css(el, '--chart-1', '#2f6fdf'), muted = mutedColour || css(el, '--chart-muted', '#c9c9c9');
|
||||
values.forEach((v, i) => { const r = svg('rect', { x: x(i) - bw / 2, y: y(v), width: bw, height: Math.max(0, y(0) - y(v)), fill: lit(i) ? main : muted, opacity: dim.includes(i) ? 0.45 : null, 'shape-rendering': 'crispEdges' }, g); titled(r, `${labels[i]}: ${format(v)}`, {}); });
|
||||
references(g, el, reference, { y, padL, w, boxes: values.map((v, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(v), y1: y(0) })) });
|
||||
notesOver(g, el, N, { padT, h, padB });
|
||||
s.setAttribute('aria-label', `${labels.length} bars from ${labels[0]} to ${labels.at(-1)}; highest ${format(Math.max(...values))}`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
}
|
||||
// Two directions on one scale: what went out above the zero line and what came in below it (sold and bought,
|
||||
// income and spend). The axis says which is which, with signed ticks and a caption on each side of zero.
|
||||
// mark: [indices] draws those periods in a second tone with their own legend entry (the nights the car charged).
|
||||
function diverging(el, { labels, up, down, values, above = 'Above', below = 'Below', mark = [], markLabel = null, markSide = 'below', partial = null, format = fmt.int, tickFormat = null, height = 200, annotations, reference, xAt = null, legend = null }) {
|
||||
if (values) { up = values.map((v) => Math.max(0, v || 0)); down = values.map((v) => Math.max(0, -(v || 0))); }
|
||||
up = up || labels.map(() => 0); down = down || labels.map(() => 0);
|
||||
const st = { above: { fill: css(el, '--chart-1', '#2f6fdf') }, below: { fill: css(el, '--chart-muted', '#c9c9c9') }, mark: { fill: css(el, '--chart-mark', css(el, '--ink', '#111')) } };
|
||||
const marked = (i, side) => mark.includes(i) && (markSide === 'both' || markSide === side);
|
||||
const tick = (v) => (v > 0 ? '+' : v < 0 ? MINUS : '') + (tickFormat || format)(Math.abs(v));
|
||||
const draw = () => {
|
||||
const { s, w, h } = frame(el, heightOf(height));
|
||||
const hiUp = Math.max(0, ...up), hiDown = Math.max(0, ...down);
|
||||
const yt = ticks(-hiDown, hiUp || 1, 4); const stp = yt[1] - yt[0]; if (hiDown > 0 && yt[0] >= -hiDown) yt.unshift(+(yt[0] - stp).toFixed(10));
|
||||
const padL = 12 + Math.max(...yt.map((v) => measure(s, tick(v), 12))), padB = 26;
|
||||
const band = (w - padL) / labels.length, bw = Math.max(2, band * 0.62);
|
||||
const x = (i) => padL + band * i + band / 2;
|
||||
// the caption for the upper side opens the label rows above the plot; the lower one sits under the plot, at left
|
||||
const N = prepNotes(s, annotations, x, w, [{ fixed: padL, glyph: 'up', label: above }]), padT = LANE(N.rows);
|
||||
const y = (v) => padT + (h - padT - padB) * (1 - (v - yt[0]) / (yt.at(-1) - yt[0]));
|
||||
const g = svg('g', {}, s), ink = css(el, '--axis-ink', '#6b6b6b');
|
||||
const bwid = measure(s, below, 11) + 9;
|
||||
axes(g, el, { w, h, padL, padB, yt, y, labels, x, band: true, at: xAt, tick, avoid: [padL, padL + bwid] });
|
||||
svg('path', { d: `M${padL} ${h - padB + 11}l3 6l3 -6z`, fill: ink }, g); text(g, below, { x: padL + 9, y: h - padB + 18, 'font-size': 11, fill: ink });
|
||||
notesUnder(g, el, N, { padT, h, padB });
|
||||
for (const it of N.items) if (it.at != null) it.end = y(up[it.at] || 0) - 4;
|
||||
labels.forEach((lab, i) => {
|
||||
const o = partial === i ? 0.5 : null;
|
||||
if (up[i] > 0) { const r = paintRect(g, { ...(marked(i, 'above') ? st.mark : st.above), opacity: o }, x(i) - bw / 2, y(up[i]), bw, Math.max(0, y(0) - y(up[i]) - 1)); titled(r, `${lab}: ${above} ${format(up[i])}`, {}); }
|
||||
if (down[i] > 0) { const r = paintRect(g, { ...(marked(i, 'below') ? st.mark : st.below), opacity: o }, x(i) - bw / 2, y(0) + 1, bw, Math.max(0, y(-down[i]) - y(0) - 1)); titled(r, `${lab}: ${below} ${format(down[i])}`, {}); }
|
||||
});
|
||||
svg('line', { x1: padL, x2: w, y1: y(0), y2: y(0), stroke: ink, 'stroke-width': 1, opacity: 0.8, 'shape-rendering': 'crispEdges' }, g);
|
||||
references(g, el, reference, { y, padL, w, boxes: labels.map((_, i) => ({ x0: x(i) - bw / 2, x1: x(i) + bw / 2, y0: y(up[i]), y1: y(-down[i]) })) });
|
||||
notesOver(g, el, N, { padT, h, padB });
|
||||
s.setAttribute('aria-label', `${above} above the line and ${below} below it, ${labels[0]} to ${labels.at(-1)}`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
const items = [];
|
||||
if (up.some((v) => v > 0)) items.push({ label: above, draw: rectSwatch(st.above) });
|
||||
if (down.some((v) => v > 0)) items.push({ label: below, draw: rectSwatch(st.below) });
|
||||
if (mark.length && markLabel) items.push({ label: markLabel, draw: rectSwatch(st.mark) });
|
||||
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
||||
}
|
||||
function ring(el, { value, label, sub, size = 132, stroke = 8, colour }) {
|
||||
el.innerHTML = '';
|
||||
const r = (size - stroke) / 2, c = 2 * Math.PI * r, v = Math.max(0, Math.min(1, value));
|
||||
const wrap = document.createElement('div'); wrap.style.cssText = `position:relative;width:${size}px;height:${size}px`;
|
||||
const s = svg('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, role: 'img', 'aria-label': `${label}${sub ? ' ' + sub : ''}` }, wrap);
|
||||
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
|
||||
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
|
||||
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
|
||||
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
|
||||
wrap.appendChild(t); el.appendChild(wrap);
|
||||
}
|
||||
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
|
||||
// label instead, ink for the top item, for a ranked list of money in a narrow card.
|
||||
function barList(el, rows, { format = fmt.int, colour, thin = false } = {}) {
|
||||
el.innerHTML = '';
|
||||
const max = Math.max(...rows.map((r) => r.value)) || 1;
|
||||
const list = document.createElement('ol'); list.style.cssText = `list-style:none;margin:0;padding:0;display:grid;gap:${thin ? 10 : 2}px;font-variant-numeric:tabular-nums`;
|
||||
const valueCol = Math.max(...rows.map((r) => String(format(r.value)).length)) + 1;
|
||||
rows.forEach((r, k) => {
|
||||
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(0,1fr) ' + valueCol + 'ch;align-items:center;column-gap:12px';
|
||||
const a = document.createElement('span'); a.textContent = r.label; a.style.cssText = 'position:relative;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
const b = document.createElement('span'); b.textContent = format(r.value); b.style.cssText = 'font-weight:500;text-align:right';
|
||||
if (thin) {
|
||||
const bar = document.createElement('span'); bar.style.cssText = `grid-column:1 / -1;height:2px;margin-top:4px;width:${(r.value / max) * 100}%;background:${k === 0 ? colour || 'var(--ink,#111)' : 'var(--chart-muted,#c9c9c9)'}`;
|
||||
li.append(a, b, bar);
|
||||
} else {
|
||||
const track = document.createElement('span'); track.style.cssText = 'position:relative;padding:10px 12px;border-radius:6px;isolation:isolate;min-width:0';
|
||||
const bar = document.createElement('span'); bar.style.cssText = `position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:${colour || 'var(--bar-fill, color-mix(in srgb, var(--chart-1, #2f6fdf) 12%, transparent))'};border-radius:inherit;z-index:-1`;
|
||||
track.append(bar, a); li.append(track, b);
|
||||
}
|
||||
list.appendChild(li);
|
||||
});
|
||||
el.appendChild(list);
|
||||
}
|
||||
// Planned against spent: the track is the plan, the fill what is spent, a lighter segment what is
|
||||
// already committed (bills to come), and an overrun past the track's end in the negative colour.
|
||||
// One row per category: label and figures on one line, the 8px track under them.
|
||||
function plan(el, rows, { format = fmt.money('EUR', 0), scale = 'row', note = (r) => r.spent > r.planned ? `${format(r.spent - r.planned)} over` : `${format(r.planned - r.spent - (r.committed || 0))} left` } = {}) {
|
||||
el.innerHTML = '';
|
||||
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:14px;font-variant-numeric:tabular-nums';
|
||||
const max = Math.max(...rows.map((r) => Math.max(r.planned, r.spent + (r.committed || 0))));
|
||||
for (const r of rows) {
|
||||
const over = r.spent > r.planned;
|
||||
const li = document.createElement('li');
|
||||
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
|
||||
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
|
||||
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
|
||||
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
|
||||
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
|
||||
const pct = (v) => (v / r.planned) * 100;
|
||||
const spent = Math.min(r.spent, r.planned);
|
||||
track.innerHTML = `<span style="position:absolute;inset:0 auto 0 0;width:${pct(spent)}%;border-radius:999px;background:var(--chart-1,#2f6fdf)"></span>`
|
||||
+ (r.committed ? `<span style="position:absolute;top:0;bottom:0;left:${pct(spent)}%;width:${Math.min(pct(r.committed), 100 - pct(spent))}%;background:var(--chart-1,#2f6fdf);opacity:.35"></span>` : '')
|
||||
+ (over ? `<span style="position:absolute;top:-3px;bottom:-3px;left:100%;width:1px;background:var(--text,#111)"></span><span style="position:absolute;top:0;bottom:0;left:calc(100% + 2px);width:max(12px, ${pct(r.spent - r.planned)}%);border-radius:0 999px 999px 0;background:var(--negative,#c4242a)"></span>` : '');
|
||||
track.setAttribute('role', 'img'); track.setAttribute('aria-label', `${r.label}: ${format(r.spent)} of ${format(r.planned)}${r.committed ? `, ${format(r.committed)} committed` : ''}${over ? ', over plan' : ''}`);
|
||||
li.append(top, track); list.appendChild(li);
|
||||
}
|
||||
el.appendChild(list);
|
||||
}
|
||||
// Share against a target: one row per part, the current share as a bar and a 1px tick at the target,
|
||||
// so drift reads at a glance (never two stacked bars to compare).
|
||||
function bullet(el, rows, { format = fmt.pct(1), max = Math.max(...rows.map((r) => Math.max(r.value, r.target))) * 1.15 } = {}) {
|
||||
el.innerHTML = '';
|
||||
const list = document.createElement('ul'); list.style.cssText = 'list-style:none;margin:0;padding:0;display:grid;gap:12px;font-variant-numeric:tabular-nums';
|
||||
for (const r of rows) {
|
||||
const drift = r.value - r.target;
|
||||
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
|
||||
li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
|
||||
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
|
||||
list.appendChild(li);
|
||||
}
|
||||
el.appendChild(list);
|
||||
}
|
||||
// shared by the row charts (timeline, matrix, time strip): a label column with an optional grey subline.
|
||||
// A subline needs a 40-44px row pitch; one-line labels sit on 32px.
|
||||
function rowLabel(g, s, el, r, { y0, pitch, lw }) {
|
||||
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
|
||||
if (r.sub) { text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 - 3, 'font-size': 14, fill: ink }); text(g, fit(s, r.sub, lw - 12, 12), { x: 0, y: y0 + pitch / 2 + 13, 'font-size': 12, fill: muted }); }
|
||||
else text(g, fit(s, r.label, lw - 12, 14), { x: 0, y: y0 + pitch / 2 + 5, 'font-size': 14, fill: ink });
|
||||
}
|
||||
const dayText = (d) => (typeof d === 'object' && d ? d.label : String(d));
|
||||
// day headers: the full label when every one fits its column, else the short form ('19'), else every k-th short
|
||||
// label with today always shown and its neighbours left out, so no two headers ever touch
|
||||
function dayHeads(g, s, el, days, { x0, cw, y, today }) {
|
||||
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b');
|
||||
const full = days.map(dayText), short = days.map((d) => (typeof d === 'object' && d ? d.short || dayText(d) : dayText(d).split(' ').at(-1)));
|
||||
const wide = (labs) => Math.max(...labs.map((t) => measure(s, t, 12))) + 6;
|
||||
const labs = wide(full) <= cw ? full : short, k = Math.max(1, Math.ceil(wide(labs) / cw));
|
||||
labs.forEach((t, i) => {
|
||||
if (i !== today && (i % k || (today != null && k > 1 && Math.abs(i - today) < k))) return;
|
||||
text(g, t, { x: x0 + i * cw + cw / 2, y, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': i === today ? 600 : 400, fill: i === today ? ink : muted });
|
||||
});
|
||||
}
|
||||
// The style of each kind of booking: a pale fill, a 1px diagonal hatch, a darker grey step, an outline, or the one
|
||||
// chart hue for the screen's main data element. tone: 'negative' turns the edge and the label red (it needs a decision).
|
||||
function kindStyle(el, k = {}) {
|
||||
const ink = css(el, '--ink', '#111'), surface = css(el, '--surface', '#fff'), neg = css(el, '--negative', '#c4242a'), hue = k.colour || css(el, '--chart-1', '#2f6fdf');
|
||||
const tone = k.tone === 'negative' ? { stroke: neg, width: 1.5 } : {};
|
||||
const dash = k.dash ? { dash: '3 2' } : {};
|
||||
if (k.mark === 'hatch') return { fill: mix(el, ink, surface, 0.04), hatch: mix(el, ink, surface, 0.32), ...tone, ...dash };
|
||||
if (k.mark === 'step') return { fill: mix(el, ink, surface, 0.24), ...tone, ...dash };
|
||||
if (k.mark === 'outline') return { fill: surface, stroke: mix(el, ink, surface, 0.55), width: 1, ...tone, ...dash };
|
||||
if (k.mark === 'hue') return { fill: mix(el, hue, surface, 0.18), edge: hue, ...tone, ...dash };
|
||||
return { fill: mix(el, ink, surface, 0.12), ...tone, ...dash };
|
||||
}
|
||||
// Who is away when: people (or rooms, vans) down the left, days across, a bar per booking told apart by its kind.
|
||||
// rows: [{ label: 'Lena Hoffmann', sub: 'Product design · Berlin', bars: [{ from: 3, to: 7, kind: 'holiday', label, note }] }]
|
||||
// and { group: 'Brand' } rows for a team subheader
|
||||
// kinds: { holiday: { label: 'Holiday', mark: 'fill' | 'hatch' | 'step' | 'outline' | 'hue', code: 'H', tone, dash } }
|
||||
// days: ['Mon 19', …] or [{ label: 'Mon 19', short: '19' }]; weekends: [indices]; today: index;
|
||||
// holidays: [{ day: index, label: 'All Saints’ Day (Lisbon)' }]; onSelect(bar, row) makes the bars buttons.
|
||||
// A label that does not fit its bar becomes the kind's code inside it, then goes after the bar's end (never into the
|
||||
// days before it), and is always in the bar's tooltip.
|
||||
function timeline(el, rows, kinds = {}, { days = [], weekends = [], today = null, holidays = [], labelWidth = null, rowHeight = null, barHeight = 22, onSelect = null, legend = null, extras = true } = {}) {
|
||||
const draw = () => {
|
||||
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 30, groupH = 30;
|
||||
const height = head + rows.reduce((a, r) => a + (r.group ? groupH : pitch), 0) + 2;
|
||||
const { s, w, h } = frame(el, height);
|
||||
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.2)));
|
||||
const cw = (w - lw) / Math.max(1, days.length), col = (i) => lw + i * cw;
|
||||
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0'), raised = css(el, '--raised', '#f4f5f6'), hue = css(el, '--chart-1', '#2f6fdf');
|
||||
const g = svg('g', {}, s);
|
||||
// weekends one tone step darker, faint day lines, the day header (today in ink)
|
||||
for (const i of weekends) svg('rect', { x: col(i), y: head, width: cw, height: h - head, fill: raised }, g);
|
||||
for (let i = 0; i <= days.length; i++) svg('line', { x1: col(i), x2: col(i), y1: head, y2: h, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
||||
dayHeads(g, s, el, days, { x0: lw, cw, y: head - 10, today });
|
||||
for (const hd of holidays) { const c = svg('circle', { cx: col(hd.day) + cw / 2, cy: head - 3, r: 2, fill: muted }, g); titled(c, hd.label, {}); }
|
||||
if (today != null) svg('line', { x1: col(today) + cw / 2, x2: col(today) + cw / 2, y1: head, y2: h, stroke: hue, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g);
|
||||
let y0 = head;
|
||||
for (const r of rows) {
|
||||
if (r.group) { svg('line', { x1: 0, x2: w, y1: y0 + 0.5, y2: y0 + 0.5, stroke: rule, 'stroke-width': 1 }, g); text(g, r.group, { x: 0, y: y0 + 20, 'font-size': 12, 'font-weight': 500, fill: muted }); y0 += groupH; continue; }
|
||||
rowLabel(g, s, el, r, { y0, pitch, lw });
|
||||
const bs = (r.bars || []).filter((b) => b.to >= 0 && b.from < days.length).sort((a, b) => a.from - b.from);
|
||||
bs.forEach((b, k) => {
|
||||
const kind = kinds[b.kind] || {}, st = kindStyle(el, kind);
|
||||
const x0 = col(Math.max(0, b.from)) + 2, x1 = col(Math.min(days.length - 1, b.to) + 1) - 2, by = y0 + (pitch - barHeight) / 2;
|
||||
const grp = svg('g', onSelect ? { tabindex: 0, role: 'button', style: 'cursor:pointer' } : {}, g);
|
||||
paintRect(grp, st, x0, by, x1 - x0, barHeight, 4);
|
||||
const label = b.label ?? kind.label ?? '', code = b.code ?? kind.code, tw = measure(s, label, 12), labInk = kind.tone === 'negative' ? css(el, '--negative', '#c4242a') : ink;
|
||||
const next = bs[k + 1] ? col(Math.max(0, bs[k + 1].from)) : w;
|
||||
// over a hatch the label keeps a halo of the bar's own fill, so the lines never run through the words
|
||||
const halo = st.hatch ? { stroke: st.fill, 'stroke-width': 4, 'stroke-linejoin': 'round', 'paint-order': 'stroke' } : {};
|
||||
if (label && tw + 16 <= x1 - x0) text(grp, label, { x: x0 + 8 + (st.edge ? 2 : 0), y: by + barHeight / 2 + 4, 'font-size': 12, fill: labInk, ...halo });
|
||||
else if (code && measure(s, code, 12) + 8 <= x1 - x0) text(grp, code, { x: (x0 + x1) / 2, y: by + barHeight / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, fill: labInk, ...halo });
|
||||
else if (label && x1 + 6 + tw <= Math.min(next - 6, w)) text(grp, label, { x: x1 + 6, y: by + barHeight / 2 + 4, 'font-size': 12, fill: kind.tone === 'negative' ? labInk : muted });
|
||||
titled(grp, `${r.label}: ${label}${b.note ? ' \u00b7 ' + b.note : ''}`, {});
|
||||
if (onSelect) { grp.addEventListener('click', () => onSelect(b, r)); grp.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(b, r); } }); }
|
||||
});
|
||||
y0 += pitch;
|
||||
}
|
||||
s.setAttribute('aria-label', `${rows.filter((r) => !r.group).length} rows across ${days.length} days, ${dayText(days[0])} to ${dayText(days.at(-1))}`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
const used = new Set(rows.flatMap((r) => (r.bars || []).map((b) => b.kind)));
|
||||
const items = Object.entries(kinds).filter(([id]) => used.has(id)).map(([, k]) => ({ label: k.label, w: 22, draw: (sw) => { const st = kindStyle(el, k); paintRect(sw, st, 0, 0, 22, 12, 3); } }));
|
||||
if (extras && today != null) items.push({ label: 'Today', w: 8, draw: (sw) => svg('line', { x1: 4, x2: 4, y1: 0, y2: 12, stroke: css(el, '--chart-1', '#2f6fdf'), 'stroke-width': 1.5 }, sw) });
|
||||
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
||||
}
|
||||
// Is there enough cover? A team-by-day grid of slack against the minimum (+2, +1, 0, −1). Cells with room to spare
|
||||
// stay plain, and one hue ramps toward the negative colour as the slack runs out; the headcount is in the tooltip.
|
||||
// rows: [{ label: 'Product design', sub: 'needs 7 of 10', values: [2, 1, 0, -1, null, …], titles: ['8 in', …] }]
|
||||
// cols: ['Mon 19', …]; comfort: the slack that needs no attention (2); null is a day with no cover to count (a dash)
|
||||
function matrix(el, rows, { cols = [], comfort = 2, today = null, format = fmt.signed(), labelWidth = null, rowHeight = null, levels = ['Room to spare', 'Tight', 'At the minimum', 'Short'], legend = null } = {}) {
|
||||
const level = (v) => (v >= comfort ? 0 : v > 0 ? 1 : v === 0 ? 2 : 3);
|
||||
const styles = () => { const neg = css(el, '--negative', '#c4242a'), surface = css(el, '--surface', '#fff'), ink = css(el, '--ink', '#111'); return [{ fill: surface, ink: css(el, '--axis-ink', '#6b6b6b'), stroke: css(el, '--grid', '#e5e5e5') }, { fill: mix(el, neg, surface, 0.1), ink }, { fill: mix(el, neg, surface, 0.26), ink }, { fill: neg, ink: surface }]; };
|
||||
const draw = () => {
|
||||
const pitch = rowHeight || (rows.some((r) => r.sub) ? 44 : 32), head = 26;
|
||||
const { s, w, h } = frame(el, head + rows.length * pitch);
|
||||
const lw = labelWidth || Math.round(Math.min(220, Math.max(w < 480 ? 96 : 120, w * 0.22)));
|
||||
const cw = (w - lw) / Math.max(1, cols.length), ST = styles(), muted = css(el, '--axis-ink', '#6b6b6b'), ink = css(el, '--ink', '#111');
|
||||
const g = svg('g', {}, s);
|
||||
dayHeads(g, s, el, cols, { x0: lw, cw, y: head - 10, today });
|
||||
rows.forEach((r, k) => {
|
||||
const y0 = head + k * pitch; rowLabel(g, s, el, r, { y0, pitch, lw });
|
||||
const ch = Math.min(pitch - 6, 28), cy = y0 + (pitch - ch) / 2;
|
||||
r.values.forEach((v, i) => {
|
||||
const cx = lw + i * cw + 1;
|
||||
if (v == null) { svg('rect', { x: cx + cw / 2 - 4, y: cy + ch / 2 - 1, width: 6, height: 2, fill: muted }, g); return; }
|
||||
const st = ST[level(v)], cell = paintRect(g, { fill: st.fill }, cx, cy, cw - 2, ch, 3);
|
||||
text(g, format(v), { x: cx + (cw - 2) / 2, y: cy + ch / 2 + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': level(v) === 3 ? 600 : 400, fill: st.ink });
|
||||
titled(cell, `${r.label}, ${dayText(cols[i])}: ${r.titles?.[i] || format(v) + ' against the minimum'}`, {});
|
||||
});
|
||||
});
|
||||
s.setAttribute('aria-label', `Cover against the minimum for ${rows.length} rows across ${cols.length} days`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
const seen = new Set(rows.flatMap((r) => r.values.filter((v) => v != null).map(level)));
|
||||
const ST = styles();
|
||||
const items = [0, 1, 2, 3].filter((l) => seen.has(l)).map((l) => ({ label: levels[l], w: 14, draw: rectSwatch(l === 0 ? { fill: ST[0].fill, stroke: ST[0].stroke } : { fill: ST[l].fill }, 14, 12, 3) }));
|
||||
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
||||
}
|
||||
// When, within the day: one row per item on a shared open–close axis. A 2px grey track, an ink dot at today's time and
|
||||
// a hollow tick at the comparison day's, so "earlier than last Sunday" is drawn and not only written.
|
||||
// rows: [{ label: 'White sourdough', sub: 'Headingley', at: '11:20', compare: '12:05', value: '11:20', note: '3 h 40 m before close' }]
|
||||
// at or compare may be null (it did not sell out); step: minutes between axis labels (60).
|
||||
function timeStrip(el, rows, { open = '08:00', close = '18:00', step = 60, todayLabel = 'Today', compareLabel = null, labelWidth = null, valueWidth = null, rowHeight = null, legend = null } = {}) {
|
||||
const o = minutes(open), c = minutes(close);
|
||||
const st = { today: { r: 4.5 }, compare: { w: 5, h: 12 } };
|
||||
const dot = (parent, cx, cy) => svg('circle', { cx, cy, r: st.today.r, fill: css(el, '--ink', '#111') }, parent);
|
||||
const tick = (parent, cx, cy) => svg('rect', { x: cx - st.compare.w / 2, y: cy - st.compare.h / 2, width: st.compare.w, height: st.compare.h, rx: 2.5, fill: css(el, '--surface', '#fff'), stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1.5 }, parent);
|
||||
const draw = () => {
|
||||
const narrow = (el.clientWidth || 600) < 560, axisH = 24;
|
||||
const pitch = rowHeight || (narrow ? 62 : rows.some((r) => r.sub || r.note) ? 44 : 32);
|
||||
const { s, w, h } = frame(el, rows.length * pitch + axisH);
|
||||
const lw = narrow ? 0 : labelWidth || Math.round(Math.min(200, Math.max(110, w * 0.26)));
|
||||
const vw = narrow ? 0 : valueWidth ?? Math.min(w * 0.32, Math.max(0, ...rows.map((r) => Math.max(r.value ? measure(s, r.value, 13, 500) : 0, r.note ? measure(s, r.note, 12) : 0))) + (rows.some((r) => r.value || r.note) ? 16 : 0));
|
||||
const x0 = narrow ? 18 : lw + 6, x1 = narrow ? w - 18 : w - vw - 6, tx = (m) => x0 + ((m - o) / Math.max(1, c - o)) * (x1 - x0);
|
||||
const ink = css(el, '--ink', '#111'), muted = css(el, '--axis-ink', '#6b6b6b'), rule = css(el, '--rule', '#f0f0f0');
|
||||
const track = css(el, '--chart-track', mix(el, ink, css(el, '--surface', '#fff'), 0.16));
|
||||
const g = svg('g', {}, s), plotH = rows.length * pitch;
|
||||
// the axis: one step from the opening, the labels thinned by the same rule as the charts
|
||||
const marks = []; for (let m = Math.ceil(o / step) * step; m <= c; m += step) marks.push(m);
|
||||
const picks = xPicks(marks.map(fmt.clock), x1 - x0);
|
||||
for (const i of picks) { const xx = tx(marks[i]); if (!narrow) svg('line', { x1: xx, x2: xx, y1: 0, y2: plotH, stroke: rule, 'stroke-width': 1, 'shape-rendering': 'crispEdges' }, g); const lab = fmt.clock(marks[i]), tw = measure(s, lab, 12); text(g, lab, { x: Math.max(x0 - 2, Math.min(x1 + 6 - tw, xx - tw / 2)), y: plotH + 17, 'font-size': 12, fill: muted }); }
|
||||
rows.forEach((r, k) => {
|
||||
const y0 = k * pitch, cy = narrow ? y0 + pitch - 12 : y0 + pitch / 2;
|
||||
if (narrow) {
|
||||
const vwid = r.value ? measure(s, r.value, 13, 500) + 12 : 0;
|
||||
text(g, fit(s, r.label, w - vwid, 14), { x: 0, y: y0 + 16, 'font-size': 14, fill: ink });
|
||||
if (r.value) text(g, r.value, { x: w, y: y0 + 16, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
|
||||
const second = [r.sub, r.note].filter(Boolean).join(' \u00b7 ');
|
||||
if (second) text(g, fit(s, second, w, 12), { x: 0, y: y0 + 33, 'font-size': 12, fill: muted });
|
||||
} else rowLabel(g, s, el, r, { y0, pitch, lw });
|
||||
svg('line', { x1: x0, x2: x1, y1: cy, y2: cy, stroke: track, 'stroke-width': 2, 'stroke-linecap': 'round' }, g);
|
||||
const cm = minutes(r.compare), am = minutes(r.at);
|
||||
if (cm != null) titled(tick(g, tx(cm), cy), `${compareLabel || 'Comparison'}: ${fmt.clock(cm)}`, {});
|
||||
if (am != null) titled(dot(g, tx(am), cy), `${todayLabel}: ${fmt.clock(am)}`, {});
|
||||
if (narrow) return;
|
||||
if (r.note) { if (r.value) text(g, r.value, { x: w, y: cy - 3, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink }); text(g, fit(s, r.note, vw - 8, 12), { x: w, y: r.value ? cy + 13 : cy + 4, 'text-anchor': 'end', 'font-size': 12, fill: muted }); }
|
||||
else if (r.value) text(g, r.value, { x: w, y: cy + 5, 'text-anchor': 'end', 'font-size': 13, 'font-weight': 500, fill: ink });
|
||||
});
|
||||
s.setAttribute('aria-label', `${rows.length} rows between ${fmt.clock(o)} and ${fmt.clock(c)}`);
|
||||
};
|
||||
draw(); watch(el, draw); settle(draw);
|
||||
const items = [];
|
||||
if (rows.some((r) => minutes(r.at) != null)) items.push({ label: todayLabel, w: 10, draw: (sw) => dot(sw, 5, 6) });
|
||||
if (compareLabel && rows.some((r) => minutes(r.compare) != null)) items.push({ label: compareLabel, w: 8, draw: (sw) => tick(sw, 4, 6) });
|
||||
return { get svg() { return el.querySelector(':scope > svg'); }, legend: legendEl(items, legend) };
|
||||
}
|
||||
function watch(el, draw) { if (!('ResizeObserver' in window)) return; let w = el.clientWidth; new ResizeObserver(() => { if (Math.abs(el.clientWidth - w) > 4) { w = el.clientWidth; draw(); } }).observe(el); }
|
||||
window.Charts = { line, bars, diverging, ring, barList, plan, bullet, timeline, matrix, timeStrip, ticks, fmt };
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-radius="soft">
|
||||
<head>
|
||||
<script>window.__tpGlMax = 16; /* a gallery shows many live effects at once; a page of a site keeps the default 4 (fx.js) */</script>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Component lab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../system.css">
|
||||
<link rel="stylesheet" href="./base.css">
|
||||
<style>
|
||||
/* one component on its own, with a chosen effect (or object) and palette:
|
||||
_lab.html?c=fx-chart&fx=ruled-grounds&palette=white-link-blue&w=640 */
|
||||
:root { --font-display: "Geist", system-ui, sans-serif; --font-text: "Geist", system-ui, sans-serif; --font-mono: "Geist Mono", ui-monospace, monospace; --display-weight: 560; --display-tracking: -0.03em; }
|
||||
body { min-height: 100vh; margin: 0; }
|
||||
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); font: 13px/1.4 var(--font-text); color: var(--muted); }
|
||||
.bar select { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-sm); height: 30px; padding: 0 8px; }
|
||||
.stage { padding: clamp(16px, 4vw, 48px); margin-inline: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<label>Part <select id="c"></select></label>
|
||||
<label>Effect <select id="fx"></select></label>
|
||||
<label>Palette <select id="pal"></select></label>
|
||||
</div>
|
||||
<main class="stage" id="stage"></main>
|
||||
<script type="module">
|
||||
import fx from '../effects/fx.js';
|
||||
const PARTS = ['fx-feature-card', 'fx-dither-object', 'fx-headline', 'fx-button', 'fx-prompt', 'fx-icon-tiles', 'fx-stat', 'fx-globe', 'fx-map', 'fx-chart', 'fx-bars', 'fx-band', 'fx-spotlight', 'fx-photo', 'fx-avatar', 'fx-search', 'fx-logos', 'fx-tabs', 'fx-pricing', 'fx-progress'];
|
||||
const PALETTES = {
|
||||
'blue-black-cornflower': { bg: '#0a0d14', surface: '#131824', ink: '#e9edf3', muted: '#8891a1', accent: '#5ab9fc', accentInk: '#0a0d14' },
|
||||
'graphite-safety-orange': { bg: '#18191b', surface: '#222427', ink: '#eff0f1', muted: '#9c9ea3', accent: '#ff4a1f', accentInk: '#18191b' },
|
||||
'white-link-blue': { bg: '#ffffff', surface: '#f4f4f4', ink: '#111111', muted: '#6b6b6b', accent: '#1a4be0', accentInk: '#ffffff' },
|
||||
'moss-sand': { bg: '#ddd6c6', surface: '#d2cab8', ink: '#2a2a26', muted: '#59574e', accent: '#4f6238', accentInk: '#ffffff' },
|
||||
'cobalt-field': { bg: '#0034eb', surface: '#002fd6', ink: '#ffffff', muted: '#dfe4f2', accent: '#e9e34a', accentInk: '#111111' },
|
||||
};
|
||||
const q = new URLSearchParams(location.search);
|
||||
const part = PARTS.includes(q.get('c')) ? q.get('c') : PARTS[0];
|
||||
const palId = PALETTES[q.get('palette')] ? q.get('palette') : 'blue-black-cornflower';
|
||||
const p = PALETTES[palId];
|
||||
const s = document.documentElement.style;
|
||||
const action = fx.color.contrast(p.accent, p.bg) >= 2.2 ? p.accent : p.ink;
|
||||
Object.entries({ '--p-bg': p.bg, '--p-surface': p.surface, '--p-ink': p.ink, '--p-muted': p.muted, '--p-accent': p.accent, '--p-accent-ink': p.accentInk, '--p-action': action, '--p-action-ink': action === p.accent ? p.accentInk : p.bg }).forEach(([k, v]) => s.setProperty(k, v));
|
||||
s.colorScheme = fx.color.isDark(p.bg) ? 'dark' : 'light';
|
||||
if (q.has('fallback')) fx.config.forceFallback = true;
|
||||
// one part at a time: at most 4 live WebGL contexts on this page
|
||||
fx.config.maxLive = 4;
|
||||
if (q.has('w')) document.getElementById('stage').style.maxWidth = q.get('w') + 'px';
|
||||
|
||||
const base = `./${part}/`;
|
||||
const [meta, html, mod] = await Promise.all([fetch(base + 'meta.json').then(r => r.json()), fetch(base + 'component.html').then(r => r.text()), import(base + 'component.js')]);
|
||||
const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: base + 'component.css' });
|
||||
document.head.append(link);
|
||||
await new Promise(r => { link.onload = link.onerror = r; });
|
||||
const choices = meta.effects?.length ? meta.effects : meta.objects || [];
|
||||
const chosen = choices.includes(q.get('fx')) ? q.get('fx') : meta.default;
|
||||
const stage = document.getElementById('stage');
|
||||
stage.innerHTML = html;
|
||||
const root = stage.firstElementChild;
|
||||
// the chosen effect goes where the part reads it: data-cx-object, data-cx-fill, or data-cx-effect on the root
|
||||
// the dither object: one figure set to the chosen object (one live object, as on a page); no caption under it
|
||||
if (meta.objects) {
|
||||
const fig = root.matches('[data-cx-object]') ? root : root.querySelector('[data-cx-object]');
|
||||
fig.dataset.cxObject = chosen;
|
||||
if (q.get('render') === 'studio') fig.dataset.cxRender = 'studio';
|
||||
fig.querySelector('.cx-obj__stage').removeAttribute('aria-label');
|
||||
fig.style.maxWidth = '720px';
|
||||
}
|
||||
// without ?fx= the markup keeps its own effects (the feature card mixes a landscape and a grain: setting
|
||||
// the default on every panel would paint the grain panel as a landscape too)
|
||||
else if (!choices.includes(q.get('fx'))) { /* as written */ }
|
||||
else if (root.querySelector('[data-cx-fill]')) root.querySelectorAll('[data-cx-fill]').forEach(el => { el.dataset.cxFill = chosen; });
|
||||
else if (root.querySelector('[data-cx-effect]')) root.querySelectorAll('[data-cx-effect]').forEach(el => { el.dataset.cxEffect = chosen; });
|
||||
else root.dataset.cxEffect = chosen;
|
||||
// the map draws its baked streets; an effect named with ?fx= shows its fallback ground (no map data) instead
|
||||
if (part === 'fx-map' && choices.includes(q.get('fx'))) delete root.dataset.cxMap;
|
||||
await document.fonts.ready;
|
||||
window.__part = mod.default(root);
|
||||
setTimeout(() => { document.documentElement.dataset.ready = '1'; }, 1200);
|
||||
|
||||
const fill = (sel, list, cur, key) => {
|
||||
const el = document.getElementById(sel);
|
||||
el.innerHTML = list.map(v => `<option${v === cur ? ' selected' : ''}>${v}</option>`).join('');
|
||||
el.addEventListener('change', () => { q.set(key, el.value); if (key === 'c') q.delete('fx'); location.search = q.toString(); });
|
||||
};
|
||||
fill('c', PARTS, part, 'c');
|
||||
// a part whose default is none (the map's streets, the photograph as it is) lists none first, so the
|
||||
// select never shows an effect that is not on
|
||||
fill('fx', meta.default === 'none' ? ['none', ...choices] : choices, chosen, 'fx');
|
||||
fill('pal', Object.keys(PALETTES), palId, 'palette');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
taste-skill components - shared base. Link after system.css. Every component reads the page tokens
|
||||
(--bg --surface --ink --muted --accent --accent-ink --action --line, the radius, type and space
|
||||
scales) and adds only the few steps below. Classes start with cx-.
|
||||
*/
|
||||
.cx {
|
||||
--cx-card: color-mix(in oklab, var(--ink) 3.5%, var(--bg));
|
||||
--cx-panel: color-mix(in oklab, var(--ink) 6.5%, var(--bg));
|
||||
--cx-raise: color-mix(in oklab, var(--ink) 9%, var(--bg));
|
||||
--cx-line: color-mix(in oklab, var(--ink) 9%, transparent);
|
||||
--cx-line-2: color-mix(in oklab, var(--ink) 16%, transparent);
|
||||
--cx-hi: color-mix(in oklab, var(--ink) 7%, transparent);
|
||||
--cx-soft: color-mix(in oklab, var(--muted) 72%, var(--bg));
|
||||
--cx-shadow:
|
||||
0 0 0 1px var(--cx-line),
|
||||
0 1px 2px rgb(0 0 0 / .08),
|
||||
0 8px 24px -8px rgb(0 0 0 / .22),
|
||||
0 24px 56px -24px rgb(0 0 0 / .32);
|
||||
--cx-ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--cx-mono: var(--font-mono, ui-monospace, monospace);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-text);
|
||||
}
|
||||
.cx *, .cx *::before, .cx *::after { box-sizing: border-box; }
|
||||
|
||||
/* a card the system way, with a hairline top light so it does not read as a flat grey box */
|
||||
.cx-card {
|
||||
position: relative;
|
||||
background: var(--cx-card);
|
||||
border: 1px solid var(--cx-line);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: inset 0 1px 0 var(--cx-hi);
|
||||
}
|
||||
.cx-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font: 500 var(--t-label)/1.4 var(--font-text); color: var(--muted); letter-spacing: .01em;
|
||||
}
|
||||
.cx-title { font: 500 var(--t-card)/1.3 var(--font-text); letter-spacing: -.01em; color: var(--ink); margin: 0; }
|
||||
.cx-text { font-size: var(--t-small); line-height: 1.55; color: var(--muted); margin: 0; text-wrap: pretty; }
|
||||
.cx-num { font-variant-numeric: tabular-nums; }
|
||||
.cx-kbd {
|
||||
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
|
||||
border-radius: var(--r-sm); border: 1px solid var(--cx-line-2); background: var(--cx-panel);
|
||||
font: 500 11px/1 var(--cx-mono); color: var(--muted);
|
||||
}
|
||||
.cx-sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
/* a mono icon from an SVG file (Simple Icons): takes the text colour */
|
||||
.cx-glyph {
|
||||
display: inline-block; width: var(--size, 20px); height: var(--size, 20px); flex: none;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: var(--src) center / contain no-repeat; mask: var(--src) center / contain no-repeat;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- fill (see shared.js) */
|
||||
/* the overlay is the next sibling of the filled element; fill() sets its left, top, width and height */
|
||||
.cx-fill {
|
||||
position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; user-select: none;
|
||||
}
|
||||
.cx-fill--screen { mix-blend-mode: screen; }
|
||||
.cx-fill--multiply { mix-blend-mode: multiply; }
|
||||
.cx-filled [data-fill]:not(.cx-knock *),
|
||||
.cx-filled--all:not(.cx-knock),
|
||||
.cx-filled--all :not(.cx-fill, .cx-fill *) { color: transparent; }
|
||||
.cx-filled [data-fill="box"]:not(.cx-knock *) { background-color: transparent; }
|
||||
|
||||
.cx-knock {
|
||||
position: absolute !important; inset: 0 !important; margin: 0 !important;
|
||||
width: auto !important; height: auto !important; max-width: none !important;
|
||||
transform: none !important; animation: none !important; transition: none !important;
|
||||
}
|
||||
.cx-knock, .cx-knock * {
|
||||
background-color: transparent !important; border-color: transparent !important;
|
||||
box-shadow: none !important; outline: none !important; text-shadow: none !important;
|
||||
text-decoration-color: transparent !important; opacity: 1 !important; filter: none !important;
|
||||
animation: none !important; transition: none !important;
|
||||
}
|
||||
.cx-knock canvas, .cx-knock video { visibility: hidden !important; }
|
||||
.cx-knock img { visibility: hidden !important; }
|
||||
.cx-knock [data-fill] img { visibility: visible !important; }
|
||||
|
||||
.cx-fill--screen .cx-knock { mix-blend-mode: multiply; background-color: #000 !important; }
|
||||
.cx-fill--screen .cx-knock, .cx-fill--screen .cx-knock * { color: #000 !important; }
|
||||
.cx-fill--screen .cx-knock--all, .cx-fill--screen .cx-knock--all *,
|
||||
.cx-fill--screen .cx-knock [data-fill], .cx-fill--screen .cx-knock [data-fill] * { color: #fff !important; }
|
||||
.cx-fill--screen .cx-knock [data-fill] img { filter: brightness(0) invert(1) !important; }
|
||||
|
||||
.cx-fill--multiply .cx-knock { mix-blend-mode: screen; background-color: #fff !important; }
|
||||
.cx-fill--multiply .cx-knock, .cx-fill--multiply .cx-knock * { color: #fff !important; }
|
||||
.cx-fill--multiply .cx-knock--all, .cx-fill--multiply .cx-knock--all *,
|
||||
.cx-fill--multiply .cx-knock [data-fill], .cx-fill--multiply .cx-knock [data-fill] * { color: #000 !important; }
|
||||
.cx-fill--multiply .cx-knock [data-fill] img { filter: brightness(0) !important; }
|
||||
|
||||
.cx-knock [data-fill="box"], .cx-knock .cx-glyph { background-color: currentColor !important; }
|
||||
/* inside a filled box, its own glyphs do not punch holes */
|
||||
.cx-knock [data-fill="box"] .cx-glyph { background-color: transparent !important; }
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.cx-fill { display: none; }
|
||||
.cx-filled [data-fill], .cx-filled--all, .cx-filled--all * { color: CanvasText !important; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- ring and glow */
|
||||
/* an effect host shown only in its padding: a lit border of width --ring */
|
||||
.cx-ring {
|
||||
position: absolute; inset: calc(-1 * var(--ring, 1.5px)); padding: var(--ring, 1.5px);
|
||||
border-radius: inherit; pointer-events: none; z-index: 0;
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
}
|
||||
/* an effect host blurred into a soft light behind an object */
|
||||
.cx-glow {
|
||||
position: absolute; pointer-events: none; z-index: -1;
|
||||
filter: blur(var(--blur, 24px)); opacity: var(--glow, .5);
|
||||
transition: opacity .5s var(--cx-ease);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- pause (see pauser() in shared.js) */
|
||||
/* a solid disc in one corner of a looping visual, never a see-through blur; 32px in a 40px hit area */
|
||||
.cx-pause {
|
||||
--cx-pause-in: 12px;
|
||||
position: absolute; z-index: 6; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
|
||||
border-radius: 999px; border: 1px solid var(--cx-line-2, color-mix(in oklab, var(--ink) 16%, transparent));
|
||||
background: var(--bg); color: var(--ink); cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / .12), 0 4px 12px -4px rgb(0 0 0 / .25);
|
||||
opacity: .82; transition: opacity .2s var(--cx-ease, ease), border-color .2s var(--cx-ease, ease);
|
||||
}
|
||||
.cx-pause::before { content: ""; position: absolute; inset: -4px; border-radius: inherit; }
|
||||
.cx-pause:hover, .cx-pause:focus-visible { opacity: 1; border-color: color-mix(in oklab, var(--ink) 30%, transparent); }
|
||||
.cx-pause:focus-visible { outline: 2px solid var(--action, var(--accent)); outline-offset: 2px; }
|
||||
.cx-pause svg { fill: currentColor; }
|
||||
.cx-pause__on, .cx-pause.is-paused .cx-pause__off { display: none; }
|
||||
.cx-pause.is-paused .cx-pause__on { display: block; margin-left: 1px; }
|
||||
.cx-pause--br { right: var(--cx-pause-in); bottom: var(--cx-pause-in); }
|
||||
.cx-pause--bl { left: var(--cx-pause-in); bottom: var(--cx-pause-in); }
|
||||
.cx-pause--tr { right: var(--cx-pause-in); top: var(--cx-pause-in); }
|
||||
.cx-pause--tl { left: var(--cx-pause-in); top: var(--cx-pause-in); }
|
||||
.cx-pause--inline { position: relative; flex: none; margin-left: auto; width: 28px; height: 28px; box-shadow: none; }
|
||||
.cx-paused, .cx-paused *, .cx-paused *::before, .cx-paused *::after { animation-play-state: paused !important; }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-pause { display: none; } }
|
||||
@media (forced-colors: active) { .cx-pause { border-color: ButtonText; } }
|
||||
@@ -0,0 +1,42 @@
|
||||
/* fx-avatar: the speaker's avatar ring is the effect, with its own soft glow; it moves to whoever speaks */
|
||||
.cx-call { --ava: 64px; --pad: 16px; display: grid; gap: var(--s-6); padding: var(--s-6); width: min(100%, 460px); isolation: isolate; }
|
||||
.cx-call__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-4); }
|
||||
.cx-call__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
|
||||
.cx-call__meta { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
|
||||
.cx-call__live {
|
||||
display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; border-radius: 999px;
|
||||
border: 1px solid var(--cx-line-2); font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.cx-call__live span { width: 6px; height: 6px; border-radius: 9px; background: var(--accent); animation: cx-call-blink 2.4s ease-in-out infinite; }
|
||||
@keyframes cx-call-blink { 50% { opacity: .35; } }
|
||||
.cx-call__people { display: flex; justify-content: space-between; gap: var(--s-3); margin: 0; padding: 0; list-style: none; }
|
||||
.cx-call__person { position: relative; display: grid; justify-items: center; gap: 10px; }
|
||||
.cx-call__ava {
|
||||
position: relative; z-index: 2; display: grid; place-items: center; width: var(--ava); height: var(--ava); border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--accent) var(--a), var(--cx-raise)); color: var(--ink);
|
||||
font: 500 15px/1 var(--font-text); letter-spacing: .02em;
|
||||
box-shadow: inset 0 0 0 1px var(--cx-line);
|
||||
}
|
||||
.cx-call__name { font-size: var(--t-label); color: var(--muted); transition: color .3s var(--cx-ease); }
|
||||
.cx-call__person.is-speaking .cx-call__name { color: var(--ink); }
|
||||
.cx-call__fx {
|
||||
position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; border-radius: 999px;
|
||||
width: calc(var(--ava) + 2 * var(--pad)); height: calc(var(--ava) + 2 * var(--pad));
|
||||
transform: translate(var(--fx-x, -999px), var(--fx-y, 0));
|
||||
transition: transform .55s cubic-bezier(.3, .9, .3, 1);
|
||||
/* a 2.5px ring 2px off the avatar, then a soft glow fading out */
|
||||
-webkit-mask: radial-gradient(closest-side, transparent calc(100% - var(--pad) + 1.5px), #000 calc(100% - var(--pad) + 2.5px), #000 calc(100% - var(--pad) + 4.5px), rgb(0 0 0 / .32) calc(100% - var(--pad) + 5.5px), transparent 100%);
|
||||
mask: radial-gradient(closest-side, transparent calc(100% - var(--pad) + 1.5px), #000 calc(100% - var(--pad) + 2.5px), #000 calc(100% - var(--pad) + 4.5px), rgb(0 0 0 / .32) calc(100% - var(--pad) + 5.5px), transparent 100%);
|
||||
}
|
||||
.cx-call__status { display: flex; align-items: center; gap: 10px; margin: 0; font-size: var(--t-small); color: var(--muted); }
|
||||
.cx-call__status [data-speaker] { color: var(--ink); font-weight: 500; margin-right: -4px; }
|
||||
.cx-call__eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; }
|
||||
.cx-call__eq i { width: 3px; height: 100%; border-radius: 2px; background: var(--accent); transform-origin: bottom; animation: cx-call-eq 1.1s ease-in-out infinite; }
|
||||
.cx-call__eq i:nth-child(2) { animation-delay: -.35s; }
|
||||
.cx-call__eq i:nth-child(3) { animation-delay: -.7s; }
|
||||
@keyframes cx-call-eq { 0%, 100% { transform: scaleY(.35); } 50% { transform: scaleY(1); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cx-call__live span, .cx-call__eq i { animation: none; }
|
||||
.cx-call__fx { transition: none; }
|
||||
}
|
||||
@media (max-width: 459px) { .cx-call { --ava: 52px; --pad: 14px; padding: var(--s-5); } }
|
||||
@@ -0,0 +1,17 @@
|
||||
<article class="cx cx-card cx-call" data-cx-seed="11" aria-label="Call: Design review">
|
||||
<header class="cx-call__head">
|
||||
<div>
|
||||
<h3 class="cx-call__title">Design review</h3>
|
||||
<p class="cx-call__meta">4 people, started 12 minutes ago</p>
|
||||
</div>
|
||||
<span class="cx-call__live"><span aria-hidden="true"></span>Live</span>
|
||||
</header>
|
||||
<ul class="cx-call__people">
|
||||
<li class="cx-call__person is-speaking"><span class="cx-call__ava" style="--a: 38%" aria-hidden="true">MO</span><span class="cx-call__name">Maya</span></li>
|
||||
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 0%" aria-hidden="true">JK</span><span class="cx-call__name">Jonas</span></li>
|
||||
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 18%" aria-hidden="true">AR</span><span class="cx-call__name">Amara</span></li>
|
||||
<li class="cx-call__person"><span class="cx-call__ava" style="--a: 8%" aria-hidden="true">LT</span><span class="cx-call__name">Leo</span></li>
|
||||
</ul>
|
||||
<p class="cx-call__status"><span class="cx-call__eq" aria-hidden="true"><i></i><i></i><i></i></span><span data-speaker>Maya</span> is speaking</p>
|
||||
<div class="cx-call__fx" aria-hidden="true"></div>
|
||||
</article>
|
||||
@@ -0,0 +1,55 @@
|
||||
// fx-avatar: one small effect host shaped by a radial mask into a ring with a soft glow; it sits on the
|
||||
// speaking person's avatar and glides to the next speaker every few seconds (the card never moves).
|
||||
// A pause button at the end of the status line holds the ring, the turns and the level bars.
|
||||
import { mount, palette, accentPalette, color, whileVisible, bag, pauser, tuned } from '../shared.js';
|
||||
|
||||
// each effect this part takes, tuned on its own: one effect's params mean something else to another
|
||||
const PARAMS = {
|
||||
'gradient-mesh': { speed: 0.35, lines: 0, warp: 0.8, softness: 0.4, tint: 1, calm: 0 },
|
||||
'silk-folds': { speed: 0.3, folds: 0.7, sheen: 0.7, scale: 1.2, grain: 0, calm: 0 },
|
||||
'aurora-curtains': { speed: 0.25, bands: 3, height: 0.8, intensity: 1.6, stars: 0, calm: 0 },
|
||||
'grain-gradient': { speed: 1, softness: 0.5, warp: 0.7, grain: 0.3, grainMotion: 0, tint: 1, contrast: 0.8, calm: 0 },
|
||||
};
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const host = root.querySelector('.cx-call__fx');
|
||||
const people = [...root.querySelectorAll('.cx-call__person')];
|
||||
const name = root.querySelector('[data-speaker]');
|
||||
const page = palette(root);
|
||||
const ring = accentPalette(page, { min: 1, lift: 0.2 });
|
||||
const p = b.add(pauser(root, { label: 'the call preview', into: root.querySelector('.cx-call__status') }));
|
||||
const effect = root.dataset.cxEffect || 'gradient-mesh';
|
||||
b.add(p.add(mount(host, effect, {
|
||||
palette: { ...ring, bg: color.mix(page.accent, page.ink, 0.25) },
|
||||
seed: root.dataset.cxSeed,
|
||||
params: tuned(PARAMS, effect, { speed: 0.3, calm: 0 }),
|
||||
})));
|
||||
|
||||
let i = Math.max(0, people.findIndex(p => p.classList.contains('is-speaking')));
|
||||
const place = () => {
|
||||
const ava = people[i].querySelector('.cx-call__ava');
|
||||
const r = root.getBoundingClientRect(), a = ava.getBoundingClientRect();
|
||||
const pad = (host.offsetWidth - a.width) / 2;
|
||||
root.style.setProperty('--fx-x', `${(a.left - r.left - pad).toFixed(1)}px`);
|
||||
root.style.setProperty('--fx-y', `${(a.top - r.top - pad).toFixed(1)}px`);
|
||||
};
|
||||
const speak = n => {
|
||||
people[i].classList.remove('is-speaking');
|
||||
i = n;
|
||||
people[i].classList.add('is-speaking');
|
||||
name.textContent = people[i].querySelector('.cx-call__name').textContent;
|
||||
place();
|
||||
};
|
||||
// the first placement without a glide
|
||||
host.style.transition = 'none';
|
||||
place();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { host.style.transition = ''; }));
|
||||
const ro = new ResizeObserver(place);
|
||||
ro.observe(root);
|
||||
b.add(() => ro.disconnect());
|
||||
const order = [0, 2, 1, 0, 3, 2];
|
||||
let step = 0;
|
||||
b.add(whileVisible(root, { every: 3400, run: () => { step = (step + 1) % order.length; speak(order[step] % people.length); } }));
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-avatar",
|
||||
"name": "Speaker ring",
|
||||
"for": "A call, presence or team card: the active person's avatar gets a thin ring of the effect with its own soft glow, and the ring glides to whoever speaks next.",
|
||||
"effects": ["gradient-mesh", "silk-folds", "aurora-curtains", "grain-gradient"],
|
||||
"default": "gradient-mesh",
|
||||
"how": "One small effect host (avatar plus 16px) shaped by a radial mask into a 2.5px ring and a fading glow; it moves by transform, so one canvas serves every avatar.",
|
||||
"rules": ["One lit ring at a time.", "The ring moves only on a state change (a new speaker, a status), never on a timer faster than 3 s.", "Avatars are the people's own photos or initials, never stock faces."],
|
||||
"a11y": "The speaker is named in text next to the bars; the ring and bars are decorative. Reduced motion holds the first speaker and stops the bars."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* fx-band: a divider band between two sections, its effect kept to the far side of the headline */
|
||||
.cx-band {
|
||||
position: relative; isolation: isolate; overflow: hidden; display: flex; align-items: center;
|
||||
min-height: 320px; padding: var(--s-7) var(--s-8); border-radius: var(--r-lg);
|
||||
background: var(--cx-card); border: 1px solid var(--cx-line);
|
||||
}
|
||||
/* the effect lives in the right part and fades in from the copy's side; the script sets its left edge
|
||||
48px past the copy's widest line (this 52% is the picture before it runs) */
|
||||
.cx-band__fx {
|
||||
position: absolute; top: 0; right: 0; bottom: 0; left: 52%; z-index: 0;
|
||||
-webkit-mask: linear-gradient(90deg, transparent, #000 22%); mask: linear-gradient(90deg, transparent, #000 22%);
|
||||
}
|
||||
.cx-band__copy { position: relative; z-index: 1; display: grid; justify-items: start; gap: var(--s-3); max-width: 460px; }
|
||||
.cx-band__title { font-size: var(--t-h2); line-height: 1.08; margin: 0; }
|
||||
.cx-band__text { color: var(--muted); font-size: var(--t-body); line-height: 1.55; margin: 0 0 var(--s-2); max-width: 40ch; }
|
||||
@media (max-width: 899px) {
|
||||
.cx-band { padding: var(--s-6); min-height: 460px; align-items: flex-start; }
|
||||
.cx-band__fx { left: 0; top: 46%; -webkit-mask: linear-gradient(180deg, transparent, #000 30%); mask: linear-gradient(180deg, transparent, #000 30%); }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.cx-band { padding: var(--s-5); min-height: 480px; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<section class="cx cx-band" data-cx-seed="6" aria-labelledby="cx-band-title">
|
||||
<div class="cx-band__fx" aria-hidden="true"></div>
|
||||
<div class="cx-band__copy">
|
||||
<span class="cx-eyebrow">Reporting</span>
|
||||
<h2 class="cx-band__title" id="cx-band-title">See every payment in one ledger</h2>
|
||||
<p class="cx-band__text">Filter by customer, currency or date, then export to CSV or your accounting tool.</p>
|
||||
<a class="link" href="#">How reporting works</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,51 @@
|
||||
// fx-band: a quiet line effect on the band's far side (topographic contours by default), softer than
|
||||
// the ink so the headline stays the strongest thing in the band. On a wide band the effect starts 48px
|
||||
// past the end of the copy (measured, so a longer line of text pushes it right); stacked, it sits under
|
||||
// the copy. Every effect here is still: a drift so slow that nobody sees it is not worth a frame loop
|
||||
// on every screen, so the band is drawn once, kept as a picture (no WebGL context at rest) and needs no
|
||||
// pause button.
|
||||
import { still, palette, tone, color, bag } from '../shared.js';
|
||||
|
||||
const GAP = 48;
|
||||
// every effect the band takes, tuned quiet: thin lines, no accent floods, no calm hole (the host is
|
||||
// already kept off the copy), no motion. ridgeline only when the page names it (it reads as a record cover).
|
||||
const PARAMS = {
|
||||
'contour-lines': { levels: 20, scale: 1.3, lineWidth: 1, indexEvery: 5, opacity: 0.42, speed: 0, tint: 0.45, shade: 0.25, edge: 0.35, calm: 0 },
|
||||
'line-engraving': { pitch: 6, source: 1, cross: 0.3, drift: 0, size: 1.1, calm: 0 },
|
||||
'dot-terrain': { spacing: 12, dot: 2.2, speed: 0, opacity: 0.7, crest: 0.6, haze: 0.2, calm: 0 },
|
||||
'soft-light-bars': { speed: 0, glow: 0.6, haze: 0.3, grain: 0.4, calm: 0 },
|
||||
ridgeline: { lines: 30, peak: 0.55, envelope: 0.36, span: 0.9, focus: 0.58, lineWidth: 1, speed: 0, calm: 0 },
|
||||
};
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const host = root.querySelector('.cx-band__fx');
|
||||
const copy = root.querySelector('.cx-band__copy');
|
||||
const page = palette(root);
|
||||
const card = tone(page, 0.035);
|
||||
// the copy's right edge is its widest line, not its max-width box
|
||||
const place = () => {
|
||||
if (getComputedStyle(root).flexDirection === 'column' || root.clientWidth < 900) { host.style.left = ''; return; }
|
||||
const rr = root.getBoundingClientRect();
|
||||
let right = 0;
|
||||
for (const n of copy.children) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(n);
|
||||
for (const r of range.getClientRects()) right = Math.max(right, r.right);
|
||||
}
|
||||
host.style.left = `${Math.round(right - rr.left + GAP)}px`;
|
||||
};
|
||||
place();
|
||||
const ro = new ResizeObserver(place);
|
||||
ro.observe(root);
|
||||
document.fonts?.ready.then(place);
|
||||
b.add(() => { ro.disconnect(); host.style.left = ''; });
|
||||
const effect = root.dataset.cxEffect || 'contour-lines';
|
||||
b.add(still(host, effect, {
|
||||
palette: { ...page, bg: card, ink: color.mix(page.ink, card, 0.35) },
|
||||
seed: root.dataset.cxSeed,
|
||||
// thin lines need more strength on a dark ground to read at all
|
||||
params: { ...(PARAMS[effect] || { calm: 0 }), ...(effect === 'contour-lines' && color.isDark(page.bg) ? { opacity: 0.62 } : {}), speed: 0, drift: 0 },
|
||||
}));
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "fx-band",
|
||||
"name": "Divider band",
|
||||
"for": "A short band between two sections that names the next part of the page: an eyebrow, one headline, one line and a link, with quiet topographic lines on the far side.",
|
||||
"effects": [
|
||||
"contour-lines",
|
||||
"line-engraving",
|
||||
"dot-terrain",
|
||||
"soft-light-bars",
|
||||
"ridgeline"
|
||||
],
|
||||
"default": "contour-lines",
|
||||
"how": "The effect host starts 48px past the end of the copy's widest line (measured) and fades in from that side; stacked, it sits under the copy. Each effect has quiet settings in component.js, all still: the band is drawn once and kept as a picture (no WebGL context and no frames at rest), so it needs no pause button.",
|
||||
"rules": [
|
||||
"At most one band per page, never two in a row.",
|
||||
"Quiet line or dot effects only: no blobs or bokeh.",
|
||||
"ridgeline only when the page names it (alone it reads as a record cover).",
|
||||
"No line starts under the text: keep 48px clear after the copy.",
|
||||
"The band never moves with scroll."
|
||||
],
|
||||
"a11y": "A labelled section with a real heading and link; the lines are decorative and still."
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* fx-bars: a bar chart on a labelled scale whose bars are windows onto one still, low-contrast grain in the accent */
|
||||
.cx-bars { display: grid; gap: var(--s-5); padding: var(--s-6); min-width: 0; }
|
||||
.cx-bars__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-5); flex-wrap: wrap; }
|
||||
.cx-bars__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
|
||||
.cx-bars__sub { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
|
||||
.cx-bars__total { display: grid; justify-items: end; gap: 4px; }
|
||||
.cx-bars__label { font-size: var(--t-label); color: var(--muted); }
|
||||
.cx-bars__value { font: 560 32px/1 var(--font-display); letter-spacing: -0.03em; }
|
||||
.cx-bars__delta { font-size: var(--t-label); color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
|
||||
|
||||
/* the scale sits in a 40px column left of the plot; its rules run under the bars */
|
||||
.cx-bars__chart { --scale-w: 40px; display: grid; grid-template-columns: var(--scale-w) minmax(0, 1fr); row-gap: var(--s-3); }
|
||||
.cx-bars__scale { position: relative; grid-row: 1; grid-column: 1 / -1; pointer-events: none; }
|
||||
.cx-bars__scale span { position: absolute; left: var(--scale-w); right: 0; height: 0; border-top: 1px dashed var(--cx-line); }
|
||||
.cx-bars__scale span.is-base { border-top: 1px solid var(--cx-line-2); }
|
||||
.cx-bars__scale span::before {
|
||||
content: attr(data-v); position: absolute; right: calc(100% + 8px); top: 0; transform: translateY(-50%);
|
||||
font: 400 11px/1 var(--cx-mono); color: var(--muted); white-space: nowrap;
|
||||
}
|
||||
.cx-bars__plot { position: relative; isolation: isolate; grid-row: 1; grid-column: 2; height: 220px; }
|
||||
.cx-bars__cols { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: clamp(4px, 1.2%, 10px); }
|
||||
.cx-bars__col { position: relative; flex: 1; height: 100%; display: flex; align-items: flex-end; }
|
||||
.cx-bars__bar {
|
||||
position: relative; width: 100%; height: var(--h); border-radius: var(--r-sm) var(--r-sm) 0 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
.cx-bars.is-live .cx-bars__bar { background: transparent; }
|
||||
/* the latest month is marked by a thin ring set off from the bar (outside the effect window), never by
|
||||
veiling the others: a veil of the card colour turns a warm accent muddy on a coloured ground */
|
||||
.cx-bars__col:last-child .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--accent); }
|
||||
.cx-bars__col:hover .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--cx-line-2); }
|
||||
.cx-bars__col:last-child:hover .cx-bars__bar { box-shadow: 0 0 0 2px var(--cx-card), 0 0 0 3.5px var(--accent); }
|
||||
/* a bar's value shows on hover; October's is in the header, so its tag stays hidden */
|
||||
.cx-bars__tag {
|
||||
position: absolute; left: 50%; bottom: calc(var(--h) + 8px); transform: translateX(-50%); z-index: 3;
|
||||
font: 500 11px/1 var(--cx-mono); color: var(--ink); white-space: nowrap; opacity: 0; transition: opacity .15s var(--cx-ease);
|
||||
padding: 3px 5px; border-radius: var(--r-sm); background: var(--cx-card);
|
||||
}
|
||||
.cx-bars__col:not(:last-child):hover .cx-bars__tag { opacity: 1; }
|
||||
.cx-bars__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
|
||||
.cx-bars__axis { grid-row: 2; grid-column: 2; display: flex; gap: clamp(4px, 1.2%, 10px); font: 400 11px/1 var(--cx-mono); color: var(--muted); }
|
||||
.cx-bars__axis span { flex: 1; text-align: center; }
|
||||
@media (max-width: 639px) {
|
||||
.cx-bars { padding: var(--s-5); }
|
||||
.cx-bars__total { justify-items: start; }
|
||||
.cx-bars__plot { height: 180px; }
|
||||
.cx-bars__chart { --scale-w: 36px; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<article class="cx cx-card cx-bars" data-cx-seed="4">
|
||||
<header class="cx-bars__head">
|
||||
<div>
|
||||
<h3 class="cx-bars__title">Monthly revenue</h3>
|
||||
<p class="cx-bars__sub">Nov 2025 to Oct 2026</p>
|
||||
</div>
|
||||
<div class="cx-bars__total">
|
||||
<span class="cx-bars__label">October</span>
|
||||
<span class="cx-bars__value cx-num">$91.2k</span>
|
||||
<span class="cx-bars__delta">+9.7% on September</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="cx-bars__chart">
|
||||
<div class="cx-bars__scale" aria-hidden="true"></div>
|
||||
<div class="cx-bars__plot" aria-hidden="true">
|
||||
<div class="cx-bars__cols"><div class="cx-bars__col" style="--h:42.1%"><span class="cx-bars__tag">$42.1k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:47.8%"><span class="cx-bars__tag">$47.8k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:51.2%"><span class="cx-bars__tag">$51.2k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:49.4%"><span class="cx-bars__tag">$49.4k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:57.9%"><span class="cx-bars__tag">$57.9k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:63.3%"><span class="cx-bars__tag">$63.3k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:61.0%"><span class="cx-bars__tag">$61.0k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:69.8%"><span class="cx-bars__tag">$69.8k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:75.6%"><span class="cx-bars__tag">$75.6k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:74.2%"><span class="cx-bars__tag">$74.2k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:83.1%"><span class="cx-bars__tag">$83.1k</span><div class="cx-bars__bar"></div></div><div class="cx-bars__col" style="--h:91.2%"><span class="cx-bars__tag">$91.2k</span><div class="cx-bars__bar"></div></div></div>
|
||||
<div class="cx-bars__fx"></div>
|
||||
</div>
|
||||
<div class="cx-bars__axis" aria-hidden="true"></div>
|
||||
</div>
|
||||
<table class="cx-sr" data-table><caption>Revenue by month, thousands of dollars</caption><tbody></tbody></table>
|
||||
</article>
|
||||
@@ -0,0 +1,35 @@
|
||||
// fx-bars: one effect canvas over the plot, masked to the bars, on a 0 / 50k / 100k scale. The effect
|
||||
// is a still, low-contrast grain in the accent (no motion, no streaks), so the bars read as one flat
|
||||
// printed material, drawn once and kept as a picture (no WebGL context at rest). The latest month
|
||||
// carries a ring; its value is the header figure.
|
||||
import { still, palette, accentPalette, windows, bag } from '../shared.js';
|
||||
|
||||
// revenue by month in thousands of dollars (sample data: replace with the product's own)
|
||||
const DATA = [42.1, 47.8, 51.2, 49.4, 57.9, 63.3, 61.0, 69.8, 75.6, 74.2, 83.1, 91.2];
|
||||
const MONTHS = ['N', 'D', 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O'];
|
||||
const FULL = ['Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'];
|
||||
const MAX = 100;
|
||||
const TICKS = [[100, '100k'], [50, '50k'], [0, '0']];
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const cols = root.querySelector('.cx-bars__cols');
|
||||
const host = root.querySelector('.cx-bars__fx');
|
||||
const page = palette(root);
|
||||
cols.innerHTML = DATA.map(v => `<div class="cx-bars__col" style="--h:${(v / MAX * 100).toFixed(1)}%"><span class="cx-bars__tag">$${v.toFixed(1)}k</span><div class="cx-bars__bar"></div></div>`).join('');
|
||||
root.querySelector('.cx-bars__scale').innerHTML = TICKS.map(([v, l]) => `<span class="${v ? '' : 'is-base'}" data-v="${l}" style="top:${(1 - v / MAX) * 100}%"></span>`).join('');
|
||||
root.querySelector('.cx-bars__axis').innerHTML = MONTHS.map(m => `<span>${m}</span>`).join('');
|
||||
root.querySelector('[data-table] tbody').innerHTML = DATA.map((v, i) => `<tr><th>${FULL[i]}</th><td>${v.toFixed(1)}</td></tr>`).join('');
|
||||
const effect = root.dataset.cxEffect || 'grain-gradient';
|
||||
if (effect === 'flat') return b; // the plain accent bars
|
||||
const bars = [...root.querySelectorAll('.cx-bars__bar')];
|
||||
b.add(windows(host, bars));
|
||||
const s = b.add(still(host, effect, {
|
||||
palette: accentPalette(page, { min: 1, lift: 0.05 }),
|
||||
seed: root.dataset.cxSeed,
|
||||
params: { speed: 0, grainMotion: 0, grain: 0.3, contrast: 0.16, tint: 0.35, softness: 0.85, warp: 0.25, calm: 0 },
|
||||
}));
|
||||
s.ready.then(() => root.classList.add('is-live'));
|
||||
b.add(() => root.classList.remove('is-live'));
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-bars",
|
||||
"name": "Bar chart with grain bars",
|
||||
"for": "A revenue or usage card: every bar is a window onto one still, low-contrast grain in the accent, so the bars read as one printed material while their heights stay exact on a labelled scale.",
|
||||
"effects": ["grain-gradient", "flat"],
|
||||
"default": "grain-gradient",
|
||||
"how": "windows(): one effect host over the plot, masked to the bar boxes, in a palette within a few steps of the accent; the grain is still (speed 0, grainMotion 0). A 0 / 50k / 100k scale sits left of the plot. The latest bar carries a thin ring and its value is the header figure; hover shows any other value. 'flat' mounts nothing.",
|
||||
"rules": ["Bars start at zero on a labelled scale.", "The header names the month it shows, and the change matches the data.", "Mark the latest bar with a ring, never by veiling the others.", "No glass, satin or metal streaks in the bars, and no motion inside them.", "Real numbers. Invented ones are placeholders covered by the page's one placeholder note and listed in the report, never a sample tag in the chart."],
|
||||
"a11y": "The plot is decorative; a hidden table holds every month and value. The effect is still, so reduced motion changes nothing."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* fx-button: the system's buttons, with an effect in the fill (primary) or the border (secondary) on hover and focus */
|
||||
.cx-btns { display: grid; justify-items: center; gap: var(--s-5); text-align: center; }
|
||||
.cx-btns__row { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-3); }
|
||||
|
||||
.cx-fxbtn { position: relative; isolation: isolate; }
|
||||
.cx-fxbtn > :not(.cx-fxbtn__fx) { position: relative; z-index: 1; }
|
||||
/* the fill: the effect fades in from the button's middle outward, so it never sits as a smudge at one end */
|
||||
.cx-fxbtn__fx {
|
||||
position: absolute; inset: 0; z-index: 0; border-radius: inherit; overflow: hidden;
|
||||
opacity: 0; transition: opacity .45s var(--cx-ease); pointer-events: none;
|
||||
}
|
||||
.cx-fxbtn__fx:not(.cx-ring) {
|
||||
-webkit-mask: radial-gradient(120% 140% at 50% 50%, #000 40%, rgb(0 0 0 / .55) 100%);
|
||||
mask: radial-gradient(120% 140% at 50% 50%, #000 40%, rgb(0 0 0 / .55) 100%);
|
||||
}
|
||||
.cx-fxbtn__fx.cx-ring { --ring: 1.5px; inset: calc(-1px - var(--ring) + 1px); border-radius: calc(var(--r-btn) + 1px); overflow: visible; }
|
||||
.cx-fxbtn.is-lit .cx-fxbtn__fx { opacity: 1; }
|
||||
.cx-fxbtn--edge.is-lit:has(.fx-canvas[style*="opacity: 1"]) { border-color: transparent; }
|
||||
.cx-fxbtn__arrow { transition: transform var(--dur) var(--ease); }
|
||||
.cx-fxbtn:hover .cx-fxbtn__arrow, .cx-fxbtn:focus-visible .cx-fxbtn__arrow { transform: translateX(2px); }
|
||||
/* the system's hover darkening would fight the light: keep the fill */
|
||||
.cx-fxbtn.btn--primary:hover { background: var(--action); }
|
||||
@media (max-width: 639px) {
|
||||
.cx-btns { justify-items: stretch; }
|
||||
.cx-btns__row { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="cx cx-btns">
|
||||
<div class="cx-btns__row">
|
||||
<a class="btn btn--primary cx-fxbtn" href="#" data-cx-btn="grain-gradient" data-cx-seed="31">
|
||||
<span class="cx-fxbtn__fx" aria-hidden="true"></span>
|
||||
<span>Start for free</span>
|
||||
<svg class="cx-fxbtn__arrow" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3 8h9.5M8.5 3.5 13 8l-4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</a>
|
||||
<a class="btn btn--secondary cx-fxbtn cx-fxbtn--edge" href="#" data-cx-btn="gradient-mesh" data-cx-seed="8" data-cx-params='{"speed":0.25,"lines":0,"tint":1,"calm":0}'>
|
||||
<span class="cx-fxbtn__fx cx-ring" aria-hidden="true"></span>
|
||||
<span>Book a demo</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
// fx-button: an effect fades into the button's fill or border on hover and focus, and runs only then.
|
||||
// At rest a button holds no WebGL context: the effect is mounted on the first hover or focus, paused
|
||||
// half a second after the pointer leaves and removed (its context freed) a few seconds later.
|
||||
import { mount, palette, accentPalette, bag, paramsOf, color } from '../shared.js';
|
||||
|
||||
// the fill is even across the button: low contrast, no blobs that settle at one end, a fine grain that
|
||||
// moves while it is pointed at (the button's own params can override)
|
||||
const FILL = { speed: 0.35, tint: 0.22, contrast: 0.1, grain: 0.3, softness: 1, scatter: 0, warp: 0.15, grainMotion: 1, calm: 0 };
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
root.querySelectorAll('[data-cx-btn]').forEach(btn => {
|
||||
const host = btn.querySelector('.cx-fxbtn__fx');
|
||||
const page = palette(btn);
|
||||
const edge = btn.classList.contains('cx-fxbtn--edge');
|
||||
// the fill is one even step off the accent (lighter under a dark label, deeper under a light one;
|
||||
// the label keeps 4.5:1) with a fine grain; the edge is drawn brighter than the ground so it reads
|
||||
// as light
|
||||
const ap = accentPalette(page, { lift: 0.07 });
|
||||
const pal = edge
|
||||
? { ...accentPalette(page, { min: 1, lift: 0.14 }), bg: color.mix(page.accent, page.bg, 0.15) }
|
||||
: { ...ap, bg: ap.accent };
|
||||
const params = edge ? paramsOf(btn) : { ...FILL, ...(paramsOf(btn) || {}) };
|
||||
let ctrl = null, pending = null, dead = false, pauseT = 0, freeT = 0;
|
||||
const lit = () => btn.classList.contains('is-lit');
|
||||
const ensure = () => {
|
||||
if (ctrl || pending) return;
|
||||
pending = mount(host, btn.dataset.cxBtn, { palette: pal, seed: btn.dataset.cxSeed, params })
|
||||
.then(c => {
|
||||
pending = null;
|
||||
if (dead) { c.destroy(); return; }
|
||||
ctrl = c;
|
||||
if (!lit()) c.pause();
|
||||
}, err => { pending = null; console.warn('fx-button:', err); });
|
||||
};
|
||||
const free = () => { ctrl?.destroy(); ctrl = null; };
|
||||
const on = () => {
|
||||
clearTimeout(pauseT);
|
||||
clearTimeout(freeT);
|
||||
btn.classList.add('is-lit');
|
||||
ensure();
|
||||
ctrl?.resume();
|
||||
};
|
||||
const off = () => {
|
||||
if (btn.matches(':hover') || btn.matches(':focus-visible')) return;
|
||||
btn.classList.remove('is-lit');
|
||||
clearTimeout(pauseT);
|
||||
clearTimeout(freeT);
|
||||
pauseT = setTimeout(() => ctrl?.pause(), 500);
|
||||
freeT = setTimeout(free, 4000);
|
||||
};
|
||||
const focus = () => { if (btn.matches(':focus-visible')) on(); };
|
||||
btn.addEventListener('pointerenter', on);
|
||||
btn.addEventListener('pointerleave', off);
|
||||
btn.addEventListener('focus', focus);
|
||||
btn.addEventListener('blur', off);
|
||||
b.add(() => {
|
||||
dead = true;
|
||||
clearTimeout(pauseT);
|
||||
clearTimeout(freeT);
|
||||
free();
|
||||
btn.classList.remove('is-lit');
|
||||
btn.removeEventListener('pointerenter', on);
|
||||
btn.removeEventListener('pointerleave', off);
|
||||
btn.removeEventListener('focus', focus);
|
||||
btn.removeEventListener('blur', off);
|
||||
});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-button",
|
||||
"name": "Effect button",
|
||||
"for": "The one primary call to action: on hover and focus a slow effect fades into its fill; a secondary button can carry the effect in its border instead.",
|
||||
"effects": ["grain-gradient", "gradient-mesh", "film-light-leak", "caustics", "soft-light-bars"],
|
||||
"default": "grain-gradient",
|
||||
"how": "An effect host inside the button (fill) or a cx-ring host around it (border), in a palette within a few steps of the accent; the fill is centred (calm 0, low scatter, a soft radial mask) and low in contrast. Mounted paused, it runs only while pointed at or focused.",
|
||||
"rules": ["Only the primary call to action, or one button pair, per page.", "The label keeps 4.5:1 on every frame of the fill.", "The fill is even across the button: no bright patch at one end.", "Nothing moves until hover or focus.", "Demo notes ('hover to see it') belong to the showcase, never to the component markup."],
|
||||
"a11y": "A real link with its own text; the effect host is aria-hidden. Keyboard focus lights it as hover does. Reduced motion shows a still frame."
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* fx-chart: an area chart with a soft accent fade (or a still dot ruling) under the line; the chart keeps one grid, its own */
|
||||
.cx-chart { display: grid; gap: var(--s-5); padding: var(--s-6); min-width: 0; }
|
||||
.cx-chart__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-5); flex-wrap: wrap; }
|
||||
.cx-chart__title { font: 500 var(--t-body)/1.3 var(--font-text); margin: 0; }
|
||||
.cx-chart__sub { font-size: var(--t-label); color: var(--muted); margin: 4px 0 0; }
|
||||
.cx-chart__total { display: grid; justify-items: end; gap: 4px; }
|
||||
.cx-chart__label { font-size: var(--t-label); color: var(--muted); }
|
||||
.cx-chart__value { font: 560 32px/1 var(--font-display); letter-spacing: -0.03em; }
|
||||
.cx-chart__delta { font-size: var(--t-label); color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
|
||||
.cx-chart__plot { position: relative; height: 220px; outline-offset: 6px; cursor: crosshair; }
|
||||
.cx-chart__grid { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
|
||||
.cx-chart__grid span { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed var(--cx-line-2); }
|
||||
.cx-chart__grid span:last-child { border-top-style: solid; }
|
||||
.cx-chart__grid span::after {
|
||||
content: attr(data-v); position: absolute; left: 0; bottom: 6px; font: 400 11px/1 var(--cx-mono); color: var(--muted);
|
||||
}
|
||||
.cx-chart__grid span:last-child::after { display: none; }
|
||||
.cx-chart__area { position: absolute; inset: 0; z-index: 0; }
|
||||
/* the plain choice (and the picture before the script): an accent area fading to the card */
|
||||
.cx-chart__area:not([data-fx-mounted]) { background: linear-gradient(to bottom, color-mix(in oklab, var(--accent) 26%, transparent), transparent); }
|
||||
.cx-chart__line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; z-index: 2; }
|
||||
.cx-chart__line path { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.cx-chart__cursor {
|
||||
position: absolute; top: 0; bottom: 0; width: 1px; z-index: 3; background: var(--cx-line-2);
|
||||
opacity: 0; transition: opacity .15s var(--cx-ease); pointer-events: none;
|
||||
}
|
||||
.cx-chart__dot {
|
||||
position: absolute; left: -5px; width: 11px; height: 11px; border-radius: 99px; background: var(--accent);
|
||||
border: 2px solid var(--cx-card); box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
/* pinned inside the plot, 14px to the side of the point (left of it on the right half) */
|
||||
.cx-chart__tip {
|
||||
position: absolute; top: 0; z-index: 4; transform: translateX(14px); white-space: nowrap;
|
||||
height: 30px; display: inline-flex; align-items: center; padding: 0 10px;
|
||||
border-radius: var(--r-sm); background: var(--bg); box-shadow: var(--cx-shadow);
|
||||
font-size: 12px; color: var(--ink); opacity: 0; transition: opacity .15s var(--cx-ease); pointer-events: none;
|
||||
}
|
||||
.cx-chart__tip.is-left { transform: translateX(calc(-100% - 14px)); }
|
||||
.cx-chart__tip b { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.cx-chart__tip span { color: var(--muted); margin-right: 8px; }
|
||||
.cx-chart.is-reading .cx-chart__cursor, .cx-chart.is-reading .cx-chart__tip { opacity: 1; }
|
||||
.cx-chart__axis { display: flex; justify-content: space-between; font: 400 11px/1 var(--cx-mono); color: var(--muted); margin-top: calc(-1 * var(--s-3)); }
|
||||
@media (max-width: 639px) {
|
||||
.cx-chart { padding: var(--s-5); }
|
||||
.cx-chart__total { justify-items: start; }
|
||||
.cx-chart__plot { height: 180px; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<article class="cx cx-card cx-chart" data-cx-seed="12">
|
||||
<header class="cx-chart__head">
|
||||
<div>
|
||||
<h3 class="cx-chart__title">API requests</h3>
|
||||
<p class="cx-chart__sub">Last 14 days, all regions</p>
|
||||
</div>
|
||||
<div class="cx-chart__total">
|
||||
<span class="cx-chart__label">Total</span>
|
||||
<span class="cx-chart__value cx-num" data-total>27.8M</span>
|
||||
<span class="cx-chart__delta">+12.4% on the 14 days before</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="cx-chart__plot" tabindex="0" role="img" aria-label="Area chart of requests per day for the last 14 days. Use the left and right arrow keys to read each day.">
|
||||
<div class="cx-chart__grid" aria-hidden="true"></div>
|
||||
<div class="cx-chart__area" aria-hidden="true"></div>
|
||||
<svg class="cx-chart__line" aria-hidden="true" preserveAspectRatio="none"><path/></svg>
|
||||
<div class="cx-chart__cursor" aria-hidden="true"><span class="cx-chart__dot"></span></div>
|
||||
<div class="cx-chart__tip" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="cx-chart__axis" aria-hidden="true"></div>
|
||||
<table class="cx-sr" data-table>
|
||||
<caption>Requests per day</caption>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</article>
|
||||
@@ -0,0 +1,95 @@
|
||||
// fx-chart: the area under the line is a soft accent fade (the default, plain CSS) or a window onto a
|
||||
// still, fine dot ruling in the accent ('ruled-grounds'); the line, the chart's own grid and the reading
|
||||
// stay crisp. A halftone screen was tried here and rejected: its tone runs across the frame, not down
|
||||
// from the line, so half the area went empty. Nothing in it loops: the ruling is drawn once and kept as a
|
||||
// picture (no WebGL context at rest).
|
||||
import { still, palette, tone, color, bag } from '../shared.js';
|
||||
|
||||
// requests per day (sample data: replace with the product's own)
|
||||
const DATA = [1.82, 1.91, 1.88, 2.04, 2.21, 1.68, 1.59, 1.98, 2.11, 2.19, 2.26, 2.38, 1.89, 1.84].map(v => v * 1e6);
|
||||
const MAX = 2.5e6;
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const plot = root.querySelector('.cx-chart__plot');
|
||||
const area = root.querySelector('.cx-chart__area');
|
||||
const line = root.querySelector('.cx-chart__line');
|
||||
const path = line.querySelector('path');
|
||||
const cursor = root.querySelector('.cx-chart__cursor');
|
||||
const dot = root.querySelector('.cx-chart__dot');
|
||||
const tip = root.querySelector('.cx-chart__tip');
|
||||
const grid = root.querySelector('.cx-chart__grid');
|
||||
const axis = root.querySelector('.cx-chart__axis');
|
||||
const page = palette(root);
|
||||
const days = DATA.map((_, i) => { const d = new Date(2026, 9, 18 + i); return d; });
|
||||
const fmtDay = d => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
const fmtM = v => `${(v / 1e6).toFixed(2)}M`;
|
||||
|
||||
root.querySelector('[data-total]').textContent = `${(DATA.reduce((a, v) => a + v, 0) / 1e6).toFixed(1)}M`;
|
||||
grid.innerHTML = [[2e6, '2M'], [1e6, '1M'], [0, '0']].map(([v, l]) => `<span data-v="${l}" style="top:${(1 - v / MAX) * 100}%"></span>`).join('');
|
||||
axis.innerHTML = [0, 4, 8, 13].map(i => `<span>${fmtDay(days[i])}</span>`).join('');
|
||||
root.querySelector('[data-table] tbody').innerHTML = DATA.map((v, i) => `<tr><th>${fmtDay(days[i])}</th><td>${fmtM(v)}</td></tr>`).join('');
|
||||
|
||||
// a smooth line through the points (monotone-ish cubic), in a 0..1000 x 0..1000 box
|
||||
const pts = DATA.map((v, i) => [i / (DATA.length - 1) * 1000, (1 - v / MAX) * 1000]);
|
||||
const d = pts.map((p, i) => {
|
||||
if (!i) return `M${p[0]},${p[1]}`;
|
||||
const a = pts[i - 1], dx = (p[0] - a[0]) / 2.6;
|
||||
return `C${a[0] + dx},${a[1]} ${p[0] - dx},${p[1]} ${p[0]},${p[1]}`;
|
||||
}).join(' ');
|
||||
line.setAttribute('viewBox', '0 0 1000 1000');
|
||||
path.setAttribute('d', d);
|
||||
const areaPath = `${d} L1000,1000 L0,1000 Z`;
|
||||
const svgMask = `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" preserveAspectRatio="none"><path d="${areaPath}"/></svg>`)}")`;
|
||||
const fade = 'linear-gradient(to bottom, #000 0%, rgb(0 0 0 / .55) 100%)';
|
||||
Object.assign(area.style, {
|
||||
maskImage: `${svgMask}, ${fade}`, webkitMaskImage: `${svgMask}, ${fade}`,
|
||||
maskSize: '100% 100%', webkitMaskSize: '100% 100%', maskRepeat: 'no-repeat', webkitMaskRepeat: 'no-repeat',
|
||||
maskComposite: 'intersect', webkitMaskComposite: 'source-in',
|
||||
});
|
||||
|
||||
// the ruling takes a palette near the card: the dots are the accent, the ground one step off the card
|
||||
const card = tone(page, 0.035);
|
||||
const effect = root.dataset.cxEffect || 'fade';
|
||||
const PARAMS = {
|
||||
'ruled-grounds': { cell: 12, major: 0, ink: 1, focus: 0, speed: 0, grain: 0.3, calm: 0 },
|
||||
};
|
||||
// 'fade' draws no effect: the CSS accent fade stays
|
||||
if (effect !== 'fade') {
|
||||
b.add(still(area, effect, {
|
||||
palette: { ...page, bg: color.mix(card, page.accent, color.isDark(page.bg) ? 0.1 : 0.06), surface: card, ink: page.accent },
|
||||
seed: root.dataset.cxSeed,
|
||||
params: PARAMS[effect] || { speed: 0, drift: 0, calm: 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
// reading: pointer or arrow keys. The tooltip is pinned inside the plot beside the point, on the
|
||||
// side with room, so it never covers the header above the chart.
|
||||
let at = -1;
|
||||
const show = i => {
|
||||
at = Math.max(0, Math.min(DATA.length - 1, i));
|
||||
const x = at / (DATA.length - 1) * 100, y = (1 - DATA[at] / MAX) * 100;
|
||||
cursor.style.left = `${x}%`;
|
||||
dot.style.top = `calc(${y}% - 5.5px)`;
|
||||
tip.style.left = `${x}%`;
|
||||
tip.style.top = `clamp(0px, calc(${y}% - 15px), calc(100% - 30px))`;
|
||||
tip.classList.toggle('is-left', x > 55);
|
||||
tip.innerHTML = `<span>${fmtDay(days[at])}</span><b>${fmtM(DATA[at])}</b>`;
|
||||
root.classList.add('is-reading');
|
||||
};
|
||||
const hide = () => root.classList.remove('is-reading');
|
||||
const move = e => { const r = plot.getBoundingClientRect(); show(Math.round((e.clientX - r.left) / r.width * (DATA.length - 1))); };
|
||||
const key = e => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); show((at < 0 ? DATA.length - 1 : at) + (e.key === 'ArrowRight' ? 1 : -1)); }
|
||||
if (e.key === 'Home') { e.preventDefault(); show(0); }
|
||||
if (e.key === 'End') { e.preventDefault(); show(DATA.length - 1); }
|
||||
if (e.key === 'Escape') hide();
|
||||
};
|
||||
plot.addEventListener('pointermove', move);
|
||||
plot.addEventListener('pointerleave', hide);
|
||||
plot.addEventListener('keydown', key);
|
||||
plot.addEventListener('focus', () => show(at < 0 ? DATA.length - 1 : at));
|
||||
plot.addEventListener('blur', hide);
|
||||
b.add(() => { plot.removeEventListener('pointermove', move); plot.removeEventListener('pointerleave', hide); plot.removeEventListener('keydown', key); });
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-chart",
|
||||
"name": "Area chart",
|
||||
"for": "A metrics card in a product or infrastructure section: one metric over 14 days, a crisp accent line over a soft accent fade, the chart's own grid, and a reading for any day by pointer or arrow keys.",
|
||||
"effects": ["fade", "ruled-grounds"],
|
||||
"default": "fade",
|
||||
"how": "'fade' is plain CSS: the area under the line fades from the accent to the card. 'ruled-grounds' masks a still accent dot ruling to the area (an SVG mask of the area path and a top-to-bottom fade). The line is a plain SVG path on top. Pointer and arrow keys read each day; the tooltip is pinned inside the plot beside the point.",
|
||||
"rules": ["Real numbers (invented ones are covered by the page's one placeholder note, never a sample tag in the chart); axis labels on the data's own scale.", "The fill stays inside the area, never behind the labels, and is still: no moving traces, no pulses.", "The chart has one grid, its own; never an effect that draws a second one (grid-pulse) or a tone that runs across the frame (halftone-screen leaves half the area empty).", "One live chart per screen."],
|
||||
"a11y": "The plot is a focusable image with a label; arrow keys, Home and End read each day into a polite live region; a hidden table holds all values."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* fx-dither-object: a real object drawn in ordered dither on its own stage, never with a caption under it.
|
||||
One figure per page, as the hero picture or the visual of one split section. */
|
||||
.cx-obj { margin: 0; display: grid; gap: var(--s-3); min-width: 0; }
|
||||
.cx-obj__stage {
|
||||
position: relative; isolation: isolate; aspect-ratio: 4 / 3; overflow: hidden;
|
||||
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<figure class="cx cx-obj" data-cx-object="headphones" data-cx-seed="3">
|
||||
<div class="cx-obj__stage" role="img" aria-label="Over-ear headphones, drawn in dots"></div>
|
||||
</figure>
|
||||
@@ -0,0 +1,480 @@
|
||||
// fx-dither-object: a real thing (headphones, an office chair, a camera, a drone) built in code as a lit
|
||||
// 3D form and printed through an 8x8 ordered dither in the page tones: the ground, a shade, the ink, the
|
||||
// accent and a deep accent. It is the picture for a product that has no photograph yet.
|
||||
// idle: the object sways slowly on its spot (about 26 s a swing)
|
||||
// hover: the object turns to follow the pointer and the light comes from it; each object answers in
|
||||
// its own way: sound rings out of the headphones' cups, the chair's seat swivels after the
|
||||
// pointer, the camera's lens zooms and its focus ring turns, the drone's rotors spin up and it
|
||||
// lifts off its legs
|
||||
// Cost: the shader runs once per dither cell, not per screen pixel. The canvas has one pixel per cell
|
||||
// (a 3 CSS px cell: a ninth of the CSS pixels, whatever the screen's DPR) and is scaled up with
|
||||
// pixelated sampling, so a hero-sized object is well under 0.3 megapixels. Rays that miss the object's
|
||||
// bounding sphere skip the march; the floor traces a shadow only right under the object. 20 frames a
|
||||
// second, paused off screen, in a hidden tab and under the pause button (fx.shader). Under reduced
|
||||
// motion it is drawn once and kept as a picture (still() in shared.js): no context, no frames. One per
|
||||
// page.
|
||||
import { fx, palette, tone, color, bag, pauser, still, reduced } from '../shared.js';
|
||||
|
||||
/* ------------------------------------------------------------------ shader */
|
||||
|
||||
// WebGL1 and WebGL2 alike: no bit operators; on WebGL2 loops start at ZERO so the compiler does not
|
||||
// unroll map() (WebGL1 wants a constant there)
|
||||
const COMMON = /* glsl */ `
|
||||
#if __VERSION__ >= 300
|
||||
#define ZERO int(min(u_time, 0.0))
|
||||
#else
|
||||
#define ZERO 0
|
||||
#endif
|
||||
float g_hov; float g_t; vec2 g_m; float g_a; float g_b;
|
||||
vec3 g_ro; vec3 g_rd; vec3 g_uu; vec3 g_vv; vec3 g_ww; float g_hitT; float g_hit;
|
||||
float sdBox(vec3 p, vec3 b) { vec3 q = abs(p) - b; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0); }
|
||||
float sdCap(vec3 p, vec3 a, vec3 b, float r) { vec3 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h) - r; }
|
||||
float sdCylY(vec3 p, float r, float h) { vec2 d = vec2(length(p.xz) - r, abs(p.y) - h); return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); }
|
||||
vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); }
|
||||
vec2 opU(vec2 a, vec2 b) { return a.x < b.x ? a : b; }
|
||||
// a world point on the screen, in the same units as uv
|
||||
vec2 proj(vec3 p) { vec3 q = p - g_ro; return vec2(dot(q, g_uu), dot(q, g_vv)) * u_focal / max(dot(q, g_ww), 0.01); }
|
||||
`;
|
||||
|
||||
// materials: 0 metal or plastic, 1 accent paint, 2 dark (rubber, cushions, fabric), 3 light (white
|
||||
// plastic), 4 glass (a lens: dark with a sharp glint)
|
||||
// each object: setup() sets its moving parts from the pointer (g_hov, g_m) and time (g_t); map(p) is its
|
||||
// distance field (distance, material); extra(uv) is a drawn layer over the picture (sound, a spinning
|
||||
// rotor), 0..1
|
||||
const OBJECTS = {
|
||||
headphones: {
|
||||
cam: { target: [0, 0.74, 0], dist: 4.7, pitch: 0.2, yaw: 0.62, focal: 2.0 },
|
||||
bound: [0, 0.78, 0, 1.12],
|
||||
follow: 1,
|
||||
label: 'Over-ear headphones, drawn in dots',
|
||||
glsl: /* glsl */ `
|
||||
void setup() {}
|
||||
// one ear cup; q.x runs outward from the cup's middle plane
|
||||
vec2 cup(vec3 q) {
|
||||
float taper = 1.0 - 0.14 * clamp((q.x + 0.02) / 0.12, 0.0, 1.0);
|
||||
vec2 rr = vec2(0.40, 0.33) * taper;
|
||||
float e = (length(q.yz / rr) - 1.0) * rr.y;
|
||||
vec2 w = vec2(e + 0.04, abs(q.x) - 0.06);
|
||||
vec2 res = vec2(min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.04, 1.0);
|
||||
// the cushion: a soft ring on the inner side
|
||||
vec3 c = q + vec3(0.15, 0.0, 0.0);
|
||||
float ring = (length(c.yz / vec2(0.30, 0.235)) - 1.0) * 0.235;
|
||||
res = opU(res, vec2(length(vec2(ring, c.x * 1.15)) - 0.07, 2.0));
|
||||
// the fork: a thin U round the cup's upper half, a pivot at each end
|
||||
float f = (length(q.yz / vec2(0.47, 0.40)) - 1.0) * 0.40;
|
||||
float fork = max(length(vec2(f, q.x)) - 0.026, -q.y);
|
||||
fork = min(fork, length(vec3(q.x, q.y, abs(q.z) - 0.40)) - 0.042);
|
||||
return opU(res, vec2(fork, 0.0));
|
||||
}
|
||||
vec2 map(vec3 p) {
|
||||
vec2 res = cup(vec3(abs(p.x) - 0.60, p.y - 0.44, p.z));
|
||||
// the band: an arc from fork to fork whose legs run straight down into a slider on each fork's crown
|
||||
// (the arc alone stopped short of the forks and left a gap), and a soft pad under its crown
|
||||
float by = p.y - clamp(p.y - 0.91, -0.06, 0.0);
|
||||
float r = length(vec2(p.x, by - 0.91));
|
||||
vec2 d = abs(vec2(r - 0.60, p.z)) - vec2(0.018, 0.06);
|
||||
float band = max(length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - 0.018, 0.91 - by);
|
||||
float slider = sdBox(vec3(abs(p.x) - 0.60, p.y - 0.86, p.z), vec3(0.024, 0.036, 0.064)) - 0.018;
|
||||
res = opU(res, vec2(min(band, slider), 0.0));
|
||||
// the pad's lower ends are rounded into the band, not cut flat
|
||||
vec2 e = abs(vec2(r - 0.545, p.z)) - vec2(0.02, 0.064);
|
||||
float padBox = length(max(e, 0.0)) + min(max(e.x, e.y), 0.0) - 0.03;
|
||||
vec2 ru = max(vec2(0.05 + padBox, 0.05 + 1.2 - p.y), 0.0);
|
||||
float pad = min(-0.05, max(padBox, 1.2 - p.y)) + length(ru);
|
||||
return opU(res, vec2(pad, 2.0));
|
||||
}
|
||||
// sound: rings that run outward from each cup while the headphones are pointed at
|
||||
float waves(vec2 uv, float sx) {
|
||||
vec3 c = vec3(sx * 0.72, 0.44, 0.0);
|
||||
vec2 pc = proj(c);
|
||||
vec2 pd = proj(c + vec3(sx * 0.4, 0.0, 0.0)) - pc;
|
||||
float pl = length(pd);
|
||||
if (pl < 1e-4) return 0.0;
|
||||
pd /= pl;
|
||||
vec2 d = uv - pc;
|
||||
float r = length(d);
|
||||
float cone = smoothstep(0.25, 0.75, dot(d, pd) / max(r, 1e-4)) * clamp(pl * 6.0, 0.0, 1.0);
|
||||
float ph = fract(r * 15.0 - g_t * 1.5);
|
||||
float ring = smoothstep(0.38, 0.5, ph) * (1.0 - smoothstep(0.5, 0.64, ph));
|
||||
float fade = smoothstep(0.11, 0.17, r) * (1.0 - smoothstep(0.22, 0.42, r));
|
||||
return ring * cone * fade;
|
||||
}
|
||||
float extra(vec2 uv) {
|
||||
if (g_hit > 0.5 || g_hov < 0.01) return 0.0;
|
||||
return max(waves(uv, 1.0), waves(uv, -1.0)) * g_hov * 0.95;
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
chair: {
|
||||
cam: { target: [0, 0.66, -0.02], dist: 3.75, pitch: 0.24, yaw: 0.78, focal: 2.0 },
|
||||
bound: [0, 0.64, -0.04, 0.98],
|
||||
follow: 0.35,
|
||||
label: 'An office chair on five castors, drawn in dots',
|
||||
glsl: /* glsl */ `
|
||||
// the seat and back swivel after the pointer; the base stays put
|
||||
void setup() { g_a = -g_hov * g_m.x * 2.6; }
|
||||
vec2 map(vec3 p) {
|
||||
// the base: five legs in a star, a castor at each end, the gas column
|
||||
float a = atan(p.z, p.x) + 0.35;
|
||||
float sec = 1.2566371;
|
||||
a = mod(a + sec * 0.5, sec) - sec * 0.5;
|
||||
float r = length(p.xz);
|
||||
vec3 q = vec3(r * cos(a), p.y, r * sin(a));
|
||||
float leg = sdCap(q, vec3(0.05, 0.135, 0.0), vec3(0.43, 0.088, 0.0), 0.03);
|
||||
float hub = sdCylY(p - vec3(0.0, 0.13, 0.0), 0.07, 0.035) - 0.01;
|
||||
float col = sdCylY(p - vec3(0.0, 0.36, 0.0), 0.028, 0.2);
|
||||
vec2 res = vec2(min(min(leg, hub), col), 0.0);
|
||||
float castor = length(vec3(q.x - 0.445, q.y - 0.046, q.z)) - 0.046;
|
||||
float shroud = sdCylY(p - vec3(0.0, 0.25, 0.0), 0.045, 0.085) - 0.006;
|
||||
res = opU(res, vec2(min(castor, shroud), 2.0));
|
||||
vec3 s = p;
|
||||
s.xz = rot(s.xz, g_a);
|
||||
float mech = sdBox(s - vec3(0.0, 0.53, 0.0), vec3(0.1, 0.03, 0.11)) - 0.01;
|
||||
float spine = min(sdCap(s, vec3(0.0, 0.53, -0.08), vec3(0.0, 0.61, -0.33), 0.034), sdCap(s, vec3(0.0, 0.61, -0.33), vec3(0.0, 0.87, -0.38), 0.034));
|
||||
vec3 m = vec3(abs(s.x) - 0.315, s.y, s.z + 0.03);
|
||||
float pad = sdBox(m - vec3(0.0, 0.81, 0.0), vec3(0.034, 0.016, 0.13)) - 0.014;
|
||||
res = opU(res, vec2(min(min(mech, spine), pad), 2.0));
|
||||
float post = min(sdCap(m, vec3(0.0, 0.61, -0.02), vec3(0.0, 0.8, -0.02), 0.02), sdCap(m, vec3(-0.13, 0.6, -0.02), vec3(0.0, 0.61, -0.02), 0.02));
|
||||
res = opU(res, vec2(post, 0.0));
|
||||
// the seat and the back: one upholstery in the accent; the back leans back and wraps a little
|
||||
float seat = sdBox(s - vec3(0.0, 0.63, 0.03), vec3(0.25, 0.035, 0.24)) - 0.05;
|
||||
vec3 b = s - vec3(0.0, 1.06, -0.4);
|
||||
b.yz = rot(b.yz, 0.13);
|
||||
b.z -= 0.45 * b.x * b.x;
|
||||
float back = sdBox(b, vec3(0.24, 0.22, 0.025)) - 0.04;
|
||||
return opU(res, vec2(min(seat, back), 1.0));
|
||||
}
|
||||
float extra(vec2 uv) { return 0.0; }
|
||||
`,
|
||||
},
|
||||
|
||||
camera: {
|
||||
cam: { target: [0.02, 0.36, 0.12], dist: 3.1, pitch: 0.27, yaw: 0.6, focal: 2.0 },
|
||||
bound: [0.0, 0.38, 0.2, 0.86],
|
||||
follow: 1,
|
||||
label: 'A camera with a zoom lens, drawn in dots',
|
||||
glsl: /* glsl */ `
|
||||
void setup() {
|
||||
g_a = g_hov * clamp(0.55 - g_m.y * 1.8, 0.0, 1.0); // the zoom: the pointer high draws the lens out
|
||||
g_b = g_hov * g_m.x * 3.0; // the focus ring turns with the pointer
|
||||
}
|
||||
vec2 map(vec3 p) {
|
||||
// the body: an accent band below a metal top plate, a rubber grip, two dials and the shutter
|
||||
float body = sdBox(p - vec3(0.0, 0.33, 0.0), vec3(0.5, 0.27, 0.1)) - 0.05;
|
||||
vec2 res = vec2(body, p.y > 0.545 ? 0.0 : 1.0);
|
||||
float hump = sdBox(p - vec3(0.06, 0.68, -0.02), vec3(0.14, 0.08, 0.1)) - 0.03;
|
||||
float dial = min(sdCylY(p - vec3(0.33, 0.68, 0.0), 0.07, 0.03), sdCylY(p - vec3(-0.2, 0.67, 0.0), 0.055, 0.025)) - 0.006;
|
||||
res = opU(res, vec2(min(hump, dial), 0.0));
|
||||
float grip = sdBox(p - vec3(-0.4, 0.31, 0.15), vec3(0.1, 0.24, 0.07)) - 0.05;
|
||||
res = opU(res, vec2(grip, 2.0));
|
||||
float shutter = sdCylY(p - vec3(-0.4, 0.665, 0.1), 0.036, 0.02) - 0.006;
|
||||
res = opU(res, vec2(shutter, 1.0));
|
||||
// the lens: a barrel as long as the zoom, a ribbed focus ring, the glass set into its front
|
||||
float L = 0.36 + 0.17 * g_a;
|
||||
vec3 l = p - vec3(0.06, 0.33, 0.13);
|
||||
float r = length(l.xy);
|
||||
vec2 w = vec2(r - 0.2, abs(l.z - L * 0.5) - L * 0.5);
|
||||
float barrel = min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - 0.016;
|
||||
float rib = step(L * 0.4, l.z) * step(l.z, L * 0.72);
|
||||
barrel -= rib * 0.007 * smoothstep(0.25, 0.85, abs(sin(atan(l.y, l.x) * 30.0 + g_b)));
|
||||
barrel = max(barrel, -max(r - 0.158, L - 0.03 - l.z));
|
||||
float m = l.z < 0.05 ? 0.0 : (l.z > L - 0.07 ? (r < 0.162 ? 4.0 : 0.0) : 2.0);
|
||||
return opU(res, vec2(barrel * 0.9, m));
|
||||
}
|
||||
float extra(vec2 uv) { return 0.0; }
|
||||
`,
|
||||
},
|
||||
|
||||
drone: {
|
||||
cam: { target: [0, 0.36, 0], dist: 3.45, pitch: 0.5, yaw: 0.55, focal: 2.0 },
|
||||
bound: [0, 0.4, 0, 1.18],
|
||||
follow: 0.6,
|
||||
label: 'A camera drone with four rotors, drawn in dots',
|
||||
glsl: /* glsl */ `
|
||||
float g_lift;
|
||||
// world to the drone's own frame: lifted, tipped toward the pointer
|
||||
vec3 frame(vec3 p) {
|
||||
vec3 q = p - vec3(0.0, g_lift, 0.0);
|
||||
q.xy = rot(q.xy, g_b);
|
||||
q.zy = rot(q.zy, g_a);
|
||||
return q;
|
||||
}
|
||||
void setup() {
|
||||
g_lift = 0.2 * smoothstep(0.0, 1.0, g_hov) + 0.012 * sin(g_t * 2.2) * g_hov;
|
||||
g_b = -g_hov * g_m.x * 0.32;
|
||||
g_a = g_hov * g_m.y * 0.22;
|
||||
}
|
||||
vec2 map(vec3 p) {
|
||||
vec3 q = frame(p);
|
||||
vec2 res = vec2(sdBox(q - vec3(0.0, 0.3, 0.0), vec3(0.15, 0.055, 0.26)) - 0.065, 1.0);
|
||||
res = opU(res, vec2(length(q - vec3(0.0, 0.22, 0.31)) - 0.06, q.z > 0.345 ? 4.0 : 2.0));
|
||||
vec3 a = vec3(abs(q.x), q.y, abs(q.z));
|
||||
float arm = sdCap(a, vec3(0.1, 0.31, 0.14), vec3(0.52, 0.345, 0.47), 0.028);
|
||||
float leg = sdCap(a, vec3(0.1, 0.25, 0.13), vec3(0.13, 0.016, 0.17), 0.016);
|
||||
// the motors in metal (dark motors under a spinning disc printed as a cluster of black marks)
|
||||
vec3 mq = a - vec3(0.52, 0.375, 0.47);
|
||||
res = opU(res, vec2(min(min(arm, leg), sdCylY(mq, 0.048, 0.035) - 0.006), 0.0));
|
||||
// the propellers at rest, two blades each at its own angle; spinning, they are drawn as discs (extra)
|
||||
if (g_hov < 0.45) {
|
||||
vec3 bq = mq - vec3(0.0, 0.046, 0.0);
|
||||
bq.xz = rot(bq.xz, 0.5 + 1.3 * step(0.0, q.x) + 0.8 * step(0.0, q.z));
|
||||
res = opU(res, vec2(sdBox(bq, vec3(0.27, 0.004, 0.026)) - 0.004, 2.0));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
float extra(vec2 uv) {
|
||||
float spin = smoothstep(0.2, 0.6, g_hov);
|
||||
if (spin < 0.01) return 0.0;
|
||||
vec3 ro = frame(g_ro);
|
||||
vec3 rd = g_rd;
|
||||
rd.xy = rot(rd.xy, g_b);
|
||||
rd.zy = rot(rd.zy, g_a);
|
||||
if (abs(rd.y) < 1e-4) return 0.0;
|
||||
float t = (0.421 - ro.y) / rd.y;
|
||||
if (t < 0.0 || t > g_hitT) return 0.0;
|
||||
vec3 h = ro + rd * t;
|
||||
float r = length(vec2(abs(h.x), abs(h.z)) - vec2(0.52, 0.47));
|
||||
return (1.0 - smoothstep(0.28, 0.295, r)) * (0.2 + 0.5 * smoothstep(0.235, 0.28, r)) * spin;
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const MAIN = /* glsl */ `
|
||||
vec3 nor(vec3 p) {
|
||||
vec3 n = vec3(0.0);
|
||||
for (int i = ZERO; i < 4; i++) {
|
||||
vec3 e = i == 0 ? vec3(1.0, -1.0, -1.0) : (i == 1 ? vec3(-1.0, -1.0, 1.0) : (i == 2 ? vec3(-1.0, 1.0, -1.0) : vec3(1.0)));
|
||||
n += e * map(p + 0.0015 * e).x;
|
||||
}
|
||||
return normalize(n);
|
||||
}
|
||||
float shadow(vec3 ro, vec3 rd) {
|
||||
float res = 1.0, t = 0.02;
|
||||
for (int i = ZERO; i < 28; i++) {
|
||||
float h = map(ro + rd * t).x;
|
||||
res = min(res, 8.0 * h / t);
|
||||
t += clamp(h, 0.03, 0.25);
|
||||
if (res < 0.004 || t > 3.5) break;
|
||||
}
|
||||
return clamp(res, 0.0, 1.0);
|
||||
}
|
||||
// three tones: ground, shade, ink, by the ordered threshold
|
||||
vec3 tone3(float v, float th) {
|
||||
float x = clamp(v, 0.0, 1.0) * 2.0;
|
||||
// studio: the tones blend smoothly, a shaded render instead of a print
|
||||
if (u_smooth > 0.5) return x < 1.0 ? mix(u_t0, u_t1, x) : mix(u_t1, u_t2, x - 1.0);
|
||||
float i = floor(x);
|
||||
float idx = min(i + step(th, x - i), 2.0);
|
||||
return idx < 0.5 ? u_t0 : (idx < 1.5 ? u_t1 : u_t2);
|
||||
}
|
||||
// the accent part: lit faces in the accent, shade in the deep accent, the deepest shade in the ground
|
||||
// (dark pages) or the ink (light pages), never a grey
|
||||
vec3 toneA(float a, float th) {
|
||||
float x = clamp(a, 0.0, 1.0) * 2.0;
|
||||
if (u_smooth > 0.5) { vec3 lo = u_dark > 0.5 ? u_t0 : u_t2; return x < 1.0 ? mix(lo, u_t4, x) : mix(u_t4, u_t3, x - 1.0); }
|
||||
float i = floor(x);
|
||||
float idx = min(i + step(th, x - i), 2.0);
|
||||
return idx < 0.5 ? (u_dark > 0.5 ? u_t0 : u_t2) : (idx < 1.5 ? u_t4 : u_t3);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float cell = max(1.0, u_cell);
|
||||
vec2 id = floor(gl_FragCoord.xy / cell);
|
||||
vec2 cpx = (id + 0.5) * cell;
|
||||
vec2 uv = (cpx - 0.5 * u_res) / u_res.y;
|
||||
float th = u_smooth > 0.5 ? 0.5 : fx_bayer(id) + 0.5 / 64.0;
|
||||
|
||||
g_t = u_time * u_speed;
|
||||
g_hov = smoothstep(0.0, 1.0, u_mouse.z);
|
||||
g_m = u_mouse.xy / u_res - 0.5;
|
||||
g_a = 0.0; g_b = 0.0;
|
||||
setup();
|
||||
|
||||
// camera: a slow sway on its spot; while pointed at, it follows the pointer (as far as the object
|
||||
// lets it: the chair answers by swivelling its seat instead)
|
||||
float sway = u_sway * sin(g_t * 0.24);
|
||||
float yaw = u_yaw0 + mix(sway, g_m.x * 1.3 * u_follow, g_hov);
|
||||
float pit = u_pitch + g_hov * g_m.y * 0.3 * u_follow;
|
||||
vec3 ta = u_target;
|
||||
vec3 ro = ta + u_dist * vec3(sin(yaw) * cos(pit), sin(pit), cos(yaw) * cos(pit));
|
||||
vec3 ww = normalize(ta - ro);
|
||||
vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0)));
|
||||
vec3 vv = cross(uu, ww);
|
||||
vec3 rd = normalize(uv.x * uu + uv.y * vv + u_focal * ww);
|
||||
g_ro = ro; g_rd = rd; g_uu = uu; g_vv = vv; g_ww = ww;
|
||||
|
||||
// the light: upper left in the world; while pointed at, it comes from the pointer
|
||||
vec3 Lw = normalize(vec3(-0.55, 0.9, 0.65));
|
||||
vec3 Lh = normalize(uu * g_m.x * 2.2 + vv * (g_m.y * 1.8 + 1.0) - ww * 1.5);
|
||||
vec3 L = normalize(mix(Lw, Lh, g_hov));
|
||||
|
||||
// march only inside the object's bounding sphere
|
||||
float mat = -1.0, t = 1e9;
|
||||
vec3 oc = ro - u_bound.xyz;
|
||||
float bb = dot(oc, rd), bc = dot(oc, oc) - u_bound.w * u_bound.w, bh = bb * bb - bc;
|
||||
if (bh > 0.0) {
|
||||
float sq = sqrt(bh);
|
||||
float t0 = max(0.05, -bb - sq), t1 = -bb + sq;
|
||||
t = t0;
|
||||
for (int i = ZERO; i < 96; i++) {
|
||||
vec2 h = map(ro + rd * t);
|
||||
if (h.x < 0.0012 * t) { mat = h.y; break; }
|
||||
t += h.x * 0.9;
|
||||
if (t > t1) break;
|
||||
}
|
||||
if (mat < 0.0) t = 1e9;
|
||||
}
|
||||
float tf = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
|
||||
bool hit = mat >= 0.0 && t < tf;
|
||||
g_hit = hit ? 1.0 : 0.0;
|
||||
g_hitT = hit ? t : tf;
|
||||
|
||||
float dark = u_dark;
|
||||
vec3 col = u_t0;
|
||||
if (hit) {
|
||||
vec3 p = ro + rd * t;
|
||||
vec3 n = nor(p);
|
||||
float dif = max(dot(n, L), 0.0);
|
||||
float sh = dif > 0.001 ? shadow(p + n * 0.004, L) : 0.0;
|
||||
float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0);
|
||||
float spec = pow(max(dot(reflect(-L, n), -rd), 0.0), 24.0) * sh;
|
||||
float lit = clamp(0.16 * (0.55 + 0.45 * n.y) + 0.84 * dif * sh, 0.0, 1.0);
|
||||
if (mat > 0.5 && mat < 1.5) {
|
||||
// accent paint, its shade in the deep accent
|
||||
float a = dark > 0.5 ? clamp(lit * 1.1 + 0.14 + rim * 0.1 + spec * 0.3, 0.0, 1.0) : clamp(lit + 0.32 + spec * 0.3, 0.0, 1.0);
|
||||
col = toneA(a, th);
|
||||
} else {
|
||||
// v: 0 the ground .. 1 the ink (light on a dark page, dark on a light one)
|
||||
float v = dark > 0.5 ? clamp(lit * 0.9 + rim * 0.2 + 0.05, 0.0, 1.0)
|
||||
: clamp((1.0 - lit) * 0.8 + 0.08 + rim * 0.28, 0.0, 1.0);
|
||||
float sp = dark > 0.5 ? spec : -spec;
|
||||
if (mat < 0.5) v = (dark > 0.5 ? 0.22 + 0.78 * v : v) + sp * 0.55;
|
||||
else if (mat < 2.5) v = (dark > 0.5 ? 0.1 + 0.42 * v : 0.7 + 0.3 * v) + sp * 0.45;
|
||||
else if (mat < 3.5) v = dark > 0.5 ? 0.3 + 0.7 * v : 0.2 + v * 0.55;
|
||||
else v = (dark > 0.5 ? 0.06 + rim * 0.25 : 0.94 - rim * 0.2) + sp * 1.1;
|
||||
col = tone3(v, th);
|
||||
}
|
||||
} else if (tf < 1e8) {
|
||||
// the floor: only the contact shadow right under the object (and on a dark page a faint pool of
|
||||
// light it stands in), fading out long before the horizon: no carpet, no edge
|
||||
vec3 p = ro + rd * tf;
|
||||
vec2 dxz = (p.xz - u_bound.xz) / u_bound.w;
|
||||
float near = exp(-dot(dxz, dxz) * 4.2);
|
||||
float sh = near > 0.03 ? shadow(p + vec3(0.0, 0.002, 0.0), L) : 1.0;
|
||||
float pool = exp(-dot(dxz, dxz) * 6.0);
|
||||
float v = dark > 0.5 ? 0.1 * pool * (0.15 + 0.85 * sh) : 0.55 * near * (1.0 - sh);
|
||||
col = tone3(v, th);
|
||||
}
|
||||
// the object's own drawn layer (sound, spinning rotors), printed in the shade and the ink
|
||||
float s = extra(uv);
|
||||
if (u_smooth > 0.5) col = mix(col, dark > 0.5 ? u_t2 : u_t1, clamp(s, 0.0, 1.0) * 0.8);
|
||||
else if (s > 0.02 && s > th * 1.1) col = s > 0.55 + th * 0.4 ? (dark > 0.5 ? u_t2 : u_t1) : u_t1;
|
||||
fxColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const SCHEMA = {
|
||||
pixel: { default: 3, min: 1, max: 8, step: 1 }, // dither cell, CSS px
|
||||
sway: { default: 0.45, min: 0, max: 1, step: 0.01 }, // how far the object swings on its spot (radians)
|
||||
speed: { default: 1, min: 0, max: 2, step: 0.01 }, // idle motion
|
||||
};
|
||||
|
||||
/* the tones: the stage ground, a shade, the ink, the accent (kept apart from the ground) and a deep
|
||||
accent for the accent part's shaded side */
|
||||
function tones(page, ground) {
|
||||
const dark = color.isDark(ground);
|
||||
const ink = dark ? color.mix(page.ink, ground, 0.06) : page.ink;
|
||||
let acc = page.accent;
|
||||
if (color.contrast(acc, ground) < 1.8) acc = color.mix(acc, ink, 0.4);
|
||||
const deep = dark ? color.mix(acc, ground, 0.5) : color.mix(acc, ink, 0.35);
|
||||
return { t0: ground, t1: color.mix(ground, ink, dark ? 0.36 : 0.3), t2: ink, t3: acc, t4: deep, dark };
|
||||
}
|
||||
|
||||
/* without WebGL: a line drawing of the same object in the ink, its accent part filled, each shape in
|
||||
front filled with the ground so no line runs through it; the strokes keep one thin width at any size.
|
||||
Drawn in a 120 x 90 box, seen from the same side as the 3D view. */
|
||||
const DRAW = {
|
||||
headphones: (a, g) => `<path d="M33 55C31 22 47 12 60 12s29 10 27 43" stroke-width="2.4"/><path d="M38 40c2-14 11-20 22-20s20 6 22 20" stroke-width="4.5" opacity=".55"/><path d="M27 58q0-9 7-9M93 58q0-9-7-9"/><rect x="24" y="52" width="17" height="30" rx="8" fill="${a}"/><rect x="38" y="55" width="8" height="24" rx="4" fill="${g}"/><rect x="79" y="52" width="17" height="30" rx="8" fill="${a}"/><rect x="74" y="55" width="8" height="24" rx="4" fill="${g}"/>`,
|
||||
chair: (a, g) => `<path d="M60 70 31 76M60 70 89 76M60 70l-3 13M60 70 44 64M60 70l16-6"/><circle cx="30" cy="77.5" r="2.6" fill="${g}"/><circle cx="90" cy="77.5" r="2.6" fill="${g}"/><circle cx="57" cy="84.5" r="2.6" fill="${g}"/><path d="M60 70V52M60 50l2-12M39 47V37h9M81 47V37h-9"/><path d="M44 8q16-5 32 0l-2 28q-14 4-28 0z" fill="${a}"/><path d="M34 46q26-8 52 0 2 5-2 7-24 6-48 0-4-2-2-7z" fill="${a}"/>`,
|
||||
camera: (a, g) => `<path d="M46 26l5-9h18l5 9" fill="${g}"/><rect x="14" y="26" width="92" height="50" rx="6" fill="${g}"/><path d="M14.5 42h91v28a6 6 0 0 1-6 6h-79a6 6 0 0 1-6-6z" fill="${a}"/><rect x="17" y="34" width="16" height="42" rx="6" fill="${g}"/><path d="M84 26v-5h12v5M22 26v-4h7v4"/><circle cx="63" cy="52" r="21" fill="${g}"/><circle cx="63" cy="52" r="16"/><circle cx="63" cy="52" r="10" fill="${g}"/><path d="M58 47q3-3 7-2" opacity=".7"/>`,
|
||||
drone: (a, g) => `<path d="M60 46 27 27M60 46 93 27M60 46 27 65M60 46 93 65"/><ellipse cx="27" cy="27" rx="19" ry="6"/><ellipse cx="93" cy="27" rx="19" ry="6"/><ellipse cx="27" cy="65" rx="19" ry="6"/><ellipse cx="93" cy="65" rx="19" ry="6"/><circle cx="27" cy="27" r="2.6" fill="${g}"/><circle cx="93" cy="27" r="2.6" fill="${g}"/><circle cx="27" cy="65" r="2.6" fill="${g}"/><circle cx="93" cy="65" r="2.6" fill="${g}"/><rect x="49" y="33" width="22" height="27" rx="8" fill="${a}"/><circle cx="60" cy="64" r="3.6" fill="${g}"/><path d="M52 60l-4 10M68 60l4 10"/>`,
|
||||
};
|
||||
const drawing = (kind, ink, accent, ground) => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" fill="none" stroke="${ink}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><style>*{vector-effect:non-scaling-stroke}</style>${(DRAW[kind] || DRAW.headphones)(accent, ground)}</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") center / min(78%, 520px) auto no-repeat, ${ground}`;
|
||||
};
|
||||
|
||||
/* the object as an effect, so still() can draw it once under reduced motion */
|
||||
function objectEffect(kind, page, studio = false) {
|
||||
const o = OBJECTS[kind] || OBJECTS.headphones;
|
||||
const ground = tone(page, 0.035);
|
||||
const T = tones(page, ground);
|
||||
const frag = COMMON + o.glsl + MAIN;
|
||||
return {
|
||||
id: `cx-object-${kind}${studio ? '-studio' : ''}`,
|
||||
params: SCHEMA,
|
||||
mount(el, { params, seed }) {
|
||||
const pixel = params.pixel;
|
||||
return fx.shader(el, {
|
||||
frag,
|
||||
schema: SCHEMA,
|
||||
params,
|
||||
palette: { ...page, bg: ground },
|
||||
seed,
|
||||
// print: one canvas pixel per dither cell on every screen (dpr = 1 / cell), scaled up pixelated.
|
||||
// studio: a smooth render at one sample per CSS pixel (0.75 past 0.64 megapixels), scaled up smooth
|
||||
crisp: !studio,
|
||||
maxDpr: studio ? (el.clientWidth * el.clientHeight > 640000 ? 0.75 : 1) : 1 / pixel,
|
||||
fps: 20,
|
||||
still: 0, // reduced motion: the rest view, as designed
|
||||
pointer: true,
|
||||
uniforms: ({ dpr, params: pr }) => ({
|
||||
u_t0: color.vec3(T.t0), u_t1: color.vec3(T.t1), u_t2: color.vec3(T.t2), u_t3: color.vec3(T.t3), u_t4: color.vec3(T.t4),
|
||||
u_dark: T.dark ? 1 : 0,
|
||||
u_cell: studio ? 1 : Math.max(1, Math.round(pr.pixel * dpr)),
|
||||
u_smooth: studio ? 1 : 0,
|
||||
u_target: o.cam.target, u_dist: o.cam.dist, u_pitch: o.cam.pitch, u_yaw0: o.cam.yaw, u_focal: o.cam.focal,
|
||||
u_bound: o.bound,
|
||||
u_follow: o.follow,
|
||||
}),
|
||||
// without WebGL: a still line drawing of the same object, so the stage still shows the thing
|
||||
fallback: () => drawing(kind, color.mix(T.t2, ground, 0.15), T.t3, ground),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const page = palette(root);
|
||||
const figs = root.matches('.cx-obj') ? [root] : [...root.querySelectorAll('.cx-obj')];
|
||||
figs.forEach(fig => {
|
||||
const stage = fig.querySelector('.cx-obj__stage');
|
||||
const kind = OBJECTS[fig.dataset.cxObject] ? fig.dataset.cxObject : 'headphones';
|
||||
stage.setAttribute('aria-label', fig.dataset.cxLabel || OBJECTS[kind].label);
|
||||
// never a caption under the picture: the section copy names the object
|
||||
const pixel = Math.max(1, Math.round(+(fig.dataset.cxPixel || (stage.clientWidth < 420 ? 2 : 3)) || 3));
|
||||
// data-cx-render="studio": the same object as a smooth shaded render (a product shot), not a print
|
||||
const studio = fig.dataset.cxRender === 'studio';
|
||||
const effect = objectEffect(kind, page, studio);
|
||||
const seed = fig.dataset.cxSeed;
|
||||
// the line drawing stands on the stage until the dither canvas covers it: a stage the effect never draws is not an empty card
|
||||
{ const ground = tone(page, 0.035), T = tones(page, ground); stage.style.background = drawing(kind, color.mix(T.t2, ground, 0.15), T.t3, ground); b.add(() => { stage.style.background = ''; }); }
|
||||
// reduced motion: one picture, its context freed (no sway, no answer to the pointer, no button)
|
||||
if (reduced()) {
|
||||
b.add(still(stage, effect, { seed, params: { pixel } }));
|
||||
return;
|
||||
}
|
||||
const p = b.add(pauser(fig, { label: 'the drawing', corner: 'tr', at: stage }));
|
||||
fx.mount(stage, effect, { seed, params: { pixel } }).then(ctrl => {
|
||||
b.add(p.add(ctrl));
|
||||
// the line drawing without WebGL is still: no pause button
|
||||
if (ctrl.info?.fallback) p.destroy();
|
||||
}, err => console.warn('fx-dither-object:', err));
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
export { OBJECTS };
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "fx-dither-object",
|
||||
"name": "Objects in dither",
|
||||
"for": "The picture of a real thing when there is no photograph of it: headphones, an office chair, a camera or a drone, built in code as a lit 3D form and printed in ordered dither in the page tones (ground, shade, ink, accent and a deep accent). It sways slowly on its spot; pointed at, it turns to the pointer, the light comes from the pointer and the object answers: sound rings out of the cups, the seat swivels, the lens zooms, the rotors spin up and the drone lifts off.",
|
||||
"effects": [],
|
||||
"objects": [
|
||||
"headphones",
|
||||
"chair",
|
||||
"camera",
|
||||
"drone"
|
||||
],
|
||||
"default": "headphones",
|
||||
"how": "data-cx-object on the figure names the object (headphones, chair, camera, drone); data-cx-label can replace the stage's spoken label. Each is a small signed-distance model in its own shader, mounted with fx.shader (lazy WebGL context, paused off screen and in a hidden tab). The shader runs once per dither cell, not per pixel: the canvas has one pixel per 3 CSS px cell (2 px on a stage under 420px) on every screen and is scaled up pixelated, rays that miss the object skip the march, and it draws 20 frames a second. Under reduced motion it is drawn once and kept as a picture (still: no context). The accent paints one part (the ear cups, the seat and back, the camera's body band, the drone's shell), its shaded side in a deep accent. The stage is the card tone with a 4:3 aspect; a pause button sits in its top-right corner. The print is the render to use. data-cx-render=\"studio\" (a smooth shaded render of the same form) exists but is not for pages yet: at one sample per CSS pixel its edges stair-step and the forms read as toys next to a real product shot. No caption under the stage, ever: the section copy says what it is.",
|
||||
"rules": [
|
||||
"One object per page (one live WebGL context), as the hero picture or the visual of one split section; never two on a page.",
|
||||
"Only an object the product really is or sells (headphones for an audio brand, a chair for a furniture maker, a camera for a camera or photo-gear shop, a drone for a drone maker or a survey service that flies them). Never an unrelated object for decoration.",
|
||||
"No caption under the stage: the section heading or copy names the object and gives one real fact from the client.",
|
||||
"The print render, never studio: studio is not anti-aliased yet.",
|
||||
"The object moves inside its stage only; the stage and the card never move, nothing follows scroll."
|
||||
],
|
||||
"a11y": "The stage is role=img with a label that names the object (data-cx-label to replace it). A pause button holds the sway and the hover answer; reduced motion shows one still picture (no context) and no button. Without WebGL the stage shows a still line drawing of the same object."
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
fx-feature-card: a 2x2 of feature cards, each a title and two grey lines on the card fill, then an
|
||||
inset panel with its own 8px margin (radius = card radius - margin) holding a still effect ground
|
||||
and one smaller UI window. Two panels (one per row) run a short pointer demo; the others show a
|
||||
settled still. The cards and panels never move.
|
||||
*/
|
||||
.cx-feat { --cx-feat-m: var(--s-2); --cx-feat-h: 340px; display: grid; gap: var(--s-7); }
|
||||
.cx-feat__head { display: grid; justify-items: center; gap: var(--s-3); text-align: center; }
|
||||
.cx-feat__h { font-size: var(--t-h2); line-height: 1.08; letter-spacing: -0.03em; margin: 0; max-width: 20ch; text-wrap: balance; }
|
||||
.cx-feat__sub { margin: 0; max-width: 54ch; font-size: var(--t-lead); line-height: 1.5; color: var(--muted); text-wrap: balance; }
|
||||
|
||||
/* each card spans two rows of the grid (text, panel) as a subgrid, so the text zones of a row share
|
||||
one height and the panels of a row start on one line */
|
||||
.cx-feat__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-4); }
|
||||
.cx-feat__card {
|
||||
display: grid; grid-row: span 2; grid-template-rows: subgrid; row-gap: 0; min-width: 0; overflow: hidden;
|
||||
border-radius: var(--r-lg); background: var(--cx-card); border: 1px solid var(--cx-line);
|
||||
box-shadow: inset 0 1px 0 var(--cx-hi);
|
||||
}
|
||||
/* the text zone: same height in every card of a row (the grid row sizes it), never over the panel */
|
||||
.cx-feat__copy { display: grid; align-content: start; gap: 6px; padding: var(--s-5) var(--s-5) var(--s-5); }
|
||||
.cx-feat__title { font: 500 var(--t-card)/1.3 var(--font-text); letter-spacing: -0.01em; margin: 0; }
|
||||
.cx-feat__text { margin: 0; font-size: 15px; line-height: 1.5; color: var(--muted); max-width: 50ch; text-wrap: pretty; }
|
||||
|
||||
/* the inset panel: one step off the card, its radius the card's minus its margin */
|
||||
.cx-feat__panel {
|
||||
position: relative; isolation: isolate; overflow: hidden; height: var(--cx-feat-h); align-self: end;
|
||||
margin: 0 var(--cx-feat-m) var(--cx-feat-m); border-radius: calc(var(--r-lg) - var(--cx-feat-m));
|
||||
background: var(--cx-panel);
|
||||
}
|
||||
|
||||
/* ---- the UI window on the ground (opaque, page ground colour) */
|
||||
.cx-feat__win {
|
||||
position: absolute; z-index: 1; display: grid; align-content: start;
|
||||
background: var(--bg); color: var(--ink); border-radius: var(--r-md);
|
||||
box-shadow: 0 0 0 1px var(--cx-line-2), 0 2px 4px rgb(0 0 0 / .12), 0 18px 40px -12px rgb(0 0 0 / .45);
|
||||
font-size: 13px; line-height: 1.4;
|
||||
}
|
||||
/* every window starts some way down the panel and runs out through its bottom edge, so it reads as a
|
||||
cut from a real screen, never as a dialog floating in the middle of an empty ground */
|
||||
.cx-feat__win--bleed { left: 12%; right: 12%; top: 56px; bottom: -24px; }
|
||||
.cx-feat__win--narrow { left: 50%; width: min(300px, calc(100% - 64px)); transform: translateX(-50%); top: 44px; bottom: -24px; }
|
||||
.cx-feat__win--side { left: 9%; right: 22%; top: 72px; bottom: -24px; }
|
||||
|
||||
.cx-feat__bar { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 10px 0 16px; border-bottom: 1px solid var(--cx-line); }
|
||||
.cx-feat__name { font-weight: 500; font-size: 13px; }
|
||||
/* in a window's bar the title keeps one line (it gives way with an ellipsis, never wraps) */
|
||||
.cx-feat__bar .cx-feat__name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.cx-feat__chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: var(--r-sm);
|
||||
font: 500 11.5px/1 var(--font-text); font-style: normal; color: var(--muted); background: var(--cx-panel);
|
||||
transition: background-color .3s var(--cx-ease), color .3s var(--cx-ease);
|
||||
}
|
||||
.cx-feat__chip::before { content: ""; width: 6px; height: 6px; border-radius: 9px; background: currentColor; opacity: .7; }
|
||||
.cx-feat__chip.is-on { color: var(--ink); background: color-mix(in oklab, var(--accent) 22%, var(--bg)); }
|
||||
.cx-feat__chip.is-on::before { background: var(--accent); opacity: 1; }
|
||||
.cx-feat__btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 14px; margin-left: auto;
|
||||
border-radius: var(--r-sm); background: var(--action); color: var(--action-ink); font: 500 12.5px/1 var(--font-text);
|
||||
transition: transform .12s var(--cx-ease), filter .2s var(--cx-ease);
|
||||
}
|
||||
.cx-feat__btn.is-down { transform: scale(.96); filter: brightness(.92); }
|
||||
.cx-feat__btn.is-done::before {
|
||||
content: ""; width: 9px; height: 5px; margin-top: -3px; border-left: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: rotate(-45deg);
|
||||
}
|
||||
.cx-feat__btn--wide { margin: 10px 0 0; width: 100%; height: 36px; font-size: 13px; }
|
||||
|
||||
.cx-feat__meta { display: grid; gap: 8px; margin: 0; padding: 14px 16px; border-bottom: 1px solid var(--cx-line); }
|
||||
.cx-feat__meta div { display: grid; grid-template-columns: 88px 1fr; }
|
||||
.cx-feat__meta dt { color: var(--muted); }
|
||||
.cx-feat__meta dd { margin: 0; }
|
||||
.cx-feat__lines { margin: 0; padding: 6px 16px; list-style: none; }
|
||||
.cx-feat__lines li { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||
.cx-feat__lines li + li { border-top: 1px solid var(--cx-line); }
|
||||
.cx-feat__lines b { font-weight: 500; }
|
||||
.cx-feat__lines .is-total span { color: var(--muted); }
|
||||
.cx-feat__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--cx-line); background: var(--cx-card); color: var(--muted); }
|
||||
.cx-feat__foot span:last-child { color: var(--ink); text-align: right; }
|
||||
|
||||
/* payment link */
|
||||
.cx-feat__pay { gap: 4px; padding: 18px; justify-items: start; }
|
||||
.cx-feat__pay .cx-feat__name { color: var(--muted); font-weight: 400; }
|
||||
.cx-feat__amount { font: 560 28px/1.15 var(--font-display); letter-spacing: -0.025em; }
|
||||
.cx-feat__note { color: var(--muted); font-size: 12px; }
|
||||
.cx-feat__label { margin-top: 12px; color: var(--muted); font-size: 12px; }
|
||||
.cx-feat__label + .cx-feat__field { margin-top: 2px; }
|
||||
.cx-feat__field {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; height: 38px; margin: 0 0 4px;
|
||||
padding: 0 12px; border-radius: var(--r-sm); box-shadow: inset 0 0 0 1px var(--cx-line-2); font-size: 12.5px; letter-spacing: .02em;
|
||||
}
|
||||
.cx-feat__marks { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
|
||||
.cx-feat__marks .cx-mark { height: 11px; width: auto; flex: none; fill: currentColor; }
|
||||
.cx-feat__or { justify-self: center; margin-top: 10px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* payout: the schedule switch in the window's bar, then the payouts */
|
||||
.cx-feat__payout .cx-feat__bar { justify-content: space-between; }
|
||||
.cx-feat__seg { display: grid; grid-template-columns: repeat(3, auto); padding: 2px; border-radius: var(--r-sm); background: var(--cx-panel); }
|
||||
.cx-feat__seg span { display: grid; place-items: center; height: 26px; padding: 0 10px; border-radius: calc(var(--r-sm) - 2px); color: var(--muted); font-size: 12px; }
|
||||
.cx-feat__seg .is-on { background: var(--bg); color: var(--ink); font-weight: 500; box-shadow: 0 0 0 1px var(--cx-line-2), 0 1px 2px rgb(0 0 0 / .12); }
|
||||
.cx-feat__payout .cx-feat__lines li:first-child span { color: var(--ink); }
|
||||
.cx-feat__payout .cx-feat__lines li:not(:first-child) { color: var(--muted); }
|
||||
|
||||
/* invite */
|
||||
.cx-feat__invite { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--cx-line); }
|
||||
.cx-feat__input, .cx-feat__select {
|
||||
display: inline-flex; align-items: center; height: 32px; padding: 0 10px; border-radius: var(--r-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--cx-line-2); white-space: nowrap;
|
||||
}
|
||||
.cx-feat__input { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.cx-feat__select { gap: 6px; color: var(--muted); }
|
||||
.cx-feat__invite .cx-feat__btn { margin-left: 0; height: 32px; }
|
||||
.cx-feat__caret { width: 1px; height: 15px; margin-left: 1px; background: var(--ink); animation: cx-feat-caret 1s steps(1) infinite; }
|
||||
@keyframes cx-feat-caret { 50% { opacity: 0; } }
|
||||
.cx-feat__people { margin: 0; padding: 6px 12px; list-style: none; }
|
||||
.cx-feat__people li { display: flex; align-items: center; gap: 10px; height: 44px; }
|
||||
.cx-feat__people li + li { border-top: 1px solid var(--cx-line); }
|
||||
.cx-feat__people em { margin-left: auto; font-style: normal; color: var(--muted); font-size: 12px; }
|
||||
.cx-feat__people em.cx-feat__chip { color: var(--ink); background: color-mix(in oklab, var(--accent) 22%, var(--bg)); }
|
||||
.cx-feat__people em.cx-feat__chip::before { background: var(--accent); opacity: 1; }
|
||||
.cx-feat__ava {
|
||||
display: grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; flex: none;
|
||||
background: color-mix(in oklab, var(--accent) var(--a), var(--cx-raise)); font-size: 10.5px; font-weight: 500; letter-spacing: .02em;
|
||||
}
|
||||
/* the new invite opens at the top of the list and pushes the others down (never a blank row) */
|
||||
.cx-feat__new { overflow: hidden; transition: opacity .35s var(--cx-ease), height .35s var(--cx-ease); }
|
||||
.cx-feat__win:not(.is-added) .cx-feat__new { opacity: 0; height: 0; }
|
||||
.cx-feat__win:not(.is-added) .cx-feat__new + li { border-top: 0; }
|
||||
|
||||
/* ---- the pointer and its click ring (added by the script) */
|
||||
.cx-feat__ptr {
|
||||
position: absolute; left: 0; top: 0; z-index: 3; width: 20px; height: 20px; pointer-events: none; opacity: 0;
|
||||
transition: transform 1.1s cubic-bezier(.45, 0, .2, 1), opacity .3s var(--cx-ease);
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / .35));
|
||||
}
|
||||
.cx-feat__ptr svg { display: block; width: 20px; height: 20px; }
|
||||
.cx-feat__ptr path { fill: var(--ink); stroke: var(--bg); stroke-width: 1.5; stroke-linejoin: round; }
|
||||
.cx-feat__ring {
|
||||
position: absolute; left: -14px; top: -14px; z-index: 2; width: 28px; height: 28px; border-radius: 99px; pointer-events: none;
|
||||
border: 1.5px solid color-mix(in oklab, var(--ink) 70%, transparent); opacity: 0;
|
||||
}
|
||||
.cx-feat__ring.is-on { animation: cx-feat-ring .5s var(--cx-ease) forwards; }
|
||||
@keyframes cx-feat-ring { from { opacity: .9; scale: .4; } to { opacity: 0; scale: 1.25; } }
|
||||
|
||||
/* the pause sits in the panel's top-right corner, above the window */
|
||||
.cx-feat__panel > .cx-pause { top: var(--s-3); right: var(--s-3); bottom: auto; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .cx-feat__caret { animation: none; } }
|
||||
@media (max-width: 899px) {
|
||||
.cx-feat__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.cx-feat { --cx-feat-h: 300px; gap: var(--s-6); }
|
||||
.cx-feat__copy { padding: var(--s-5) var(--s-4) var(--s-4); }
|
||||
/* the ground keeps a clear margin round the window on a phone, as on a wide screen */
|
||||
.cx-feat__win--bleed, .cx-feat__win--side { left: 28px; right: 28px; top: 52px; }
|
||||
.cx-feat__win--narrow { top: 44px; width: calc(100% - 56px); }
|
||||
.cx-feat__invite .cx-feat__select { display: none; }
|
||||
/* the window title keeps one line: the state chip gives way (the button says Sent) */
|
||||
.cx-feat__bar .cx-feat__chip { display: none; }
|
||||
.cx-feat__meta div { grid-template-columns: 76px 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<section class="cx cx-feat" aria-labelledby="cx-feat-title">
|
||||
<header class="cx-feat__head">
|
||||
<h2 class="cx-feat__h" id="cx-feat-title">Get paid without chasing anyone</h2>
|
||||
<p class="cx-feat__sub">Invoices, payment links and payouts in one account, with the right access for everyone on your team.</p>
|
||||
</header>
|
||||
|
||||
<div class="cx-feat__grid">
|
||||
<article class="cx-feat__card">
|
||||
<div class="cx-feat__copy">
|
||||
<h3 class="cx-feat__title">Send an invoice in a minute</h3>
|
||||
<p class="cx-feat__text">Add the customer and what they owe, then send. Reminders go out on their own until it is paid.</p>
|
||||
</div>
|
||||
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="day" data-cx-seed="3" data-cx-params='{"horizon":0.4,"side":-1,"slope":10}' data-cx-loop="invoice">
|
||||
<div class="cx-feat__win cx-feat__win--bleed" data-demo aria-hidden="true">
|
||||
<div class="cx-feat__bar">
|
||||
<span class="cx-feat__name">Invoice INV-0142</span>
|
||||
<span class="cx-feat__chip" data-state>Draft</span>
|
||||
<span class="cx-feat__btn" data-target><span data-label>Send</span></span>
|
||||
</div>
|
||||
<dl class="cx-feat__meta">
|
||||
<div><dt>Billed to</dt><dd>Northwind Ltd</dd></div>
|
||||
<div><dt>Due</dt><dd>14 Nov 2026</dd></div>
|
||||
</dl>
|
||||
<ul class="cx-feat__lines">
|
||||
<li><span>Design retainer, October</span><b class="cx-num">$2,400.00</b></li>
|
||||
<li><span>Hosting, October</span><b class="cx-num">$120.00</b></li>
|
||||
<li class="is-total"><span>Total</span><b class="cx-num">$2,520.00</b></li>
|
||||
</ul>
|
||||
<div class="cx-feat__foot"><span>Reminders</span><span>11 Nov, then every 7 days until paid</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cx-feat__card">
|
||||
<div class="cx-feat__copy">
|
||||
<h3 class="cx-feat__title">Take payment with a link</h3>
|
||||
<p class="cx-feat__text">Share a link by email or chat. Your customer pays by card, Apple Pay or bank transfer.</p>
|
||||
</div>
|
||||
<div class="cx-feat__panel" data-cx-effect="grain-gradient" data-cx-seed="47">
|
||||
<div class="cx-feat__win cx-feat__win--narrow cx-feat__pay" aria-hidden="true">
|
||||
<span class="cx-feat__name">Halden & Co</span>
|
||||
<span class="cx-feat__amount cx-num">$1,080.00</span>
|
||||
<span class="cx-feat__note">Invoice INV-0139, due 14 Nov</span>
|
||||
<span class="cx-feat__label">Email</span>
|
||||
<span class="cx-feat__field cx-feat__field--first">accounts@northwind.co</span>
|
||||
<span class="cx-feat__label">Card</span>
|
||||
<span class="cx-feat__field">
|
||||
<span class="cx-num">4242 4242 4242 4242</span>
|
||||
<span class="cx-feat__marks">
|
||||
<svg class="cx-mark" aria-hidden="true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 83"> <g> <path d="M132.397094,56.2397455 C132.251036,44.7242808 142.65954,38.2977599 150.500511,34.4772086 C158.556724,30.5567233 161.262627,28.0430004 161.231878,24.5376253 C161.17038,19.1719416 154.805357,16.804276 148.847757,16.7120293 C138.454628,16.5505975 132.412467,19.5178668 127.607952,21.7625368 L123.864273,4.24334875 C128.684163,2.02174043 137.609033,0.084559486 146.864453,-7.10542736e-15 C168.588553,-7.10542736e-15 182.802234,10.7236802 182.879107,27.3511501 C182.963666,48.4525854 153.69071,49.6210438 153.890577,59.05327 C153.959762,61.912918 156.688728,64.964747 162.669389,65.7411565 C165.628971,66.133205 173.800493,66.433007 183.0636,62.1665965 L186.699658,79.11693 C181.718335,80.931115 175.314876,82.6684285 167.343223,82.6684285 C146.895202,82.6684285 132.512402,71.798691 132.397094,56.2397455 M221.6381,81.2078555 C217.671491,81.2078555 214.327548,78.8940005 212.836226,75.342502 L181.802894,1.24533061 L203.511621,1.24533061 L207.831842,13.1835926 L234.360459,13.1835926 L236.866494,1.24533061 L256,1.24533061 L239.303345,81.2078555 L221.6381,81.2078555 M224.674554,59.6067505 L230.939643,29.5804456 L213.781755,29.5804456 L224.674554,59.6067505 M106.076031,81.2078555 L88.9642665,1.24533061 L109.650591,1.24533061 L126.754669,81.2078555 L106.076031,81.2078555 M75.473185,81.2078555 L53.941265,26.7822953 L45.2316377,73.059396 C44.2092367,78.2252115 40.1734431,81.2078555 35.6917903,81.2078555 L0.491982464,81.2078555 L0,78.886313 C7.22599245,77.318119 15.4359498,74.7890215 20.409585,72.083118 C23.4537265,70.4303645 24.322383,68.985166 25.3217224,65.0569935 L41.8185094,1.24533061 L63.68098,1.24533061 L97.1972855,81.2078555 L75.473185,81.2078555" fill="currentColor" transform="translate(128.000000, 41.334214) scale(1, -1) translate(-128.000000, -41.334214) "></path> </g></svg>
|
||||
<svg class="cx-mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="-0.48 4.104 24.96 15.792" fill="currentColor"><path d="M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="cx-feat__btn cx-feat__btn--wide">Pay $1,080.00</span>
|
||||
<span class="cx-feat__or">or pay by bank transfer</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cx-feat__card">
|
||||
<div class="cx-feat__copy">
|
||||
<h3 class="cx-feat__title">Get paid out on your schedule</h3>
|
||||
<p class="cx-feat__text">Money reaches your bank daily, weekly or monthly. You see every payout before it leaves.</p>
|
||||
</div>
|
||||
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="dusk" data-cx-seed="9" data-cx-params='{"horizon":0.5,"side":1,"slope":16,"hillY":0.1}'>
|
||||
<div class="cx-feat__win cx-feat__win--side cx-feat__payout" aria-hidden="true">
|
||||
<div class="cx-feat__bar">
|
||||
<span class="cx-feat__name">Payouts</span>
|
||||
<span class="cx-feat__seg"><span>Daily</span><span class="is-on">Weekly</span><span>Monthly</span></span>
|
||||
</div>
|
||||
<ul class="cx-feat__lines">
|
||||
<li><span>Mon 3 Nov, to account ••42</span><b class="cx-num">$12,480.22</b></li>
|
||||
<li><span>Mon 27 Oct, paid</span><b class="cx-num">$9,904.10</b></li>
|
||||
<li><span>Mon 20 Oct, paid</span><b class="cx-num">$11,236.75</b></li>
|
||||
<li><span>Mon 13 Oct, paid</span><b class="cx-num">$8,718.40</b></li>
|
||||
<li><span>Mon 6 Oct, paid</span><b class="cx-num">$10,052.65</b></li>
|
||||
<li><span>Mon 29 Sep, paid</span><b class="cx-num">$9,377.18</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cx-feat__card">
|
||||
<div class="cx-feat__copy">
|
||||
<h3 class="cx-feat__title">Give your team the right access</h3>
|
||||
<p class="cx-feat__text">Invite your accountant to see the books and nothing else. Every change is kept in the log.</p>
|
||||
</div>
|
||||
<div class="cx-feat__panel" data-cx-effect="painterly-landscape" data-cx-scene="meadow" data-cx-seed="14" data-cx-params='{"horizon":0.36,"side":1,"slope":20,"hillY":0.05}' data-cx-loop="invite">
|
||||
<div class="cx-feat__win cx-feat__win--bleed" data-demo aria-hidden="true">
|
||||
<div class="cx-feat__bar">
|
||||
<span class="cx-feat__name">Invite people</span>
|
||||
</div>
|
||||
<div class="cx-feat__invite">
|
||||
<span class="cx-feat__input"><span data-type></span><i class="cx-feat__caret"></i></span>
|
||||
<span class="cx-feat__select">Can view<svg viewBox="0 0 12 12" width="10" height="10"><path d="m3 4.5 3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="cx-feat__btn" data-target><span data-label>Invite</span></span>
|
||||
</div>
|
||||
<ul class="cx-feat__people">
|
||||
<li class="cx-feat__new" data-new><span class="cx-feat__ava" style="--a:24%">AN</span><span>ana@northwind.co</span><em class="cx-feat__chip">Invited</em></li>
|
||||
<li><span class="cx-feat__ava" style="--a:38%">MO</span><span>Maya Okafor</span><em>Owner</em></li>
|
||||
<li><span class="cx-feat__ava" style="--a:12%">JK</span><span>Jonas Keller</span><em>Admin</em></li>
|
||||
<li><span class="cx-feat__ava" style="--a:30%">LO</span><span>Luis Ortega</span><em>Can view</em></li>
|
||||
<li><span class="cx-feat__ava" style="--a:18%">SB</span><span>Sofia Brandt</span><em>Can edit</em></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,247 @@
|
||||
// fx-feature-card: every inset panel gets its own still ground (a painted landscape or a soft grain),
|
||||
// drawn once and kept as a picture: the four grounds render one after another through one WebGL context
|
||||
// that is freed straight after, so the cards hold no context and draw no frames at rest. The UI window on
|
||||
// each ground is plain HTML, cut by the panel's bottom edge. Panels marked data-cx-loop run a short
|
||||
// pointer demo (glide about 1 s, click, the state changes, rest about 3 s) while on screen, with a pause
|
||||
// button in the panel's corner; the others show a settled still. Reduced motion shows every demo in its
|
||||
// end state with no pointer.
|
||||
import { fx, still, palette, color, bag, reduced, held, pauser, paramsOf } from '../shared.js';
|
||||
|
||||
// each ground: still (no drift), no calm patch (the window covers the middle)
|
||||
const GROUNDS = {
|
||||
'painterly-landscape': { drift: 0, calm: 0, blur: 0.65, brush: 0.55, grain: 0.035 },
|
||||
'grain-gradient': { speed: 0, grainMotion: 0, grain: 0.45, tint: 0.7, contrast: 0.6, softness: 0.55, calm: 0.3, calmX: 0.5, calmY: 0.5 },
|
||||
'ruled-grounds': { speed: 0, focus: 0, ink: 0.5, grain: 0.4, calm: 0.4, calmX: 0.5, calmY: 0.6 },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ the light of each panel */
|
||||
|
||||
const ok = (l, c, h) => color.fromLch([l, Math.max(0, c), h]);
|
||||
const lum = c => color.lab(c)[0];
|
||||
const coolHue = h => Math.min(265, Math.max(205, h));
|
||||
|
||||
/* what the page gives the pictures: its kind, the accent's hue family, its darkest and palest tone */
|
||||
function read(page) {
|
||||
const [La, Ca, ha] = color.lch(page.accent);
|
||||
const [Lb, Cb, hb] = color.lch(page.bg);
|
||||
const inkLight = lum(page.ink) > lum(page.bg);
|
||||
return {
|
||||
kind: fx.paletteKind(page), La, Ca, ha, Lb, Cb, hb,
|
||||
warm: Ca > 0.05 && (ha < 80 || ha >= 330), // red, orange, amber
|
||||
cool: Ca > 0.04 && ha >= 190 && ha <= 285, // blue, teal
|
||||
dark: inkLight ? page.bg : page.ink,
|
||||
pale: inkLight ? page.ink : page.bg,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
A painted landscape is a picture, so it takes a natural light, near the page but never one hue laid
|
||||
over everything:
|
||||
- day: a pale sky (the accent's hue at low chroma when the accent is cool, else almost grey) over
|
||||
hills that step down to the near slope: graphite under a warm accent (an orange hill reads as rust),
|
||||
a misty blue-grey under a cool one, the accent itself when it is green or neutral; on a colour page
|
||||
deeper shades of the page colour.
|
||||
- dusk: a deep blue sky that lights up in gold only along the horizon, dark hills with a lit crest
|
||||
(the gold leans toward a warm accent, between amber and gold); on a colour page a light shade of the
|
||||
page colour (never its complementary accent).
|
||||
- meadow: daylight as a photograph shows it, whatever the page: a clear blue sky that pales toward the
|
||||
horizon, a blue far ridge and a sunlit field of grass in front. On a blue colour page the sky is the
|
||||
page colour itself. The natural picture among the panels, as on x.ai's cards.
|
||||
*/
|
||||
const SCENES = {
|
||||
day(page, k) {
|
||||
let sky, hill;
|
||||
if (k.kind === 'colour') {
|
||||
sky = ok(0.87, Math.min(k.Cb * 0.3, 0.06), k.hb);
|
||||
hill = ok(0.46, Math.min(k.Cb * 0.8, 0.14), k.hb);
|
||||
} else {
|
||||
const h = k.cool ? coolHue(k.ha) : k.kind === 'light' && k.Cb > 0.012 ? k.hb : 235;
|
||||
const L = k.kind === 'light' ? Math.min(0.93, Math.max(0.8, k.Lb - 0.05)) : 0.84;
|
||||
sky = ok(L, k.cool ? 0.04 : k.kind === 'light' ? Math.min(k.Cb, 0.03) : 0.012, h);
|
||||
hill = k.warm ? ok(0.42, 0.008, k.ha)
|
||||
: k.cool ? ok(Math.min(k.La, 0.62), Math.min(k.Ca * 0.55, 0.07), coolHue(k.ha))
|
||||
: ok(Math.min(k.La, 0.6), Math.min(k.Ca, 0.1), k.ha);
|
||||
}
|
||||
return { bg: sky, surface: color.lighten(sky, -0.04), ink: k.dark, muted: color.mix(sky, k.dark, 0.4), accent: hill, accentInk: k.pale };
|
||||
},
|
||||
dusk(page, k) {
|
||||
// a natural evening: a deep blue sky with a band of gold low on the horizon, whatever the accent
|
||||
// (a blue glow under a blue accent read as the day panel again, only darker). The gold leans toward a
|
||||
// warm accent's hue but stays between amber and gold: a red-orange glow paints the lit crests rust.
|
||||
// A gold that mixes with the cool grey of the hills turns beige, never mauve or violet.
|
||||
const colourPage = k.kind === 'colour';
|
||||
const h = colourPage ? k.hb : k.cool ? coolHue(k.ha) : 248;
|
||||
const c = colourPage ? Math.min(k.Cb * 0.5, 0.065) : 0.04;
|
||||
const sky = ok(0.26, c, h);
|
||||
const glow = colourPage ? ok(0.78, Math.min(k.Cb * 0.4, 0.08), k.hb)
|
||||
: ok(0.8, 0.115, k.warm ? Math.min(80, Math.max(58, k.ha < 180 ? k.ha : 58)) : 74);
|
||||
return { bg: sky, surface: color.lighten(sky, 0.04), ink: k.pale, muted: ok(0.58, 0.012, h), accent: glow, accentInk: k.dark };
|
||||
},
|
||||
meadow(page, k) {
|
||||
// a sky with chroma above 0.07 is painted as a colour sky over a field of the accent: here a clear
|
||||
// blue (never toward violet) over sunlit grass
|
||||
const blue = k.kind === 'colour' && k.hb >= 220 && k.hb <= 270;
|
||||
const sky = blue ? ok(0.62, Math.min(Math.max(k.Cb * 0.6, 0.09), 0.12), k.hb) : ok(0.66, 0.1, 238);
|
||||
const grass = ok(0.74, 0.12, 112);
|
||||
return { bg: sky, surface: color.lighten(sky, -0.05), ink: ok(0.97, 0.01, 238), muted: ok(0.8, 0.03, 238), accent: grass, accentInk: k.dark };
|
||||
},
|
||||
};
|
||||
|
||||
/* a soft grain: light grounds under a warm accent (a warm accent on a dark ground mixes to rust), deep
|
||||
ones under a cool accent, the page colour's own shades on a colour page */
|
||||
function grainGround(page, k) {
|
||||
if (k.kind === 'colour') {
|
||||
return { ...page, bg: ok(0.5, Math.min(k.Cb * 0.8, 0.16), k.hb), surface: ok(0.42, Math.min(k.Cb * 0.9, 0.18), k.hb), accent: ok(0.8, Math.min(k.Cb * 0.4, 0.09), k.hb), muted: ok(0.66, 0.04, k.hb) };
|
||||
}
|
||||
if (k.kind === 'dark') {
|
||||
if (k.cool) return { ...page, bg: ok(0.34, Math.min(k.Ca * 0.4, 0.06), k.ha), surface: ok(0.42, Math.min(k.Ca * 0.5, 0.07), k.ha), muted: ok(0.6, 0.03, k.ha) };
|
||||
return { ...page, bg: ok(0.8, 0.025, k.ha), surface: ok(0.72, 0.04, k.ha), ink: k.dark, muted: ok(0.6, 0.02, k.ha) };
|
||||
}
|
||||
return { ...page, bg: color.mix(page.bg, page.accent, 0.16), surface: color.mix(page.bg, page.accent, 0.3), muted: color.mix(page.bg, page.ink, 0.5) };
|
||||
}
|
||||
|
||||
function groundPalette(page, id, scene) {
|
||||
const k = read(page);
|
||||
if (id === 'painterly-landscape') return (SCENES[scene] || SCENES.day)(page, k);
|
||||
if (id === 'grain-gradient') return grainGround(page, k);
|
||||
return { ...page, bg: color.mix(page.bg, page.accent, 0.16), surface: color.mix(page.bg, page.accent, 0.3), muted: color.mix(page.bg, page.ink, 0.5) };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ the pointer demo */
|
||||
|
||||
const POINTER = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 2.8v13.4l3.5-3.3 2.3 5.1 2.5-1.1-2.3-5h5Z"/></svg>';
|
||||
|
||||
/* one demo: a pointer and a click ring in the panel; script(api) runs once per loop */
|
||||
function demo(panel, script, end) {
|
||||
const ptr = document.createElement('span');
|
||||
ptr.className = 'cx-feat__ptr';
|
||||
ptr.innerHTML = POINTER;
|
||||
const ring = document.createElement('span');
|
||||
ring.className = 'cx-feat__ring';
|
||||
panel.append(ring, ptr);
|
||||
let alive = true, onScreen = false;
|
||||
const io = new IntersectionObserver(e => { onScreen = e[e.length - 1].isIntersecting; });
|
||||
io.observe(panel);
|
||||
const running = () => onScreen && !document.hidden && !held(panel);
|
||||
// wait ms of running time: off screen, in a hidden tab or paused, the clock stops
|
||||
const wait = ms => new Promise(res => {
|
||||
let acc = 0, last = performance.now();
|
||||
const step = () => {
|
||||
if (!alive) return;
|
||||
const now = performance.now();
|
||||
if (running()) acc += now - last;
|
||||
last = now;
|
||||
if (acc >= ms) res(); else setTimeout(step, running() ? Math.min(100, ms - acc) : 250);
|
||||
};
|
||||
step();
|
||||
});
|
||||
const move = (x, y, instant) => {
|
||||
if (instant) ptr.style.transition = 'none';
|
||||
ptr.style.transform = `translate(${(x - 4).toFixed(1)}px, ${(y - 3).toFixed(1)}px)`;
|
||||
if (instant) { ptr.getBoundingClientRect(); ptr.style.transition = ''; }
|
||||
};
|
||||
const spot = el => {
|
||||
const pr = panel.getBoundingClientRect(), r = el.getBoundingClientRect();
|
||||
return [r.left - pr.left + r.width * 0.56, r.top - pr.top + r.height * 0.62];
|
||||
};
|
||||
const show = on => { ptr.style.opacity = on ? '1' : '0'; };
|
||||
const click = async el => {
|
||||
const [x, y] = spot(el);
|
||||
// the ring is placed with the translate property and grows with scale: the two compose as
|
||||
// translate then scale, so the ring grows on the spot (a translate inside transform would be
|
||||
// scaled too and fly off across the window)
|
||||
ring.style.translate = `${x.toFixed(1)}px ${y.toFixed(1)}px`;
|
||||
ring.classList.remove('is-on'); void ring.offsetWidth; ring.classList.add('is-on');
|
||||
el.classList.add('is-down');
|
||||
await wait(140);
|
||||
el.classList.remove('is-down');
|
||||
};
|
||||
const api = { wait, move, spot, show, click, panel };
|
||||
if (reduced()) end();
|
||||
else {
|
||||
end(); // the finished picture first, so a panel that is off screen, paused or never seen is never empty
|
||||
(async () => { while (alive) { await wait(600); await script(api); } })();
|
||||
}
|
||||
return { destroy() { alive = false; io.disconnect(); ptr.remove(); ring.remove(); } };
|
||||
}
|
||||
|
||||
const SCRIPTS = {
|
||||
invoice(panel) {
|
||||
const btn = panel.querySelector('[data-target]'), label = btn.querySelector('[data-label]');
|
||||
const state = panel.querySelector('[data-state]');
|
||||
const reset = () => { btn.classList.remove('is-done'); label.textContent = 'Send'; state.textContent = 'Draft'; state.classList.remove('is-on'); };
|
||||
const done = () => { btn.classList.add('is-done'); label.textContent = 'Sent'; state.textContent = 'Sent'; state.classList.add('is-on'); };
|
||||
return {
|
||||
still: done,
|
||||
async run({ wait, move, spot, show, click, panel: p }) {
|
||||
reset();
|
||||
const [x, y] = spot(btn);
|
||||
show(false);
|
||||
move(x - 150, y + 150, true);
|
||||
await wait(900);
|
||||
show(true);
|
||||
move(x, y);
|
||||
await wait(1300);
|
||||
await click(btn);
|
||||
label.textContent = 'Sending';
|
||||
await wait(700);
|
||||
done();
|
||||
await wait(500);
|
||||
move(x + 40, Math.min(p.clientHeight - 30, y + 110));
|
||||
show(false);
|
||||
await wait(3200);
|
||||
},
|
||||
};
|
||||
},
|
||||
invite(panel) {
|
||||
const btn = panel.querySelector('[data-target]'), label = btn.querySelector('[data-label]');
|
||||
const typed = panel.querySelector('[data-type]'), win = panel.querySelector('[data-demo]');
|
||||
const EMAIL = 'ana@northwind.co';
|
||||
const reset = () => { typed.textContent = ''; win.classList.remove('is-added'); btn.classList.remove('is-done'); label.textContent = 'Invite'; };
|
||||
const done = () => { typed.textContent = ''; win.classList.add('is-added'); btn.classList.add('is-done'); label.textContent = 'Invited'; };
|
||||
return {
|
||||
still: done,
|
||||
async run({ wait, move, spot, show, click, panel: p }) {
|
||||
reset();
|
||||
const [x, y] = spot(btn);
|
||||
show(false);
|
||||
move(x - 160, y + 140, true);
|
||||
await wait(700);
|
||||
for (const ch of EMAIL) { typed.textContent += ch; await wait(55); }
|
||||
await wait(300);
|
||||
show(true);
|
||||
move(x, y);
|
||||
await wait(1300);
|
||||
await click(btn);
|
||||
await wait(250);
|
||||
done();
|
||||
await wait(500);
|
||||
move(x + 30, Math.min(p.clientHeight - 30, y + 120));
|
||||
show(false);
|
||||
await wait(3400);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const page = palette(root);
|
||||
root.querySelectorAll('.cx-feat__panel').forEach(panel => {
|
||||
const id = panel.dataset.cxEffect || 'painterly-landscape';
|
||||
const ground = groundPalette(page, id, panel.dataset.cxScene);
|
||||
const params = { ...(GROUNDS[id] || {}), ...(paramsOf(panel) || {}), speed: 0, drift: 0 };
|
||||
// the ground never moves: drawn once, kept as a picture, its context freed
|
||||
b.add(still(panel, id, { palette: ground, seed: panel.dataset.cxSeed, params }));
|
||||
const loop = SCRIPTS[panel.dataset.cxLoop];
|
||||
if (loop) {
|
||||
// the pause button holds the pointer demo (the ground is a picture and has nothing to pause)
|
||||
if (!reduced()) b.add(pauser(panel, { label: 'the demo', corner: 'tr' }));
|
||||
const s = loop(panel);
|
||||
b.add(demo(panel, s.run, s.still));
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
export { groundPalette };
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "fx-feature-card",
|
||||
"name": "Feature cards with inset panels",
|
||||
"for": "The features grid of a product page, as on the best company sites: a centred headline and a grey line, then a 2x2 of cards, each a title and two grey lines, then an inset panel with its own margin holding a bright painterly or grain ground and one smaller UI window that runs out through the panel's bottom edge. Two panels run a short pointer demo; the cards never move.",
|
||||
"effects": [
|
||||
"painterly-landscape",
|
||||
"grain-gradient",
|
||||
"ruled-grounds"
|
||||
],
|
||||
"default": "painterly-landscape",
|
||||
"how": "Each panel names its ground in data-cx-effect, its light in data-cx-scene (day, dusk or meadow, for painterly-landscape) and its composition in data-cx-params (horizon, side, slope), so no two panels look alike. The grounds are still: drawn once as they near the screen, kept as pictures and their WebGL context freed, one after another, so the cards hold no context and draw no frames at rest. A landscape takes a natural light near the page: a pale sky over graphite hills under a warm accent, blue-grey under a cool one, the accent itself when it is green or neutral, the page colour's own shades on a colour page; dusk is a deep blue evening sky lit in gold along the horizon, whatever the accent (a blue glow read as the day panel again); meadow is natural daylight (a blue sky over sunlit grass), as a photograph would show it. The sample grid mixes day, a grain, dusk and meadow. The window is plain HTML in the page's own ground colour, cut by the panel's bottom edge (classes --bleed, --narrow, --side vary its width and place); its content runs out through that edge. A card is a subgrid of two rows, so the text zones of a row share one height. Panels with data-cx-loop run a pointer demo (glide, click, state change, rest) only while on screen, each with a pause button in its top-right corner.",
|
||||
"rules": [
|
||||
"Text only in the top zone on the flat card fill; the panel never reaches under it.",
|
||||
"Panel margin 8px on three sides, panel radius = card radius - margin.",
|
||||
"Every window runs out through the panel's bottom edge; never a small dialog centred on an empty ground (that reads cheap).",
|
||||
"No orb, sphere or other focal ground in these panels: the window is the subject.",
|
||||
"One capability per card, one UI window per panel, real labels in it (never lorem), no UI text under 11px.",
|
||||
"At most two looping panels in view (one per row); the others show a settled still.",
|
||||
"The loop never follows scroll and the card never moves.",
|
||||
"Every panel a different picture: a different scene or composition, or a grain; never four copies of one landscape, never a landscape painted in the accent hue only.",
|
||||
"The window's content reaches the panel's bottom edge: no empty band under its last row."
|
||||
],
|
||||
"a11y": "Each card has a heading and a sentence that say what the feature does; the UI windows are illustrations (aria-hidden). Looping panels have a pause button; reduced motion shows the end state with no pointer and no button."
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* fx-globe: the dotted globe as a data visual, rising from the panel's floor, beside live region figures */
|
||||
.cx-globe { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: var(--s-4); padding: var(--s-4); overflow: hidden; }
|
||||
.cx-globe__copy { display: flex; flex-direction: column; gap: var(--s-3); padding: var(--s-5) var(--s-4) var(--s-4); min-width: 0; }
|
||||
.cx-globe__title { font: 500 var(--t-h3)/1.2 var(--font-display); letter-spacing: -0.02em; margin: var(--s-1) 0 0; max-width: 18ch; text-wrap: balance; }
|
||||
.cx-globe__live { width: 7px; height: 7px; border-radius: 9px; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
|
||||
.cx-globe__table { width: 100%; border-collapse: collapse; margin-top: auto; font-size: var(--t-small); }
|
||||
.cx-globe__table tr { border-top: 1px solid var(--cx-line); }
|
||||
.cx-globe__table tr:last-child { border-bottom: 1px solid var(--cx-line); }
|
||||
.cx-globe__table th, .cx-globe__table td { padding: 11px 0; text-align: left; font-weight: 400; }
|
||||
.cx-globe__table th { color: var(--ink); display: flex; align-items: center; gap: 10px; }
|
||||
.cx-globe__table td:nth-child(2) { color: var(--muted); font-family: var(--cx-mono); font-size: 12px; }
|
||||
.cx-globe__table td:last-child { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.cx-globe__dot { width: 6px; height: 6px; border-radius: 9px; background: color-mix(in oklab, var(--accent) 80%, var(--ink)); }
|
||||
.cx-globe__panel {
|
||||
position: relative; isolation: isolate; min-height: 440px; border-radius: var(--r-md); overflow: hidden;
|
||||
background: var(--cx-panel); padding: var(--s-5);
|
||||
}
|
||||
.cx-globe__chip {
|
||||
position: relative; z-index: 1; display: inline-grid; gap: 2px; padding: 12px 14px; border-radius: var(--r-md);
|
||||
background: color-mix(in oklab, var(--bg) 82%, transparent); backdrop-filter: blur(10px);
|
||||
box-shadow: var(--cx-shadow);
|
||||
}
|
||||
.cx-globe__chip-k { font-size: 12px; color: var(--muted); }
|
||||
.cx-globe__chip-v { font: 500 22px/1.15 var(--font-display); letter-spacing: -0.02em; }
|
||||
@media (max-width: 899px) {
|
||||
.cx-globe { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-globe__panel { min-height: 360px; order: -1; }
|
||||
.cx-globe__copy { padding: var(--s-2) var(--s-2) var(--s-2); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<article class="cx cx-card cx-globe">
|
||||
<div class="cx-globe__copy">
|
||||
<span class="cx-eyebrow"><span class="cx-globe__live" aria-hidden="true"></span>Network</span>
|
||||
<h3 class="cx-globe__title">Your app runs in the region closest to each user</h3>
|
||||
<p class="cx-text">Requests go to the nearest of 41 regions. If one is slow, traffic moves to the next one in seconds.</p>
|
||||
<table class="cx-globe__table">
|
||||
<caption class="cx-sr">Response time by region</caption>
|
||||
<thead class="cx-sr"><tr><th scope="col">Region</th><th scope="col">Code</th><th scope="col">Response time</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Frankfurt</th><td>fra1</td><td><span class="cx-num" data-ms="18">18</span> ms</td></tr>
|
||||
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Virginia</th><td>iad1</td><td><span class="cx-num" data-ms="24">24</span> ms</td></tr>
|
||||
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>Singapore</th><td>sin1</td><td><span class="cx-num" data-ms="33">33</span> ms</td></tr>
|
||||
<tr><th scope="row"><span class="cx-globe__dot" aria-hidden="true"></span>São Paulo</th><td>gru1</td><td><span class="cx-num" data-ms="41">41</span> ms</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cx-globe__panel" data-cx-seed="5">
|
||||
<div class="cx-globe__chip">
|
||||
<span class="cx-globe__chip-k">Requests now</span>
|
||||
<span class="cx-globe__chip-v cx-num" data-count="48210">48,210</span>
|
||||
<span class="cx-globe__chip-k">per second</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,31 @@
|
||||
// fx-globe: dotted-globe in an inset panel, rising from its floor; the region times and the request
|
||||
// counter change a little about once a second while the card is on screen (the card never moves).
|
||||
// The panel's pause button holds the globe and the counters together.
|
||||
import { mount, palette, tone, bag, whileVisible, pauser } from '../shared.js';
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const panel = root.querySelector('.cx-globe__panel');
|
||||
const page = palette(root);
|
||||
const p = b.add(pauser(root, { label: 'the globe', at: panel }));
|
||||
b.add(p.add(mount(panel, 'dotted-globe', {
|
||||
palette: { ...page, bg: tone(page, 0.065) },
|
||||
seed: panel.dataset.cxSeed,
|
||||
params: { calmX: 0.5, calmY: 0.12, calm: 0, size: 1.05, speed: 3, arcs: 3.2, tilt: 18, glow: 0.6 },
|
||||
})));
|
||||
const cells = [...root.querySelectorAll('[data-ms]')];
|
||||
const count = root.querySelector('[data-count]');
|
||||
let n = +count.dataset.count;
|
||||
const fmt = new Intl.NumberFormat('en-US');
|
||||
b.add(whileVisible(root, {
|
||||
every: 1200,
|
||||
run() {
|
||||
const c = cells[Math.floor(Math.random() * cells.length)];
|
||||
const base = +c.dataset.ms;
|
||||
c.textContent = String(base + Math.round((Math.random() - 0.5) * 4));
|
||||
n += Math.round((Math.random() - 0.45) * 900);
|
||||
count.textContent = fmt.format(n);
|
||||
},
|
||||
}));
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-globe",
|
||||
"name": "Globe data card",
|
||||
"for": "A feature card for a product whose reach is global (payments, hosting, logistics, networks): the dotted globe rises from the floor of an inset panel, with arcs between cities, beside live region figures.",
|
||||
"effects": ["dotted-globe"],
|
||||
"default": "dotted-globe",
|
||||
"how": "dotted-globe mounted on the inset panel with the calm area at the top, so the globe is cut by the panel's floor; a small opaque card sits in the clear top corner.",
|
||||
"rules": ["Only when the product really works across countries.", "Figures tick about once a second, by a few units; nothing counts up on scroll.", "One globe per page."],
|
||||
"a11y": "Region figures are a real table with a caption; the globe is aria-hidden. Under reduced motion the figures and globe hold still."
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* fx-headline: a display headline whose key phrase (or every word) is filled with an effect */
|
||||
.cx-headline {
|
||||
display: grid; justify-items: center; text-align: center; gap: var(--s-5);
|
||||
max-width: 980px; margin-inline: auto;
|
||||
}
|
||||
.cx-headline__pill {
|
||||
display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 12px 0 4px;
|
||||
border-radius: 999px; border: 1px solid var(--cx-line); background: var(--cx-card);
|
||||
font-size: var(--t-label); color: var(--ink);
|
||||
transition: border-color var(--dur) var(--ease), background-color var(--dur) var(--ease);
|
||||
}
|
||||
.cx-headline__pill:hover { border-color: var(--cx-line-2); background: var(--cx-panel); }
|
||||
.cx-headline__pill svg { color: var(--muted); transition: transform var(--dur) var(--ease); }
|
||||
.cx-headline__pill:hover svg { transform: translateX(2px); }
|
||||
.cx-headline__tag {
|
||||
display: inline-grid; place-items: center; height: 24px; padding: 0 10px; border-radius: 999px;
|
||||
background: var(--action); color: var(--action-ink); font-weight: 500; font-size: 12px;
|
||||
}
|
||||
.cx-headline__title {
|
||||
font-size: clamp(40px, 5.2vw + 12px, 88px); line-height: 1.02; letter-spacing: -0.035em;
|
||||
max-width: 15ch; margin: 0;
|
||||
}
|
||||
.cx-headline__title [data-fill] { white-space: nowrap; }
|
||||
.cx-headline__sub { font-size: var(--t-lead); line-height: 1.5; color: var(--muted); max-width: 46ch; margin: 0; }
|
||||
.cx-headline__actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-3); margin-top: var(--s-2); }
|
||||
@media (max-width: 639px) {
|
||||
.cx-headline__actions { flex-direction: column; align-items: stretch; width: 100%; }
|
||||
.cx-headline__title [data-fill] { white-space: normal; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="cx cx-headline">
|
||||
<a class="cx-headline__pill" href="#">
|
||||
<span class="cx-headline__tag">New</span>
|
||||
<span>Instant payouts to debit cards</span>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</a>
|
||||
<h1 class="cx-headline__title" data-cx-fill="silk-folds" data-cx-seed="14">Accept payments in <span data-fill>135 currencies</span></h1>
|
||||
<p class="cx-headline__sub">One API for cards, wallets and bank transfers. The money lands in your bank account two days later.</p>
|
||||
<div class="cx-headline__actions">
|
||||
<a class="btn btn--primary" href="#">Start for free</a>
|
||||
<a class="btn btn--secondary" href="#">Talk to sales</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
// fx-headline: fills [data-cx-fill] headlines (their [data-fill] phrase, or all of their words) with an
|
||||
// effect. The fill is still (speed 0, still grain): letters that keep moving would need a pause button
|
||||
// next to the page's main line, and a still sheen reads as well. Being still, it is drawn once and kept
|
||||
// as a picture: no WebGL context and no frames at rest.
|
||||
import { fill, bag, paramsOf } from '../shared.js';
|
||||
|
||||
const STILL = { speed: 0, drift: 0, grainMotion: 0 };
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
root.querySelectorAll('[data-cx-fill]').forEach(el => {
|
||||
b.add(fill(el, el.dataset.cxFill, { still: true, seed: el.dataset.cxSeed, params: { ...(paramsOf(el) || {}), ...STILL } }));
|
||||
});
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-headline",
|
||||
"name": "Effect headline",
|
||||
"for": "The one display line on a page (hero or closing call to action) where a key phrase, or the whole line, is filled with a still effect while the rest stays in ink.",
|
||||
"effects": ["silk-folds", "aurora-curtains", "grain-gradient"],
|
||||
"default": "silk-folds",
|
||||
"how": "fill(): one still canvas over the headline (speed 0), blended so only the glyphs of the [data-fill] phrase show it; the real text stays selectable. Still, so it needs no pause button.",
|
||||
"rules": ["One filled headline per page.", "Fill a phrase of two or three words, or one short line; never body text.", "Display size only: 40px and up, weight 500 or more.", "Never fluted-glass in type: on a colour ground it paints thick accent bars through the letters."],
|
||||
"a11y": "The text stays real and is read normally; the overlay is aria-hidden. Forced colours and no-script show the plain ink. Reduced motion shows one still frame."
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* fx-icon-tiles: a hairline grid of features; every icon chip is a window onto one shared effect */
|
||||
.cx-tiles { position: relative; isolation: isolate; }
|
||||
.cx-tiles__grid {
|
||||
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin: 0; padding: 0; list-style: none;
|
||||
background: var(--cx-line); border: 1px solid var(--cx-line); border-radius: var(--r-lg); overflow: hidden;
|
||||
}
|
||||
.cx-tiles__cell { display: grid; align-content: start; gap: var(--s-2); padding: var(--s-6) var(--s-6) var(--s-7); background: var(--bg); min-width: 0; }
|
||||
.cx-tiles__cell .cx-title { margin-top: var(--s-5); }
|
||||
.cx-tiles__chip {
|
||||
position: relative; z-index: 2; display: grid; place-items: center; width: 48px; height: 48px; border-radius: var(--r-md);
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
box-shadow: inset 0 1px 0 color-mix(in oklab, #fff 28%, transparent), inset 0 0 0 1px color-mix(in oklab, #000 10%, transparent), 0 6px 16px -6px color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
}
|
||||
.cx-tiles.is-live .cx-tiles__chip { background: transparent; }
|
||||
.cx-tiles__chip svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.cx-tiles__fx { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
|
||||
@media (max-width: 899px) { .cx-tiles__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 559px) {
|
||||
.cx-tiles__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-tiles__cell { padding: var(--s-5); grid-template-columns: 48px 1fr; column-gap: var(--s-4); row-gap: 4px; }
|
||||
.cx-tiles__chip { grid-row: span 2; }
|
||||
.cx-tiles__cell .cx-title { margin-top: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="cx cx-tiles" data-cx-seed="22">
|
||||
<ul class="cx-tiles__grid">
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M10 14.5V4.5M5.5 9 10 4.5 14.5 9M4 17h12"/></svg></span>
|
||||
<h3 class="cx-title">Deploys</h3>
|
||||
<p class="cx-text">Push to Git and every branch gets its own preview link.</p>
|
||||
</li>
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><ellipse cx="10" cy="5.5" rx="6" ry="2.5"/><path d="M4 5.5v9c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-9M4 10c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5"/></svg></span>
|
||||
<h3 class="cx-title">Databases</h3>
|
||||
<p class="cx-text">Postgres with daily backups and restore to any minute.</p>
|
||||
</li>
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M3 6.5 10 3l7 3.5v7L10 17l-7-3.5ZM3 6.5 10 10l7-3.5M10 10v7"/></svg></span>
|
||||
<h3 class="cx-title">Storage</h3>
|
||||
<p class="cx-text">Files and images served from 30 regions.</p>
|
||||
</li>
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M4 5h12M4 10h12M4 15h7"/></svg></span>
|
||||
<h3 class="cx-title">Logs</h3>
|
||||
<p class="cx-text">Search every request from the last 30 days.</p>
|
||||
</li>
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7"/><path d="M10 6v4l2.5 2.5"/></svg></span>
|
||||
<h3 class="cx-title">Cron jobs</h3>
|
||||
<p class="cx-text">Run a function on a schedule, down to the minute.</p>
|
||||
</li>
|
||||
<li class="cx-tiles__cell">
|
||||
<span class="cx-tiles__chip" aria-hidden="true"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7"/><path d="M3 10h14M10 3c1.9 2.1 2.8 4.5 2.8 7s-.9 4.9-2.8 7c-1.9-2.1-2.8-4.5-2.8-7S8.1 5.1 10 3Z"/></svg></span>
|
||||
<h3 class="cx-title">Domains</h3>
|
||||
<p class="cx-text">Add a domain and get HTTPS in under a minute.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cx-tiles__fx" aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
// fx-icon-tiles: one effect canvas over the grid, masked to the icon chips, so each chip shows its own
|
||||
// part of the same field (related tones, never six copies) for the cost of one canvas. The field is
|
||||
// still, so it is drawn once and kept as a picture: no WebGL context and no frames at rest.
|
||||
import { still, palette, accentPalette, windows, bag, tuned } from '../shared.js';
|
||||
|
||||
// each effect this part takes, tuned on its own: one effect's params mean something else to another
|
||||
const PARAMS = {
|
||||
'grain-gradient': { speed: 0, softness: 0.4, warp: 0.7, grain: 0.35, grainMotion: 0, tint: 0.9, contrast: 0.75, calm: 0 },
|
||||
'gradient-mesh': { speed: 0, lines: 0, warp: 0.7, softness: 0.4, tint: 0.9, calm: 0 },
|
||||
};
|
||||
|
||||
export default function init(root) {
|
||||
const b = bag();
|
||||
const host = root.querySelector('.cx-tiles__fx');
|
||||
const chips = [...root.querySelectorAll('.cx-tiles__chip')];
|
||||
const page = palette(root);
|
||||
b.add(windows(host, chips));
|
||||
const effect = root.dataset.cxEffect || 'grain-gradient';
|
||||
const s = b.add(still(host, effect, {
|
||||
palette: accentPalette(page, { lift: 0.16 }),
|
||||
seed: root.dataset.cxSeed,
|
||||
// still: six chips that drift would be six small loops on one screen
|
||||
params: { ...tuned(PARAMS, effect), speed: 0, drift: 0 },
|
||||
}));
|
||||
// the chips keep their plain accent fill until the picture is in place
|
||||
s.ready.then(() => root.classList.add('is-live'));
|
||||
b.add(() => root.classList.remove('is-live'));
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "fx-icon-tiles",
|
||||
"name": "Icon tiles",
|
||||
"for": "A grid of features where each icon chip is filled with the effect, like an app icon: one canvas seen through every chip, so the chips are related but each a little different.",
|
||||
"effects": ["grain-gradient", "gradient-mesh"],
|
||||
"default": "grain-gradient",
|
||||
"how": "windows(): one still effect host over the grid, masked to the chips' boxes (12px radius, the --r-md token); the glyphs sit above it in the accent ink. Before the canvas draws, each chip is plain accent.",
|
||||
"rules": ["Glyphs in the accent ink, drawn by hand or from one icon set; never brand logos here.", "Still: no drift and no flicker in chips this small.", "Chip radius is the --r-md token (12px), as for every chip in the kit.", "One tile group with filled chips per page."],
|
||||
"a11y": "Chips are decorative (aria-hidden); every tile has a heading and a sentence. The effect is still."
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* fx-logos: a still row of brand marks; every few seconds a band of light passes through them */
|
||||
@property --cx-sweep { syntax: "<percentage>"; inherits: true; initial-value: -30%; }
|
||||
.cx-logos { position: relative; display: grid; justify-items: center; gap: var(--s-6); text-align: center; padding-inline: 44px; }
|
||||
.cx-logos__title { margin: 0; font-size: var(--t-small); color: var(--muted); }
|
||||
.cx-logos__row {
|
||||
display: flex; flex-wrap: wrap; justify-content: center; align-items: center; column-gap: clamp(28px, 4.5vw, 64px); row-gap: var(--s-5);
|
||||
margin: 0; padding: 4px 8px; list-style: none;
|
||||
}
|
||||
.cx-logos__row li {
|
||||
display: inline-flex; align-items: center; gap: 10px; color: color-mix(in oklab, var(--ink) 62%, var(--bg));
|
||||
font: 600 20px/1 var(--font-display); letter-spacing: -0.02em;
|
||||
}
|
||||
.cx-logos__row .cx-mark { width: 24px; height: 24px; flex: none; fill: currentColor; }
|
||||
/* the light: the fill overlay (the row's next sibling) is shown only inside a soft band that crosses the row, then rests */
|
||||
.cx-logos__row + .cx-fill {
|
||||
-webkit-mask: linear-gradient(100deg, transparent calc(var(--cx-sweep) - 16%), #000 var(--cx-sweep), transparent calc(var(--cx-sweep) + 16%));
|
||||
mask: linear-gradient(100deg, transparent calc(var(--cx-sweep) - 16%), #000 var(--cx-sweep), transparent calc(var(--cx-sweep) + 16%));
|
||||
animation: cx-sweep 9s cubic-bezier(.45, .05, .55, .95) infinite;
|
||||
animation-delay: .8s;
|
||||
}
|
||||
@keyframes cx-sweep { 0% { --cx-sweep: -30%; } 34% { --cx-sweep: 130%; } 100% { --cx-sweep: 130%; } }
|
||||
/* the pause sits at the row's right end, on the row's middle line */
|
||||
.cx-logos > .cx-pause { right: 0; bottom: 0; }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-logos__row + .cx-fill { display: none; } }
|
||||
@media (max-width: 639px) {
|
||||
.cx-logos { padding-inline: 0; padding-bottom: 48px; }
|
||||
.cx-logos > .cx-pause { right: 50%; transform: translateX(50%); bottom: 0; }
|
||||
.cx-logos__row { display: grid; grid-template-columns: repeat(2, auto); justify-content: center; column-gap: 40px; }
|
||||
.cx-logos__row li { font-size: 18px; }
|
||||
}
|
||||