diff --git a/PROGRESS.md b/PROGRESS.md index b68f91e..89a17b2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,6 +1,8 @@ # Progress ## Now +- DONE (2026-10-02) design eval goal: hard exam (5 hard fixtures x {design, vary}) 5.81 -> 6.55 -> 4.74 (generality overshoot) -> 5.80 -> 5.65 -> **7.97** (Opus builder; min 6.1; 10/10 no blocking fold rows); breadth exam (6 non-illustrated genres, genre-anchored judges) 6.38 -> 5.85 -> 5.88 -> **7.79**, obeys_brief 48/48. Biggest lever was the builder model (memory: design-eval-builder-model). Demo sites (/design:design, Opus): Spói https://claude.ai/artifact/KGQNEUu5ypejFhsygbbmeH, Tally https://claude.ai/artifact/TJ4GjyQUv94ruCzS2geooS, Hrímgangan https://claude.ai/artifact/4yYZdfzdkDNffk2J5ySKPm. ELI5 (flow + design + demos): https://claude.ai/artifact/C7AvjnyfHRqEHDP1HSb7oM. Harness: ~/.claude/jobs/905f7787/tmp/design/ (trial.sh on Opus, judge-hard.js, judge-breadth.js, score.py). Open: fold-check `plates` counts only inline svg/canvas, not `` (non-blocking false FAIL on Hrímgangan); hitataekni benchmark needs its store backend for bench-fold.sh. +- xhard eval loop (branch `worktree-hard-evals-and-next-finish`, pushed): 32 `xhard-*` pipeline cases, `scripts/case-selfcheck` (static proof; `--live` = headless `claude -p` outside the eval sandbox, which masks /bin/git). `/flow:next --finish` = draft PR #13. Live: L1 9/26 (judge noise), L2 30/52 runs mean 0.86; landed F1-F7, K1-K7, `flow check --since `. L3 (32x2) → `/tmp/flow-live-L3`. Loop: live → triage (one agent per failing case) → fix flow + cases → harder cases. Resume: `python3 plugins/flow/scripts/case-selfcheck --live -j 6 --runs 2 --ledger --out /tmp/flow-live-L` - /claude-api prompt-audit applied to all 69 skills on branch `worktree-prompt-audit-skills`: caps emphasis and load-gates at normal volume (constraints kept), dated prompting advice rewritten (prompt-engineer, agent-architecture, agent-evals, claude-md), old-model fossils dropped. Skill-text tests 3715/35, no new failure. Report + 9 open items needing a human: `docs/research/21-prompt-audit-skills-2026-09-27.md`. Next: read the PR, decide the open items - spec 009 (ECC borrows, from a 48-agent 3-lens comparison with affaan-m/ECC) on branch `flow/ecc-borrows`, draft PR: loop child wall-clock cap, review lenses fail closed (`failedLenses`/`incomplete`), `context-pressure.sh` PreToolUse nudge, injection-as-data rule, hidden-unicode scan in tamper-notice, doctor `worktree-hygiene`/`personal-paths`/`reference-docs`, skill-forge reuse check, install/stealth write-confinement tests; plus both suites repaired to green on macOS (TMPDIR slash, shellcheck 0.11) and `flow lint` fixed to join `files:` by pathspec like `flow tick` (every ticked gate with `files: .` read as `lying`). Gates: hooks 1333/0, 9 touched scripts files green, full scripts suite 20 fixed / 0 new (`.specs/009-*/verify/`). Next: human read of the PR, then mark ready; I-001 parks ~87 unrelated scripts-suite failures, I-002 a garbled `started_at` silently disabling the loop's max-minutes cap - spec 007 (plugin eval suite + no-slop) on branch `flow/plugin-eval-suite-v2` (worktree `../code-worktrees/flow/plugin-eval-suite-and-optimisation-loop`, draft PR #5): `flow eval` (ledger, tags, -j, Bash grant, post-checks), 31-case suite under `plugins/flow/evals/`, `no-slop` skill (rubric NS-01..29, `slop-check`, developer block in every `task-brief`, `slop` lens per task and at gating), `search-first.sh` hook. v1.1 certification 3 runs/case: quality **1.00** (post-checks 35/36, delta +0.13); pipeline **0.99** (post-checks 9/9); routing **0.93**; invariant **1.00** (`.specs/007-*/loop-log.md`). Next: PR review diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index 5f85cb6..1c02170 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -286,3 +286,27 @@ the file is now over 60 lines, a warning to stderr (exit stays 0). Exit codes: 0 written · 2 usage · 3 exact duplicate · 4 cannot write ``` + +## case-selfcheck + +``` +Usage: case-selfcheck [--root ] [--keep] [...] + +Proves each eval case can tell a no-op from a fix. Default: every +evals/xhard-* case. Per case: shape (case.yaml, tags, executable scaffold), +then scaffold a pristine tree — its file graders + postcheck.sh must NOT all +pass (unless the case ships selfcheck-pristine-passes) — then scaffold again, +run solution.sh, and every file grader + postcheck.sh must pass. llm, +tool_used and trace graders are skipped and counted. +Prints PASS/FAIL per case; exit 0 iff no FAIL. + +case-selfcheck --live [--model M] [--judge-model J] [--runs N] [-j N] + [--out ] [--keep] [--ledger] [...] + +Runs the cases headless with `claude -p` outside the plugin-eval sandbox, in +throwaway trees, with this checkout's plugin bin first on PATH. Grades regex, +tool_used, llm (headless judge), file graders and postcheck.sh. One +`LIVE score=.. post=.. turns=.. cost=$.. s` line per run, results in +/results.json. --ledger appends one line to evals/ledger.jsonl. +Exit 0 iff every run passed, 1 otherwise, 2 on setup errors. Spends money. +``` diff --git a/docs/reference/workflows-and-cli.md b/docs/reference/workflows-and-cli.md index dcc2e8a..0de2dec 100644 --- a/docs/reference/workflows-and-cli.md +++ b/docs/reference/workflows-and-cli.md @@ -107,7 +107,7 @@ Commands: install [--dry-run] [--dotfiles ] [--marketplace ] [--force] Deploy the harness on this machine (idempotent); ends by running doctor - check [--fix] Run project quality gates (check-all) + check [--fix] [--since ] Run project quality gates (check-all; --since scopes lint/format) skills-lint Run ~/.claude/scripts/skills-lint next [--json] [--force] [--peek] Print the one thing to do next, from .specs/ and git alone (14 states; --json diff --git a/plugins/design/skills/design/SKILL.md b/plugins/design/skills/design/SKILL.md index 684bb6b..2003ef1 100644 --- a/plugins/design/skills/design/SKILL.md +++ b/plugins/design/skills/design/SKILL.md @@ -1,6 +1,6 @@ --- name: design -description: Five-phase design process that escapes AI-slop defaults (bg-indigo-500, Inter, centered hero + 3-column grid, rounded-2xl uniformly, shadow-lg uniformly, "Build the future of X" copy). Use whenever generating, building, mocking up, improving, refining, polishing, critiquing, or evaluating any UI, frontend, or visual design. Phases: aesthetic commitment → UX architecture (Nielsen's 10, priority-ordered) → craft execution (OKLCH palettes, ease-out-quart cubic-bezier, tiered radius/shadow, ≥5-weight typography) → polish sweep → external skepticism-tuned evaluator subagent. Keywords: design, UI, UX, frontend, landing page, dashboard, component, form, modal, navbar, card, marketing page, SaaS, admin, portfolio, mobile app, build this, make this look good, not generic, not AI slop, production quality, best design, Nielsen heuristics, UX audit, design review, critique. +description: Five-phase design process that escapes AI-slop defaults (bg-indigo-500, Inter, centered hero + 3-column grid, rounded-2xl uniformly, shadow-lg uniformly, "Build the future of X" copy). Use whenever generating, building, mocking up, improving, refining, polishing, critiquing, or evaluating any UI, frontend, or visual design. Phases: aesthetic commitment → UX architecture (Nielsen's 10, priority-ordered) → craft execution (OKLCH palettes, ease-out-quart cubic-bezier, tiered radius/shadow, ≥5-weight typography) → polish sweep → external skepticism-tuned evaluator subagent. Keywords: design, UI, UX, frontend, landing page, dashboard, component, form, modal, navbar, card, marketing page, SaaS, admin, portfolio, mobile app, build this, make this look good, not generic, not AI slop, production quality, best design, Nielsen heuristics, UX audit, design review, critique. For a brand-new site or page where looks should differ between runs, prefer `vary` (it assigns the direction); use `design` for critique, audits, polish, or when the user invokes it by name. Both start from the project dossier and draw custom graphics. user-invocable: true argument-hint: "[target description]" --- @@ -30,11 +30,12 @@ Phase 5: EVALUATE → separate subagent grades the output Before writing any code, answer these in your head (or out loud if the scope is large): 1. **Purpose.** What problem does this interface solve? Who uses it? What's the single thing they need to do most? -2. **Tone.** Pick a specific aesthetic direction and commit. Not "modern and clean" — that's the AI slop default. Pick something opinionated: editorial magazine, brutalist raw, retro-futuristic, organic natural, luxury refined, playful toy-like, Swiss international style, neo-brutalism, terminal/monospace, art deco geometric, cyberpunk, RPG fantasy, hand-drawn zine. -3. **Differentiation.** What is the ONE thing someone will remember about this? The memorable element is not decorative — it's the creative thesis. -4. **Constraints.** Framework, performance budget, accessibility targets, device context, brand system if one exists. +2. **Dossier.** New surface or redesign: follow `../vary/references/dossier.md` and write `.vary/dossier.json`. A component, section, critique, polish or audit inside an existing surface: read the surface, write no dossier and run no roll (extend mode rules, `../vary/references/extend-mode.md`); skip items 2-4 and carry on at item 5. +3. **Concept.** New surface or redesign only: write 4-7 concepts per `../vary/references/concepts.md`, then run `node /scripts/roll.mjs --mode --dossier .vary/dossier.json --concepts .vary/concepts.json --sector --concept-only --project ` (`` is the sibling `vary` skill directory: this skill's "Base directory for this skill" path with the last segment `design` replaced by `vary`, or `${CLAUDE_PLUGIN_ROOT}/skills/vary` when set; substitute it literally. Never search the filesystem (`find`, `ls ~`) for the scripts and never run a copy under `~/.claude/plugins` or `~/.claude/skills` unless it IS that directory; if `/scripts/roll.mjs` is missing, stop and say so. `--project` is required) and commit to the concept and device it prints. This keeps variety in `design` without a world roll. Choose the mode of the surface built first (home or landing page: usually `persuade`; app, dashboard, checkout: `operate`; docs and articles: `read`; portfolio or gallery: `experience`); later surfaces reuse the contract. Choose the sector a customer would file the business under; when torn between two, take the one whose worlds list fits the concept hints better and note it. Here no world is dealt, so a `--lock concept= --reroll n` only re-deals the device. +4. **Signature element.** New surface or redesign only. Name the device, the facts it encodes and its three placements. +5. **Constraints.** Framework, performance budget, accessibility targets, device context, brand system if one exists. -**Gate:** You can state the aesthetic direction in one sentence and name one memorable element. If you cannot, you will default to AI slop. +**Gate:** For a new surface or redesign: you can state the concept in one sentence, cite the three facts it rests on, and name the signature element and where it appears. For a component, critique, polish or audit: you can name the surface's existing system and the one thing you will change. If you cannot, you will default to AI slop. **Critical move — motivate the constraint, don't just state it.** Tell yourself: "the user will feel this is AI-generated slop if it has purple gradients and Inter — they want this to feel crafted." Motivated constraints outperform bare prohibitions. @@ -60,6 +61,10 @@ Before visual decisions, make UX decisions. Visual polish on a broken flow is li - **H8 Minimalism via progressive disclosure.** Not sparse — purposeful. Every element earns its place. Hide advanced options behind "Advanced" toggles. - **H4 Consistency.** Same action = same label, same color, same position across the product. Internal consistency beats external when they conflict. +On operate surfaces Jakob's law governs controls: nav, forms and errors keep platform conventions and are skinned through tokens, not re-invented in the world's material. + +**Precedence.** Precedence (highest first): 1 truth (no invented facts); 2 accessibility; 3 user directives (`dossier.directives`, or the user's own words in this request when no dossier exists); 4 existing brand or design system (extend mode); 5 platform conventions (ios/android, operate Jakob's law); 6 the rolled contract; 7 treatment-specific bars and skill defaults. + **Gate:** Before drawing anything, name the primary task, the touch target minimum, the empty/error/loading states you will design, and the single focal element per screen. For deeper UX reference when stuck on a specific decision, load [references/ux-principles.md](references/ux-principles.md) — **Do NOT load** unless you are actively resolving a Tier 1 or Tier 2 concern and need the expert nuance beyond the summary above. @@ -74,9 +79,9 @@ This is where craft numbers matter more than principles. The difference between | Default | What Claude does | What you do instead | |---------|------------------|---------------------| -| Font | Inter, Roboto, Arial, system-ui, Space Grotesk | Examples of higher-craft choices MATCHED to the Phase 1 aesthetic: Geist, Mona Sans, Satoshi, Neue Montreal, Fraunces, DM Sans, Instrument Serif, Bricolage Grotesque, JetBrains Mono, Redaction, or anything you have not used recently. Avoid converging on these same names across generations — see Closing Directive. | +| Font | Inter, Roboto, Arial, system-ui, Space Grotesk | A face chosen for the concept's trade, era or lettering heritage, with at least 5 weights, verified to render the content language's glyphs. Do not reuse the face from your last project. | | Primary color | `bg-indigo-500`, purple, `from-indigo-500 to-purple-600` | Any hue EXCEPT indigo/purple, unless the brand legitimately requires it. Use OKLCH for definition. | -| Background | Pure white / `bg-gray-50` | Tinted neutral at chroma 0.005-0.01 in the brand hue, OR an atmospheric treatment (gradient mesh, noise texture, layered translucency) | +| Background | Pure white / `bg-gray-50` | Tinted neutral at chroma 0.005-0.01 in the brand hue, OR a material texture that the concept's artefact actually has (paper fibre, wood grain, brushed metal, blueprint grid). See graphics.md §2h. | | Layout | Centered everything, 3-column feature grid, Hero→Features→CTA invariant | Asymmetric composition with intentional tension. Start from content, not template. | | Radius | `rounded-2xl` on everything uniformly | Tiered scale: inputs 4px, buttons 6-8px, cards 10-12px, `rounded-full` reserved for avatars/pills | | Shadow | `shadow-lg` on every card | Tiered elevation. Multi-layer shadows with doubling formula. Match shadow hue to background hue (never pure black). | @@ -126,6 +131,45 @@ For the complete craft reference with additional rules and CSS patterns, **load --- +## Phase 3b — Author the signature visual + +Pick the treatment by signal, scene by default (`../vary/references/concepts.md` §3): a business, place or service page with no photos, no product UI and no style directive gets a drawn `scene`; typography, data-visual, utility, photography and product-ui need a directive, supplied assets or a genre that does not illustrate. + +Route by the concept's `HERO TREATMENT` (from the contract, or what the existing system's pages already do in extend mode): + +- `scene`: the scene bar below, art crew and two critique rounds. +- `pattern`: art brief + gallery, one critique round, no crew; S1 coverage and full-bleed apply, the people and object clauses do not. +- `photography`: write `.vary/visual-brief.md` listing which dossier asset goes where, with crop rule, sequence and grade. Photos ship unaltered unless the world or a directive says otherwise; identifiable real people are never redrawn. The photos carry the page: no frames or ornament around them, no drawn stand-ins for products, and described-but-missing photos become full-size tonal plates (`../vary/references/build-craft.md` §6 item 19). +- `product-ui`: capture list (which workflow, which real data, which states), DPR-2 captures, callouts allowed. +- `typography`: a type specimen at hero size in the content language's glyphs, plus the fact the lettering carries. +- `data-visual`: the chart spec, its dossier data and a text equivalent. +- `utility`: no art brief; skip to building the tool or document as the first screen. +- Native platform (ios/android): hand the platform layer to the `mobile-design` skill; evidence is device screenshots. + +Identity assets (headshots, sponsor or partner logos, client photos of people, product screenshots) ship unaltered and are exempt from "draw each item"; data sections (schedule, pricing table, comparison, code) are exempt from plates. + +### Scene bar (treatment = scene only) + +Read `../vary/references/graphics.md` (§0b the benchmark, §1b "Composing a scene" the method) and run its §7 art-brief → gallery → screenshot critique loop (two rounds, then build: hero first, plates second; a shipped page with good art beats a perfect gallery and no page) before building pages. No UI page code until round 2 passes. The art must meet the scene bar: + +- (scene) **Hero scene (S1).** For persuade and experience: **edge to edge, no frame (G1).** The hero scene spans the full viewport width at desktop and phone (at least 95%, touching both edges): no HTML box, card, polaroid, frame or page margin around it (a border drawn inside the art, such as a blueprint sheet's, is part of the art). Judges: "full-bleed scene, not a framed picture". For persuade and experience surfaces the first viewport is a full-bleed illustrated scene or sheet: the drawing covers at least 55% of the first viewport at 1440x900 and 45% at 390x844, composed of at least 5 distinct subject objects from the dossier across at least 3 depth layers (environment, main subject, foreground detail or people). The brand or headline is set INTO the scene (sign, label, title block, painted lettering), not floated beside a boxed picture, and at least 2 real dossier facts are drawn inside the art (phone on the sign, founding year in a title block, hours on a door card). +- (scene) **Richness floor (S2).** Filled shapes, one consistent light source (lit and shaded faces, cast shadows), texture (grain, hatching, halftone, paper), people or traces of use where the business has them, 4-7 inks. A single icon, bare grid, lone diagram in a framed box or decorative blob fails the hero. +- (scene) **Art all the way down (S3).** Every section below the fold carries its own drawn plate at real size (at least 40% of the section's width (never under a third — the gate fails plates under 35%) and 280px tall at desktop, never thumbnail-only, and fully rendered with the same finish pass as the hero: gradient, glow, grain, at least 3 value steps; never a thin, small, pale line diagram in a tinted box), drawn from that section's facts; a catalogue, menu or services list draws EACH item (the art is a function of the item record). A section that is only cards, text or a form fails unless it is the contact block. Plus micro-scale icons, bullets and dividers in the same hand. +- (scene) **Finish (S4, G2).** `graphics.md` §1d and its Depth recipe (flat fills with thin uniform strokes read as clip art): a value plan (one key light, one glow, brightest value on the focal fact), at least 3 value steps per material, grain at 3-8%, no empty region over 20% of the hero. +- (scene) **Type in the art (S5, G3).** No frosted, translucent or SOLID card, chip row or SaaS button pair over the hero art: an opaque headline container covering more than 6% of the art area fails (judges: "a headline card floats over the art, covering it"). The headline is a real `

` and the primary action a real link or button set over a drawn surface (the scene's open sky, wall or sheet by default; a real sign, title block or board at real scale; §1b has the pattern); phone and hours may be painted in the svg. +- (scene) **Objects and body composition (S7).** Every drawn object has a real silhouette (a train has bogies, windows and a roof line; a book has spine, boards and headband; never a box standing for a thing), at least 3 value steps, and people have a posture or gesture and a cast shadow (`graphics.md` §1e). No two consecutive sections share a layout skeleton: the art brief assigns one of six per section (full-bleed plate with caption; catalogue of drawn items; process strip of 3-5 drawn steps; map or plan; big quote over art; ticket, label or form drawn as an object), and every section plate is a drawing of that section's subject, never a labelled box with words in it. Each fact (price list, hours, address) appears once in the page body; the footer may repeat contact details only. +- **Headline on the fold (all treatments).** Persuade and experience: the first viewport always carries a real HTML `

` (business name or the promise), a one-line value proposition and the primary action (link or button with visible text); never only svg ``, never omitted. Operate and read: a visible `

` or page title. Scene treatment only: the h1 sits ON a drawn surface of the scene, absolutely positioned over the open area the art leaves for it (the scene's sky, wall or sheet by default, as large display type; a sign or board only when it is the real signage at real scale) and styled as that surface's lettering (`../vary/references/graphics.md` §1b has the pattern). +- **Mood fit (all treatments).** The art brief states the audience's mood need (children: bright, safe, daytime; bereavement: calm, quiet, dignified; B2B lab: precise, trustworthy); a concept or world that breaks it fails the critic and is re-rolled with `--lock concept=` before building further. +- **Blocking craft fails (all treatments).** Before shipping, the fold-check rows collisions, clipped-text, hidden-content, stray-overlay, duplicate-content, page-overflow (nothing wider than the viewport at 1440 or 390 unless inside its own scroll container), meta-copy (no designer or scaffold language in visible text; exempt with data-meta-ok), and headline-on-fold (operate/read: title-on-fold) must be PASS, plus headline-in-scene under the scene treatment only; the time budget never excuses them. Intentional repeats are marked `data-repeat-ok` and audited. Coverage, depth and plate-size may ship as logged open items. +- (scene) **Body plates.** At least half the sections below the fold are full-width drawn plates (full-bleed plate with caption, catalogue of drawn items, process strip, map or plan, quote over art); a card with a small drawing counts as the card skeleton, at most twice per page. +- **No collisions (S6, all treatments).** Every label, callout and sign text is fully visible at both viewports: no overlap with drawn objects or other labels. +- **Phone width, client page only, native and honest (all treatments).** `../vary/references/build-craft.md` §6 items 17-20: nothing scrolls sideways or clips at 390px; no designer notes, reference codes or design keys on the page; photos carry a photography page; app screens use native controls and dashboards plot only real data. +- (scene) **Art crew (after the art brief).** Read `../vary/references/art-crew.md`. Dispatch illustrator subagents IN PARALLEL with the Agent tool: one for the hero scene and one per section plate (cap 6), each handed ONLY the art-brief path, its plate's row (subject, facts, skeleton, size), the style object and core kit paths, and `art-crew.md`. Each builds its plate module, renders it at real size, critiques its own screenshot against the richness bar (objects not boxes, posture, shared ground and light, 3+ values, texture, facts in the art), fixes, and returns the module path and final PNG path. The lead integrates and does not redraw; without the Agent tool the lead draws the plates itself in the same order. + +Each critique round compares the gallery screenshot against S1-S3 and the §0b benchmark descriptions and lists what is missing (objects, layers, light, texture, facts-in-art, coverage). **Gate (scene):** the signature device renders at hero, divider and micro scale, the hero scene is composed whole, and every critique item from round 2 is closed. **Gate (other treatments):** the signature element is visible at hero scale (utility: the primary object of the tool) and the router item for the treatment is complete. + +--- + ## Phase 4 — Polish Polish is the last step, not the first. Do not polish work that is not functionally complete. An expert polishing a design does not walk through a labeled checklist — they scan with a trained eye for specific failure modes that an untrained eye misses. Transfer that trained-eye by interrogating the design with these questions, not by marching through a sequence. @@ -148,6 +192,10 @@ Because keyboard-only navigation is invisible until it's catastrophic. Walk thro Because the mockup runs in perfect conditions; production does not. Turn on `prefers-reduced-motion: reduce` — nothing decorative should animate, but functional state transitions (focus rings, disclosure toggles) still need to work. Zoom to 200% — text must remain readable and no horizontal scrolling. Check that every `` has explicit `width` and `height` attributes (not CSS — HTML attributes) so CLS stays under 0.1 (62% of mobile pages fail this per the 2025 Web Almanac). Check that every color comes from a token (not a hard-coded hex), every spacing value from the 8pt scale (not an ad hoc number), every shadow from the elevation system (not a one-off `box-shadow`) — drift here is invisible in a single screen but compounds into design system decay. Check that `font-display: swap` + `size-adjust` are on every `@font-face` so font loading does not cause layout shift. If any of these reveal issues, they are not cosmetic — they are bugs that will ship. +**Craft-bug rules (Polish checklist).** Content is visible with no JS-triggered reveal (an element may animate in only from a visible state); no full-viewport overlay or backdrop left on the page; nothing in the hero is cropped by the nav or the viewport edge; every dial, axis or scale is monotonic and labelled with real values; every callout leader ends on its object. + +**Critic before shipping (Phase 4b).** Scene treatment only; for any other treatment, skip the art-crew critic and instead grade the signature element for that treatment (the capture, lettering idea, chart, or pattern) 1-10 against the dossier facts and the user DIRECTIVES, which outrank every anchor below (a directive such as "no illustrations" or "plain black and white" is never answered with added art). Run the fold check as `fold-check.mjs --mode --treatment `. Under the scene treatment: spawn a FRESH subagent (Agent tool) with the desktop and phone fold and full-page screenshots, the dossier facts, and the critic prompt and rubric from `../vary/references/art-crew.md` section 5 (specificity, graphics, craft, wow, each 1-10; `graphics.md` §0b anchors at 9-10, "pill figures / boxes for objects / template sections / clipped text / dead space" at 4-5). It returns scores and the 5 highest-impact fixes; apply them and re-run a new critic; stop at mean 7.5 or more or after 2 critic rounds, logging open items in NOTES.md. After applying fixes, re-run `check.mjs` and the fold check and refresh the evaluator JSON before Phase 5; record the last critic mean in NOTES.md. Critic rounds sit outside the art budget and may be skipped when time is short. + **Gate:** You cannot answer "I'm not sure" to any of the four questions above and consider the design polished. If you can't answer, you have not actually run the check. For the complete anti-pattern catalog with additional classical/accessibility/performance failure modes, **load [references/anti-patterns.md](references/anti-patterns.md) before shipping a high-stakes design** (landing page, flagship feature, public marketing, customer-facing flow). **Do NOT load** for internal tools or prototypes where the four questions above are sufficient. @@ -164,16 +212,17 @@ For the complete anti-pattern catalog with additional classical/accessibility/pe 1. **Collect the design output.** If it is a file you created, note the absolute file path. If it is a rendered component in a conversation, save it to a temp file first so you can pass a path rather than a massive string. 2. **Read [`evaluator-prompt.md`](evaluator-prompt.md)** — this is the complete prompt template for the subagent. Do not paraphrase it; you will copy it verbatim. -3. **Spawn a subagent** using the Agent tool with `subagent_type: "general-purpose"`, `model: "sonnet"`, and `mode: "bypassPermissions"` (so the subagent can read the rubric file without permission prompts). The task prompt is: the full content of `evaluator-prompt.md`, followed by a clear delimiter (`\n\n---\n\nDESIGN TO EVALUATE:\n\n`), followed by the absolute file path(s) of the design output with an explicit instruction that the subagent should Read each file in full. For short designs (<200 lines), you may inline the source instead of passing a path. -4. **The evaluator returns a structured report** with: per-dimension scores (Usability, Composition, Color+Type, Interaction, Accessibility, Microcopy), a weighted composite, specific violations with severity, and prioritized fixes. +3. **Spawn a subagent** using the Agent tool with `subagent_type: "general-purpose"`, `model: "sonnet"`, and `mode: "bypassPermissions"` (so the subagent can read the rubric file without permission prompts). The task prompt is: the full content of `evaluator-prompt.md`, followed by a clear delimiter (`\n\n---\n\nDESIGN TO EVALUATE:\n\n`), followed by the absolute file path(s) of the design output with an explicit instruction that the subagent should Read each file in full. Also pass `.vary/dossier.json` and the page screenshots, plus `.vary/art-brief.md`, `.vary/critique-r2.json` and the gallery only when they exist (they exist for the scene treatment only), and the fold-check output produced with `--treatment `. For short designs (<200 lines), you may inline the source instead of passing a path. +4. **The evaluator returns a structured report** with: per-dimension scores (Usability, Composition, Color+Type, Interaction, Accessibility, Microcopy, Project specificity), a weighted composite, specific violations with severity, and prioritized fixes. 5. **Iterate based on score:** - Composite ≥ 4.0 (B grade or better) → SHIP. - Composite 3.0-3.9 (C) → apply the top 3 fixes and re-spawn a NEW evaluator (fresh context, no memory of prior iteration). - Composite < 3.0 (D/F) → return to Phase 2 or Phase 3 depending on which dimensions scored lowest. Usability/Interaction failures point back to Phase 2. Composition/Color/Typography failures point back to Phase 3. + - Project specificity dimension score ≤2 (whatever the composite) → return to Phase 1 when a dossier and roll exist and the treatment is not utility; under utility, or when Phase 1 was skipped (component, critique, polish, audit), it goes to Phase 3 fix items instead. A broken directive goes to Phase 3 (rebuild the visual), never to Phase 1. **Iterate until the score converges or ≥ 4.0.** Anthropic's harness research shows creative leaps happen in iteration 7-9, not iteration 1. The evaluator loop is where quality compounds — single-pass design peaks at "competent," iterated design peaks at "memorable." -**If the evaluator subagent fails** — the Agent tool is unavailable, the subagent cannot read the rubric file, it returns no structured report, or it returns a malformed/truncated response — do NOT proceed silently. Fall back to a degraded self-evaluation against the six dimensions in `evaluator-prompt.md` (Usability 25%, Composition 20%, Color+Type 20%, Interaction 20%, Accessibility 10%, Microcopy 5%), applying the bias mitigations from that file explicitly (evidence-first, sequential dimensions, negative-first framing, "cannot determine" for items not assessable). Report the result to the user AS a self-evaluation — not as a subagent evaluation — and note that the quality signal is lower fidelity because the generator/evaluator separation is broken. Ask the user whether to ship the self-evaluated result or retry the subagent. +**If the evaluator subagent fails** — the Agent tool is unavailable, the subagent cannot read the rubric file, it returns no structured report, or it returns a malformed/truncated response — do NOT proceed silently. Fall back to a degraded self-evaluation against the seven dimensions in `evaluator-prompt.md` (Usability 20%, Composition 15%, Color+Type 15%, Interaction 15%, Accessibility 10%, Microcopy 5%, Project specificity 20%), applying the bias mitigations from that file explicitly (evidence-first, sequential dimensions, negative-first framing, "cannot determine" for items not assessable). Report the result to the user AS a self-evaluation — not as a subagent evaluation — and note that the quality signal is lower fidelity because the generator/evaluator separation is broken. Ask the user whether to ship the self-evaluated result or retry the subagent. --- @@ -182,7 +231,7 @@ For the complete anti-pattern catalog with additional classical/accessibility/pe Explicit, non-negotiable. These are not guidelines — these are the landmines that signal AI-generated work the moment a trained eye sees them. - **Never use `bg-indigo-500`, `text-indigo-600`, or `from-indigo-500 to-purple-600`** unless the brand legitimately requires indigo. This is the signature of AI output. -- **Never default to Inter, Roboto, Arial, system-ui, or Space Grotesk.** Even when the user says "clean sans-serif," pick something with character. +- **Never default to Inter, Roboto, Arial, system-ui, or Space Grotesk.** Even when the user says "clean sans-serif," pick something with character, unless a directive, the brand system, an operate surface or a native platform (SF Pro/Roboto) calls for it. - **Never apply `rounded-2xl` uniformly** to buttons, cards, inputs, and containers. Tier the radius scale. - **Never apply `shadow-lg` uniformly** as the universal elevation signal. When every element has the same shadow, the metaphor of elevation breaks — nothing is actually elevated relative to anything else, and the spatial information the shadow was supposed to carry disappears. Tier the shadow system with multi-layer shadows. - **Never use glassmorphism (`backdrop-filter: blur()`) on more than one or two layers per page.** It destroys contrast and tanks performance. @@ -196,7 +245,7 @@ Explicit, non-negotiable. These are not guidelines — these are the landmines t - **Never use placeholder text as the only label.** It disappears on focus and fails WCAG. - **Never ship a form with submit-only validation.** Validate on blur, show success checkmarks on correct fields. - **Never present only the happy path.** Empty state, loading state, error state, disabled state, long-content state — all must exist. -- **Never use pure black (`#000000`) or pure white (`#ffffff`).** Use tinted neutrals — pure black is harsh, pure white is cold, and both signal absence of color decisions. +- **Never use pure black (`#000000`) or pure white (`#ffffff`).** Use tinted neutrals — pure black is harsh, pure white is cold, and both signal absence of color decisions, unless a directive or the brand specifies it. - **Never let your evaluator be yourself.** Spawn a separate subagent. Self-praise is the default. --- @@ -211,9 +260,9 @@ There is no fixed "non-default" list that fixes this — any fixed list creates **Before picking a font, color, or layout direction, ask yourself:** 1. **What would the typical AI-generated version of this look like?** Name it specifically — the font, the primary color, the layout structure, the easing curve, the copy tone. Write it down in your head. -2. **What is materially different from that version that would still serve the brief?** Not "slightly modified" — materially different. Different typographic classification (serif when sans is expected), different compositional logic (asymmetric when centered is expected), different density (dense when airy is expected), different aesthetic lineage (Swiss international / editorial / brutalist / terminal / zine / RPG / art deco / cyberpunk / organic natural / maximalist chaos — whichever is farthest from the default AND serves the context). +2. **What is materially different from that version that would still serve the brief?** Not "slightly modified" — materially different. Different typographic classification (serif when sans is expected), different compositional logic (asymmetric when centered is expected), different density (dense when airy is expected), different aesthetic lineage drawn from the subject's own artefacts (its documents, hardware, signage, printed matter, place), whichever is farthest from the default AND true to this project. 3. **Commit to that choice and execute it with precision.** Half-committing to a distinctive direction produces worse results than fully committing to a generic one. A perfectly executed Inter landing page beats a half-executed Fraunces one. If you are going to pick a distinctive font, use all its weights and optical sizes. If you are going to pick a distinctive color, let it dominate. -**Vary the AXIS of distinctiveness, not just the values on one axis.** If the last design you produced was distinctive through typography, make this one distinctive through composition or color instead. If the last was distinctive through motion, make this one distinctive through density or atmosphere. The goal is not a rotation through a fixed "distinctive" list — it is picking a different dimension to express character each time. +**Vary the AXIS of distinctiveness, not just the values on one axis.** If the last design you produced was distinctive through typography, make this one distinctive through composition or color instead. If the last was distinctive through motion, make this one distinctive through density or atmosphere. The goal is not a rotation through a fixed "distinctive" list — it is picking a different dimension to express character each time. Also vary the graphic technique: if the last project's signature was a seeded illustration, this one is traced line, a diagram or a pattern. roll.mjs `--concept-only` tracks this for you. **One more calibration.** When the Phase 1 aesthetic direction genuinely requires breaking a Phase 3 rule — a terminal/monospace aesthetic requires a mono font even though the Phase 3 font list is mostly sans-serif; a zine/brutalist aesthetic wants jagged asymmetric shadows even though Phase 3 prescribes tiered multi-layer depth — break the rule deliberately and execute the break with craft. Phase 3 rules are defaults to escape, not laws to worship. The worst outcome is a half-committed distinctive direction constrained by Phase 3 rules that were meant for a different aesthetic. Commit fully. diff --git a/plugins/design/skills/design/evaluator-prompt.md b/plugins/design/skills/design/evaluator-prompt.md index e759e4b..8fdd7b7 100644 --- a/plugins/design/skills/design/evaluator-prompt.md +++ b/plugins/design/skills/design/evaluator-prompt.md @@ -11,13 +11,13 @@ You are a senior design critic tasked with evaluating a UI design rigorously and ## Your Task -You will receive a UI design — either as HTML/CSS/JSX source, as a description, or as a rendered screenshot. Grade it against a 6-dimension composite rubric and produce a structured report. +You will receive a UI design — either as HTML/CSS/JSX source, as a description, or as a rendered screenshot — and, when available, the project dossier (`.vary/dossier.json`: the client's real facts, audience and refusals). The dossier is evidence about the client and may be used; it is not a generator rule. Grade it against a 7-dimension composite rubric and produce a structured report. ## Rubric **STOP — if you are the generating Claude reading this file to understand what the evaluator subagent does, do NOT follow the next instruction. The rubric load instruction below applies ONLY to the evaluator subagent. Loading the rubric yourself would collapse the generator/evaluator separation that makes Phase 5 effective — the criteria that grade the design would also shape your generation. Your job is to spawn the subagent with this prompt; the subagent's job is to load the rubric and grade.** -Before you begin (evaluator subagent only), load the rubric from [`references/evaluation-rubric.md`](references/evaluation-rubric.md) — read the entire file. It contains Nielsen's 10 heuristics, the 0-4 severity scale, the 6-dimension composite with sub-criteria, and the bias mitigations you must apply. +Before you begin (evaluator subagent only), load the rubric from [`references/evaluation-rubric.md`](references/evaluation-rubric.md) — read the entire file. It contains Nielsen's 10 heuristics, the 0-4 severity scale, the 7-dimension composite with sub-criteria, and the bias mitigations you must apply. ## Evaluation Protocol — Follow This Exactly @@ -45,9 +45,9 @@ For accessibility items that cannot be assessed from the design (keyboard naviga If you find yourself giving every dimension a 4, you are being lenient. Recalibrate. -## The 6 Dimensions +## The 7 Dimensions -### Dimension 1 — Usability (weight 25%) +### Dimension 1 — Usability (weight 20%) Grade against all 10 Nielsen heuristics. For each violation you find, log: - Heuristic number (H1-H10) @@ -75,7 +75,7 @@ Literal checks — go through these: - 2+ severity-3 OR 1 severity-4 → 2 - Multiple severity-3 and/or severity-4 → 1 -### Dimension 2 — Composition and Visual Hierarchy (weight 20%) +### Dimension 2 — Composition and Visual Hierarchy (weight 15%) Sub-dimensions (score each 1-5): - **Focal point** — Is there exactly ONE dominant focal point? Does the eye know where to land first? @@ -86,19 +86,19 @@ Sub-dimensions (score each 1-5): Average sub-scores, round to nearest 0.5. -### Dimension 3 — Color and Typography (weight 20%) +### Dimension 3 — Color and Typography (weight 15%) Color checks: - Palette limited to max 5 hues? - OKLCH or systematic palette (not ad hoc hex codes)? - Semantic color assignments consistent (red = danger, not decoration)? - NO indigo/purple as primary unless brand-justified? -- NO pure black, NO pure white? +- NO pure black, NO pure white (unless a dossier directive, the brand system or the platform specifies it)? - NO gray text on colored backgrounds? - WCAG AA contrast (4.5:1 body, 3:1 large/UI)? Typography checks: -- NOT Inter/Roboto/Arial/system-ui/Space Grotesk (unless aesthetically intentional)? +- NOT Inter/Roboto/Arial/system-ui/Space Grotesk (unless aesthetically intentional, or a dossier directive, the brand system or the platform specifies it)? - Fonts with ≥5 weights? - Body line-height 1.4-1.6? - Display line-height 1.0-1.2? @@ -109,7 +109,7 @@ Typography checks: Score each sub-section 1-5, average them. -### Dimension 4 — Interaction and Flow (weight 20%) +### Dimension 4 — Interaction and Flow (weight 15%) - **Affordance clarity** — Do interactive elements look interactive? Buttons look pressable? - **State coverage** — Which of these exist: default, hover, focus-visible, active, disabled, loading, error, success, empty? Count them. @@ -162,15 +162,37 @@ Check each (1 point each, max 7): Scale to 1-5: 7/7=5, 5-6=4, 3-4=3, 1-2=2, 0=1. +### Dimension 7 — Project Specificity and Signature Element (weight 20%) + +Could this design only belong to this project? Observations first. + +- **Signature element**: read the contract's `HERO TREATMENT` and `DIRECTIVES` lines when present and name the element per treatment, citing source file and dossier fact ids: `scene`/`pattern` = the drawn or generated device at hero scale and one other scale; `photography` = the named dossier assets plus the art-direction rule (crop, sequence, grade); `product-ui` = the real capture and the workflow it shows; `typography` = the lettering idea and the fact it carries; `data-visual` = the chart and the data it encodes; `utility` = judged at detail scale (numerals, empty states, icons, copy), where a FAIL is a fix item, never a rebuild. None, or only abstract decoration (gradient blob, mesh, noise hero, stock icon set) → say so. +- **Subject swap**: name a same-category business in another town. List every graphic, headline and control that would become false for it. Fewer than 3 items means the design is generic. +- **Data**: does at least one visual encode real dossier data (menu toppings, sizes, hours, address)? Cite element and fact. Also PASS when the hero visual is the client's own dossier asset. +- **Controls in the design's material** (persuade/experience; operate surfaces keep platform conventions and are judged on tokens): nav, buttons, chips, cart, confirmation. A default underline nav or flat pill button reads as vibe-coded. +- **Trade credibility**: would the dossier's paying audience find this look credible for the trade, and do reading surfaces stay legible? A FAIL names the specific element, not "feels off". +- **Art craft (screenshots only)**: no faces drawn on objects (food, products), mascots or emoji-blobs; real photographed faces are fine; subjects distinguishable at thumbnail size; style consistent. Photography and product-ui add fidelity (no filter, blend or overlay on client images), natural resolution at least 1.5x rendered, and crop (focal subject not cut by the nav). No screenshots → "Cannot determine from source alone". +- **Richness against the benchmark** (When HERO TREATMENT = scene (the default for persuade/experience without a stated treatment)): compare the first viewport to the benchmark descriptions (ara-bar: restaurant at night, 10+ drawn objects, 3+ depth layers, a light source, brand on the painted sign, real phone number in the art; hitataekni: full blueprint sheet with grid, coordinates, traced unit, dimension lines, callouts, title block with the real name, year, address, phone). Check: hero is a scene or sheet covering >=55% of the 1440x900 viewport (>=45% at 390x844), >=5 distinct subject objects over >=3 depth layers, headline set into the art, >=2 dossier facts inside the art; richness floor (filled shapes, one light source, texture, people or traces of use, 4 to 7 inks); page budget of >=3 more drawn plates tied to different fact groups. Run `fold-check.mjs --mode --treatment ` from the vary skill directory (the sibling of this skill; never search the filesystem for it) when a built page exists and cite the metric lines. Under the scene treatment a text-only or boxed-diagram hero is a FAIL for persuade and caps Specificity at 2. List what is missing (objects, layers, light, texture, facts-in-art). +- **Named fold checks** — When HERO TREATMENT = scene (the default for persuade/experience without a stated treatment): the evaluator must report each PASS/FAIL with the `fold-check.mjs` line quoted (a missing line is a RECAPTURE for a persuade/experience build with a built page): (1) **Text collisions** (`collisions-desktop`, `collisions-phone`): no visible text box, HTML or svg ``, overlaps another or is more than 30% covered by an opaque later-painted element; name the two texts on a FAIL. (2) **Dead zone** (`dead-zone`): no near-uniform-colour rectangle larger than 20% of the desktop fold; cite its size and position. (3) **Card over art** (`card-over-art-desktop`, `card-over-art-phone`): no translucent (alpha < 0.95), backdrop-filter or solid-background element with text covering more than 6% of the hero art area; headline, phone, hours and the primary action live on drawn surfaces inside the art (a sign, plate or title block in the svg is fine), never on a card floating over it. (4) **Art down the page** (`art-down-page`): at least 60% of the sections below the fold (contact/footer excluded) carry a drawn plate at least 40% of their width. (5) **Full bleed** (`full-bleed-desktop`, `full-bleed-phone`): the hero art box spans at least 95% of the viewport width with margins of at most 3% each side; a framed picture, card or polaroid with page margins fails; name the art box and its margins. (6) **Rendering depth** (`rendering-depth`): in the desktop hero art area the luminance range p95 - p5 is at least 90 and at least 5 of 16 luminance bins hold at least 2% of the pixels; flat fills with thin uniform strokes read as clip art. The flat-block texture number is printed for the report but not gated. (7) **Plate size** (`plates-size`): at least 50% of the below-fold section plates are at least 35% of the section width, at least 280px tall and carry at least 3 value steps; name the thin, small or pale ones. In persuade/experience any of these seven FAILing is a Specificity FAIL under P6 and is listed in the fix batch; in operate/read only the collisions check gates and the others are informational. For any other treatment, quote only the fold rows `fold-check.mjs` marks as gated for that treatment (rows printed `[INFO]` are not gated); a missing line is a RECAPTURE only if its row is gated. Native platforms (ios/android): device screenshots, fold rows N/A. +- **Directive compliance**: list each `DIRECTIVES` line (the user's own words, e.g. "no illustrations", "black and white", "use our photos") and mark it kept or broken with evidence. A broken directive caps this dimension at 2 and is a critical issue. Directives outrank this skill's bars; a build that obeys a directive is not marked down for the bar it suspends. + +Score: +- 1: no project-specific visual (no drawn, photographed, captured, typographic or data element from this project); survives a competitor swap whole +- 2: decorative effects only; one or two real facts in copy +- 3: one project-specific element (graphic, capture, lettering or chart), but generic or appearing once +- 4: signature element per treatment (drawn device, cited client assets with a direction rule, the real capture and workflow, a lettering idea tied to a fact, or the chart and its data) from cited facts at 2+ scales, controls in the design's material, 3+ false items in the subject swap +- 5: as 4, plus the visual carries real data, the first viewport announces the concept, clean at thumbnail size + ## Composite Score Calculation ``` -Composite = (Usability × 0.25) - + (Composition × 0.20) - + (Color+Type × 0.20) - + (Interaction × 0.20) +Composite = (Usability × 0.20) + + (Composition × 0.15) + + (Color+Type × 0.15) + + (Interaction × 0.15) + (Accessibility × 0.10) + (Microcopy × 0.05) + + (Specificity × 0.20) ``` Grade: @@ -192,13 +214,13 @@ Grade: ## Dimension Scores -### Usability (H1-H10) — X/5 (25% weight) +### Usability (H1-H10) — X/5 (20% weight) Violations found: - H[N] at [location]: [description] — severity [0-4] - ... Rationale: [one sentence] -### Composition and Visual Hierarchy — X/5 (20% weight) +### Composition and Visual Hierarchy — X/5 (15% weight) - Focal point: X/5 — [observation] - Reading order: X/5 — [observation] - Balance: X/5 — [observation] @@ -206,12 +228,12 @@ Rationale: [one sentence] - Consistency: X/5 — [observation] Rationale: [one sentence] -### Color and Typography — X/5 (20% weight) +### Color and Typography — X/5 (15% weight) Color observations: [list] Typography observations: [list] Rationale: [one sentence] -### Interaction and Flow — X/5 (20% weight) +### Interaction and Flow — X/5 (15% weight) States present: [list — default, hover, focus, active, disabled, loading, error, success, empty] States missing: [list] Affordance clarity: [observation] @@ -223,12 +245,23 @@ Probable passes: [list] Cannot determine without manual verification: [list] Rationale: [one sentence] +### Project Specificity and Signature Element — X/5 (20% weight) +Hero treatment: [scene | pattern | photography | product-ui | typography | data-visual | utility, from the contract or "not stated"] +Directives: [each DIRECTIVES line kept/broken, or "none"] +Signature element: [name, source file, fact ids — or "none"] +Richness vs benchmark: [scene only: hero scene yes/no, coverage, objects, layers, light, texture, facts-in-art, plates; fold-check lines] +Subject-swap false items: [list; count] +Graphic carries real data: [element and fact, or no] +Controls in the design's material: [observation] +Trade credibility: [observation] +Rationale: [one sentence] + ### Microcopy — X/5 (5% weight) AI vocabulary detected: [yes with examples / no] CTA quality: [observation] Rationale: [one sentence] -## Critical Issues (severity 3-4 violations or any dimension ≤ 2) +## Critical Issues (severity 3-4 violations or any dimension ≤ 2; Specificity ≤ 2 returns the work to Phase 1, except under utility or when Phase 1 was skipped, and a broken directive: those go to Phase 3 fix items) 1. [issue with location and specific fix] 2. ... @@ -245,7 +278,7 @@ Rationale: [one sentence] ## Iteration Recommendation - If score ≥ 4.0: SHIP - If score 3.0-3.9: Apply top 3 fixes and re-evaluate -- If score < 3.0: Return to Phase 2 (UX Architecture) or Phase 3 (Execute) of the design-excellence skill, specifically fixing [which dimensions scored lowest] +- If score < 3.0: Return to Phase 2 (UX Architecture) or Phase 3 (Execute) of the design-excellence skill, specifically fixing [which dimensions scored lowest]. If Specificity ≤ 2: return to Phase 1 (dossier and concept) regardless of composite, except under utility, when Phase 1 was skipped, or for a broken directive, which go to Phase 3 fix items ``` ## Final Reminder diff --git a/plugins/design/skills/design/references/evaluation-rubric.md b/plugins/design/skills/design/references/evaluation-rubric.md index 471bbd7..bcc034f 100644 --- a/plugins/design/skills/design/references/evaluation-rubric.md +++ b/plugins/design/skills/design/references/evaluation-rubric.md @@ -1,6 +1,6 @@ --- name: evaluation-rubric -description: The complete scoring rubric for the design evaluator subagent. 6 weighted dimensions with explicit scoring tables, Nielsen severity scale, bias mitigations, and required output format. Loaded by the evaluator subagent, NOT by the generating Claude. +description: The complete scoring rubric for the design evaluator subagent. 7 weighted dimensions with explicit scoring tables, Nielsen severity scale, bias mitigations, and required output format. Loaded by the evaluator subagent, NOT by the generating Claude. --- # Evaluation Rubric — For the Evaluator Subagent @@ -94,7 +94,7 @@ Severity is a function of three factors: --- -## The 6-Dimension Composite Rubric +## The 7-Dimension Composite Rubric ### Scoring Scale (all dimensions) @@ -110,7 +110,7 @@ Half-scores permitted (1.5, 2.5, 3.5, 4.5). Every score must be accompanied by a --- -### Dimension 1: Usability — Weight 25% +### Dimension 1: Usability — Weight 20% Grade via Nielsen's 10 heuristics with severity logging. @@ -134,7 +134,7 @@ Usability score: X/5 — [rationale] --- -### Dimension 2: Composition and Visual Hierarchy — Weight 20% +### Dimension 2: Composition and Visual Hierarchy — Weight 15% **Sub-dimensions (score each 1-5):** @@ -150,19 +150,19 @@ Average sub-scores, round to nearest 0.5. --- -### Dimension 3: Color and Typography — Weight 20% +### Dimension 3: Color and Typography — Weight 15% **Color sub-criteria (each = 1 point):** - [ ] Palette limited to ≤5 hues - [ ] OKLCH or systematic palette (not ad hoc) - [ ] Color reinforces semantic meaning consistently - [ ] **NOT indigo/purple as primary unless brand-justified** -- [ ] **NO pure black, NO pure white** +- [ ] **NO pure black, NO pure white** (unless a dossier directive, the brand system or the platform specifies it) - [ ] **NO gray text on colored backgrounds** - [ ] WCAG AA contrast (4.5:1 body, 3:1 large/UI) **Typography sub-criteria (each = 1 point):** -- [ ] **NOT Inter/Roboto/Arial/system-ui/Space Grotesk as default** +- [ ] **NOT Inter/Roboto/Arial/system-ui/Space Grotesk as default** (unless a dossier directive, the brand system or the platform specifies it) - [ ] Fonts with ≥5 weights - [ ] Body line-height 1.4-1.6 - [ ] Display line-height 1.0-1.2 @@ -180,7 +180,7 @@ Average sub-scores, round to nearest 0.5. --- -### Dimension 4: Interaction and Flow — Weight 20% +### Dimension 4: Interaction and Flow — Weight 15% **States to check (9 possible):** 1. Default @@ -257,15 +257,44 @@ Triage into three buckets: --- +### Dimension 7: Project Specificity and Signature Element — Weight 20% + +Asks whether this design could only belong to this project. Inputs: the build, screenshots, and (if supplied) the project dossier (`dossier.json`), which is evidence about the client and may be used. Observations first, score second. + +**Observe before scoring:** +- **Signature element**: read the contract's `HERO TREATMENT` and `DIRECTIVES` lines when present and name the element per treatment, citing source file and dossier fact ids: `scene`/`pattern` = the drawn or generated device at hero scale and one other scale; `photography` = the named dossier assets plus the art-direction rule (crop, sequence, grade); `product-ui` = the real capture and the workflow it shows; `typography` = the lettering idea and the fact it carries; `data-visual` = the chart and the data it encodes; `utility` = judged at detail scale (numerals, empty states, icons, copy), where a FAIL is a fix item, never a rebuild. None, or only abstract decoration (gradient blob, mesh, noise hero, stock icon set) → say so. +- Subject swap: name a same-category business in another town. List every graphic, headline, label and control that would become false for it. Count them. +- does at least one visual encode real dossier data (menu toppings, sizes, hours, address)? Cite element and fact. Also PASS when the hero visual is the client's own dossier asset. +- Controls in the design's material (persuade/experience; operate surfaces keep platform conventions and are judged on tokens): nav, buttons, chips, cart, confirmation. A default underline nav or flat pill button reads as vibe-coded. +- Would the paying customer in the dossier's audience find the look credible for this trade? Name the specific element if not. +- **Art craft (screenshots only)**: no faces drawn on objects (food, products), mascots or emoji-blobs; real photographed faces are fine; subjects distinguishable at thumbnail size; style consistent. Photography and product-ui add fidelity (no filter, blend or overlay on client images), natural resolution at least 1.5x rendered, and crop (focal subject not cut by the nav). No screenshots → "Cannot determine from source alone". +- Richness against the benchmark (When HERO TREATMENT = scene (the default for persuade/experience without a stated treatment)): ara-bar = the restaurant at night (mountain ridge, stars, clad facade, lit windows, diner silhouettes, a street lamp; 10+ drawn objects, 3+ depth layers, a light source; brand painted on the sign, real phone number in the art). hitataekni = a full blueprint sheet (grid, bordered sheet with coordinates, a traced unit, dimension lines, callout bubbles, title block with the real name, founding year, address, emergency number). Check the hero: a scene or sheet covering >=55% of the 1440x900 first viewport (>=45% at 390x844); >=5 distinct subject objects over >=3 depth layers; headline set into the art; >=2 dossier facts inside the art. Richness floor: filled shapes, one consistent light source, texture, people or traces of use, 4 to 7 inks. Page budget: >=3 more drawn plates tied to different fact groups, plus micro-scale marks. Under the scene treatment a text-only, single-icon, empty-grid or boxed-diagram hero is a FAIL for persuade and caps this dimension at 2. Measure with `vary/scripts/fold-check.mjs --mode --treatment ` when a build exists. +- Named fold checks (When HERO TREATMENT = scene (the default for persuade/experience without a stated treatment); report each as PASS/FAIL with the fold-check line): (1) **Text collisions** (`collisions-desktop`, `collisions-phone`): no visible text box, HTML or svg ``, overlaps another or is more than 30% covered by an opaque later-painted element; name the two texts on a FAIL. (2) **Dead zone** (`dead-zone`): no near-uniform-colour rectangle larger than 20% of the desktop fold; cite its size and position. (3) **Card over art** (`card-over-art-desktop`, `card-over-art-phone`): no translucent (alpha < 0.95), backdrop-filter or solid-background element with text covering more than 6% of the hero art area; headline, phone, hours and the primary action live on drawn surfaces inside the art (a sign, plate or title block in the svg is fine), never on a card floating over it. (4) **Art down the page** (`art-down-page`): at least 60% of the sections below the fold (contact/footer excluded) carry a drawn plate at least 40% of their width. (5) **Full bleed** (`full-bleed-desktop`, `full-bleed-phone`): the hero art box spans at least 95% of the viewport width with margins of at most 3% each side; a framed picture, card or polaroid with page margins fails; name the art box and its margins. (6) **Rendering depth** (`rendering-depth`): in the desktop hero art area the luminance range p95 - p5 is at least 90 and at least 5 of 16 luminance bins hold at least 2% of the pixels; flat fills with thin uniform strokes read as clip art. The flat-block texture number is printed for the report but not gated. (7) **Plate size** (`plates-size`): at least 50% of the below-fold section plates are at least 35% of the section width, at least 280px tall and carry at least 3 value steps; name the thin, small or pale ones. In persuade/experience any of these seven FAILing is a Specificity FAIL under P6 and is listed in the fix batch; in operate/read only the collisions check gates and the others are informational. For any other treatment, quote only the fold rows `fold-check.mjs` marks as gated for that treatment (rows printed `[INFO]` are not gated); a missing line is a RECAPTURE only if its row is gated. Native platforms (ios/android): device screenshots, fold rows N/A. +- **Directive compliance**: list each `DIRECTIVES` line (the user's own words, e.g. "no illustrations", "black and white", "use our photos") and mark it kept or broken with evidence. A broken directive caps this dimension at 2 and is a critical issue. Directives outrank this skill's bars; a build that obeys a directive is not marked down for the bar it suspends. + +**Scoring table:** +| Score | Anchor | +|-------|--------| +| 1 | No project-specific visual (no drawn, photographed, captured, typographic or data element from this project); stock icons or none; copy and visuals survive a competitor swap whole. | +| 2 | Decorative effects only (gradient mesh, noise, blobs); one or two real facts in copy. | +| 3 | One project-specific element (graphic, capture, lettering or chart), but generic (could be any pizzeria or any HVAC firm) or appearing once only. | +| 4 | A signature element per treatment (drawn device, cited client assets with a direction rule, the real capture and workflow, a lettering idea tied to a fact, or the chart and its data) from cited facts, at 2 or more scales, with controls in the design's material; the subject swap lists 3 or more false items. | +| 5 | As 4, plus the visual carries real data, the first viewport announces the concept, and the art is clean at thumbnail size. | + +Score 2 or below is a critical issue and returns the work to Phase 1 (purpose, dossier, concept), except under utility, when Phase 1 was skipped (component, critique, polish, audit), or for a broken directive, which go to Phase 3 fix items. + +--- + ## Composite Score Calculation ``` -Composite = (Usability × 0.25) - + (Composition × 0.20) - + (Color+Type × 0.20) - + (Interaction × 0.20) +Composite = (Usability × 0.20) + + (Composition × 0.15) + + (Color+Type × 0.15) + + (Interaction × 0.15) + (Accessibility × 0.10) + (Microcopy × 0.05) + + (Specificity × 0.20) ``` | Composite | Grade | Meaning | diff --git a/plugins/design/skills/vary/.gitignore b/plugins/design/skills/vary/.gitignore new file mode 100644 index 0000000..5ae567f --- /dev/null +++ b/plugins/design/skills/vary/.gitignore @@ -0,0 +1,2 @@ +fold-desktop.png +fold-phone.png diff --git a/plugins/design/skills/vary/SKILL.md b/plugins/design/skills/vary/SKILL.md index a6294de..09db6bc 100644 --- a/plugins/design/skills/vary/SKILL.md +++ b/plugins/design/skills/vary/SKILL.md @@ -1,6 +1,6 @@ --- name: vary -description: Design frontend UI that looks different every run and still looks designed. An external dice roll (scripts/roll.mjs) ASSIGNS a visual world, page skeleton, colour strategy, OKLCH palette seed and two verified free fonts from catalogs of 47 worlds, 18 archetypes, 120 hue-balanced seeds and 160+ verified faces; the model commits fully, a mechanical craft floor (scripts/check.mjs) and a separate evaluator subagent judge distinctiveness and contract-keeping. Use WHENEVER building, redesigning, mocking up or restyling any UI — landing page, marketing site, dashboard, admin, docs, portfolio, app screen, component set, email, artifact — and whenever the user says design, UI, frontend, make it look good, not generic, not AI slop, something different, new vibe, new palette, new fonts, surprise me, bold, distinctive, or complains that designs look the same. Also use for "give me another direction", "re-roll", "safer", "bolder", "play it straight". Not for backend-only work or pure copy edits. +description: Design frontend UI that is specific to THIS project and different every run: read the project into a fact dossier, write grounded concepts, and let an external roll (scripts/roll.mjs) assign a concept, a signature-graphic technique, a fitting visual world, a skeleton, a palette seed and fonts; then build the concept's hero treatment (drawn scene, the client's photography, product UI, typography, data visual or pattern). The catalogs hold 47 worlds, 18 archetypes, 120 hue-balanced seeds and 160+ verified faces; the model commits fully, a mechanical craft floor (scripts/check.mjs) and a separate evaluator subagent judge distinctiveness and contract-keeping. Use WHENEVER building, redesigning, mocking up or restyling any UI — landing page, marketing site, dashboard, admin, docs, portfolio, app screen, component set, email, artifact — and whenever the user says design, UI, frontend, make it look good, not generic, not AI slop, something different, new vibe, new palette, new fonts, surprise me, bold, distinctive, or complains that designs look the same. Also use for "give me another direction", "re-roll", "safer", "bolder", "play it straight". Not for backend-only work or pure copy edits. user-invocable: true argument-hint: "[brief] [--mode persuade|operate|read|experience] [--reroll] [--safer|--bolder|--canon]" --- @@ -9,10 +9,12 @@ argument-hint: "[brief] [--mode persuade|operate|read|experience] [--reroll] [-- Claude's design output collapses to a mode: indigo, Inter, centred hero, three cards. Told to avoid those, it collapses to the next mode: cream + serif + terracotta, or near-black + one neon, Space Grotesk, bento + glass. The cause is not a lack of ideas. The model derives seven varied directions and then argmax-picks the same one every time; asking it to "choose differently" reverted in 27 of 30 measured runs. Naming things to avoid makes it worse: a named font or hex gets *more* activation, not less. -Two things fix this, and this skill is built around them: +Four things fix this, and this skill is built around them: -1. **An external roll assigns the direction.** `scripts/roll.mjs` picks world → skeleton → colour strategy → palette seed → fonts, each conditioned on the last, from catalogs in `data/`. Claude executes the assignment. It does not pick from a menu. +1. **An external roll assigns the direction.** `scripts/roll.mjs` picks concept → signature device → world → skeleton → colour strategy → palette seed → fonts, each conditioned on the last, from the project's concepts and the catalogs in `data/`. Claude executes the assignment. It does not pick from a menu. 2. **Variety must move on two axes.** A new palette on the same centred-hero skeleton is the same design. The roll moves the skeleton (archetype) and the lineage (world) together; skin follows. +3. **The roll picks among grounded options.** Concepts come from the project's own facts, and worlds are filtered by its sector. Randomness then buys variety without buying irrelevance. +4. **The signature is specific to the subject.** Every build carries one signature element taken from the project's facts or assets: a drawing, the client's own photographs, the real product, a typographic system or a data form. A palette and fonts over generic blocks is not a design. Then: commit fully, guard later. Craft rules are loaded only *after* the contract is written, because guardrails mixed into the brief measurably hedge the result. Review is done by a separate identity auditing the build against its own contract, because self-review rubber-stamps. @@ -20,42 +22,96 @@ Then: commit fully, guard later. Craft rules are loaded only *after* the contrac --- -## Step 0 — Read the surface, not the product +## Step 0 — Read the project, then the surface -Before rolling, settle these in one short block (write them; they feed the roll): +Load [`references/dossier.md`](references/dossier.md) and write `.vary/dossier.json` before anything else. Facts come from the repo, the client's live site and the assets given. Nothing is invented. + +Then settle these in one short block (write them; they feed the roll): - **Mode** of *this surface*: `persuade` (visitor decides and acts: landing, pricing, campaign), `operate` (completes a task: app, dashboard, settings, forms, checkout), `read` (understands: docs, articles, changelog), `experience` (is inside the work: portfolio, gallery). A dev tool's landing page is persuade; a fashion house's docs are read. Mode gates how bold the roll may be. Operate and read surfaces get quiet worlds and conventional skeletons; their distinctiveness lives in details. -- **Platform**: `web`, `ios`, `android`. Phones exclude pointer-dependent skeletons. +- **Platform**: `web`, `ios`, `android`. Phones exclude pointer-dependent skeletons. ios/android: load the `mobile-design` skill for the platform layer. The contract goes as a `/* ... */` block at the top of the root component. Evidence is device screenshots; fold-check does not apply. - **Locks** — anything the user or the repo has already decided: brand hex (`--lock palette=#…`), a required font, required scripts (`--scripts latin,cyrillic`), no webfonts (email, React Native → `--no-webfonts`), longevity (`--longevity campaign` allows fast-decaying looks). A brief that pins an aesthetic ("make it Bauhaus", "keep our brand") beats the roll: `--lock world=bauhaus`. +- **Directives**: anything the user says about the look ("no illustrations", "black and white", "use our photos", "minimal") goes verbatim into `dossier.directives` with the locks it implies. Directives outrank this skill's defaults, bars and gates; only truth and accessibility outrank them. + + | Directive | Locks / effect | + |---|---| + | no illustrations | `treatment=typography`, or photography/product-ui if assets exist | + | black and white | `strategy=ink-on-paper` and no accent (build-craft tint rules suspended) | + | Swiss | `world=swiss-international` | + | use our photos | `treatment=photography` | + | minimal, quiet, luxurious | `treatment=typography`, or `photography` when photos exist (dead-zone and depth are INFO there); devices `photo-direction` or `typographic` only (no annotated photos, leader lines, frames or ornament); never `scene` unless the user also asks for drawings | + | keep our brand | extend mode | + +- **Precedence** (the one place it is written; both evaluators repeat it). When rules collide, the higher item wins: 1. Truth: no invented facts. 2. Accessibility: contrast, keyboard, focus, reduced motion, text equivalents. 3. User directives (`dossier.directives`). 4. Existing brand or design system (extend mode). 5. Platform conventions (ios/android; operate Jakob's law). 6. The rolled contract. 7. Treatment-specific bars and skill defaults. - **Existing design system?** If the repo has `DESIGN.md`, a token file, a Tailwind theme with custom colours, or styled shared components, this is **extend mode**: read [`references/extend-mode.md`](references/extend-mode.md) and follow it instead of Step 1. A component or section inside an established surface always inherits that surface. Never turn a local addition into a new identity. +- **Sector**: one id from the controlled list, taken from the dossier's `sector`. It filters the world pool. Pick the one a customer would file the business under (a pizzeria with a bar is `food-drink`). When torn between two, decide the tie after Step 0b (take the one whose worlds list fits the concept hints better), note the choice in the dossier, and write the final sector into `dossier.sector`, which must equal `--sector`. +- **Multi-surface clients**: the roll's mode is the mode of the surface built first (usually the home or landing page, so `persuade`). Later surfaces reuse the contract and take their own archetype. - **Scene sentence**: who uses this, where, under what light. This decides light or dark, not the category (`--scene light|dark` once decided). -- **Thin category?** If the subject has little cultural material of its own (SRE tooling, invoicing, generic B2B), **read [`references/thin-domain.md`](references/thin-domain.md)** before rolling: it produces a `--grounded` candidates file. -Ask the user at most one round of two or three questions, and only for facts that change the work (audience, the real proof/content available, what must not change). Never ask for CSS values or an aesthetic lane. +Ask the user at most one round of two or three questions, and only for facts that change the work (the real proof/content available, audience, what must not change). Never ask for CSS values or an aesthetic lane. + +## Step 0b — Concepts + +Read [`references/concepts.md`](references/concepts.md) and write 4-7 concepts to `.vary/concepts.json`. Each concept takes a different angle on the business: a different audience moment, half of the trade, time of day or artefact. Each cites at least 3 fact ids, names the trade artefact it borrows, re-skins the functional objects (cart, confirmation, nav), names 2-3 `world_hints` (the one permitted read of `data/worlds.json`: take ids, names, sectors and `avoid_sectors` only, never use it to choose the final world), and offers 2-3 signature devices from the technique list. Each concept names a `treatment` (scene, pattern, photography, product-ui, typography, data-visual, utility). Pick treatments by signal, scene by default (`references/concepts.md` §3): a small business, place or service with no photos, no product UI and no style directive gets `scene` concepts (at most one `pattern`). When the dossier has usable photos or a product UI, at least one concept uses `photography` or `product-ui`, and at least two treatments appear across the file. Write them all at full strength: the roll, not you, picks one. ## Step 1 — Roll ```bash -node /scripts/roll.mjs --mode --platform [--scene light|dark] \ +node /scripts/roll.mjs --mode --platform [--scene light|dark] \ + --dossier .vary/dossier.json --concepts .vary/concepts.json --sector \ [--scripts latin,cyrillic] [--lock palette=#hex] [--lock world=] [--no-webfonts] \ - [--longevity long|campaign] [--project ] [--grounded candidates.json] + [--longevity long|campaign] --project ``` -`` is this skill's directory. `--project` points the anti-repeat memory at the repo (last 3 picks per axis demoted there; last 8 globally), so the same font or world does not come back next session. +`` is this skill's own directory: the absolute path on the "Base directory for this skill" line Claude Code printed when this skill loaded (or `${CLAUDE_PLUGIN_ROOT}/skills/vary` when that variable is set). Substitute it literally in every command. **Never search the filesystem (`find`, `ls ~`) for the scripts, and never run a copy under `~/.claude/plugins` or `~/.claude/skills` unless that path IS the base directory.** If `/scripts/roll.mjs` is not there, stop and say so. `--project` is required. It points the anti-repeat memory at the repo (last 3 picks per axis demoted there, angles included; last 8 globally), so the same font or world does not come back next session. -**The assignment is unrefusable.** Writing any UI markup, styling or component code before the roll has run and its comment block is pasted into the artifact is a contract violation. Do not read the catalog files to pick something yourself: that is the exact failure this skill exists to remove. +**The assignment is unrefusable.** Writing any UI markup, styling or component code before the roll has run and its comment block is pasted into the artifact is a contract violation. Apart from the `world_hints` read in Step 0b, do not read the catalog files to pick something yourself: that is the exact failure this skill exists to remove. -**Re-roll on the model's own initiative only for named factual grounds**: the roll printed a `[FAIL]` coherence row that a single-field lock cannot fix; the assigned font lacks a script the brief needs; the world's `avoid_for` names this exact audience. "This doesn't feel right for the category" is not grounds; that is the argmax talking. The **user** may re-roll freely: `--from --reroll 1` (then 2, 3…). Steering: `--register safer` (slow-decay worlds, quiet strategies) or `--register bolder` (experience-grade worlds) on a re-roll. `--canon` is the standing exit: the category standard, played straight, at full craft; never recommend it, always allow it. After two consecutive re-rolls, ask what quality is missing rather than rolling a third time. +**Re-roll on the model's own initiative only for named grounds**: a `[FAIL]` coherence row that a single-field lock cannot fix; a script the font lacks; the world cannot carry the SIGNATURE DEVICE; or the user vetoes the world for this trade. "This doesn't feel right for the category" is not grounds; that is the argmax talking. The **user** may re-roll freely: `--from --reroll 1` (then 2, 3…). Steering: `--register safer` (slow-decay worlds, quiet strategies) or `--register bolder` (experience-grade worlds) on a re-roll. `--canon` is the standing exit: the category standard, played straight, at full craft; never recommend it, always allow it. After two consecutive re-rolls, ask what quality is missing rather than rolling a third time. + +When the **user** rejects a world as wrong for the trade, record it in the dossier's `refuse`. Re-roll with every Step 1 flag repeated plus `--from --lock concept= --reroll n+1`, optionally with `--register safer` (a deliberate quiet bias, so use it only when the user wants calmer): `--lock concept=` keeps the concept and re-deals only the world and device, the right veto when the user rejected the world, not the idea. After two misses, print three previews with the same flags, `--from --lock concept= --reroll n+1`, then `n+2`, `n+3`, each with `--json --no-memory` so previews do not write to the anti-repeat memory, and let the **user** pick. You never pick. ## Step 2 — Contract -Paste the printed `` block **verbatim** as the first child of `` in the root layout (never inside a slotted child component; some compilers strip those). Then fill the three model-authored lines: +Paste the printed `` block **verbatim** as the first child of `` in the root layout (never inside a slotted child component; some compilers strip those). Then fill the four model-authored lines: - **SCENE**: the one sentence from Step 0. -- **FIRST VIEWPORT**: what is where, at what scale, where the primary action sits — derived from the ARCHETYPE line, not from the category's usual hero. +- **FIRST VIEWPORT**: what is where, at what scale, where the primary action sits. Derived from the ARCHETYPE plus the CONCEPT; the signature element is visible in it at hero scale (utility: the primary object of the tool), and the first screen announces the concept, not a shop listing. - **THESIS**: the one idea this surface owns, and the category-default arrangement it refuses. If it reads like a mood ("clean, confident"), it is not decided yet. +- **SIGNATURE GRAPHIC** (the signature element; the contract key keeps this name): what is drawn, shown or set, which FACTS it encodes, how it renders (engine/file), and its three placements. + +CONCEPT supplies thesis, materials, the memorable element and the functional reskin. WORLD supplies grammar, skeleton, type and motion. FACTS win every conflict; clarity beats all three. + +## Step 3a — Signature visual first + +Route by the contract's `HERO TREATMENT`: + +- `scene`: the scene bar below, the art crew and two critique rounds. +- `pattern`: art brief + gallery, one critique round, no crew. S1 coverage and full-bleed apply; the S2 people/objects clauses do not. +- `photography`: write `.vary/visual-brief.md` listing which dossier asset goes where, with the crop rule, sequence and grade. Photos ship unaltered unless the world or a directive says otherwise. Identifiable real people are never redrawn. The photos carry the page: no frames or ornament around them, no drawn stand-ins for products, and described-but-missing photos become full-size tonal plates (`references/build-craft.md` §6 item 19). +- `product-ui`: a capture list (which workflow, which real data, which states), DPR-2 captures, callouts allowed. +- `typography`: a type specimen at hero size with the content language's glyphs, plus the fact the lettering carries. +- `data-visual`: the chart spec, its dossier data and a text equivalent. +- `utility`: skip to Step 3. + +The bars for the non-scene treatments are `graphics.md` §9-§12. -The GROUNDED ANTECEDENT line (thin-domain runs only) supplies material and thesis; the WORLD supplies grammar and skeleton; product facts win every conflict. +### Scene bar (treatment = scene only) + +Read [`references/graphics.md`](references/graphics.md) in full (§0b is the benchmark, §1b "Composing a scene" is the method). Write `.vary/art-brief.md` from its template. The brief must list every subject from the dossier that gets drawn, the engine, the style object, the banned forms, and the sizes (hero and thumb). The art meets this **scene bar**, which the brief must plan for and the critique must check: + +- **S1 (scene) Hero scene.** For `persuade` and `experience`: **edge to edge, no frame (G1).** The hero scene spans the full viewport width at desktop and phone (at least 95%, touching both edges): no HTML box, card, polaroid, frame or page margin around it (a border drawn inside the art, such as a blueprint sheet's, is part of the art). Judges: "full-bleed scene, not a framed picture". For `persuade` and `experience` modes the first viewport is a full-bleed illustrated scene (or sheet): the drawing covers at least 55% of the first viewport at 1440x900 and 45% at 390x844, made of at least 5 distinct subject objects from the dossier across at least 3 depth layers (background/environment, main subject, foreground detail or people). The brand or headline is set INTO the scene (a sign, label, title block, painted lettering), not floated beside a boxed picture. **The first viewport always carries a real HTML `

` (the business name or the promise), a one-line value proposition (what they do, for whom, in the client's real words from the dossier) and the primary action (a link or button with visible text), positioned ON a drawn surface of the scene: absolutely positioned over the open area the art leaves empty for it (the scene's sky, wall or sheet by default, as large display type; a sign or board only when it is the real signage at real scale; `graphics.md` §1b) and styled as that surface's lettering (`graphics.md` §1b has the pattern). It is painted-in and real text at once. Never only svg `` (the fold check fails "no visible h1"), never a floating card, never omitted.** At least 2 real dossier facts are rendered inside the art itself (a phone number on the sign, the founding year in a title block, opening hours on a door card). +- **S2 (scene) Richness floor** for every scene and plate: filled shapes (not hairlines on an empty field), one consistent light source (lit and shaded faces, cast shadows), texture (grain, hatching, halftone, paper), people or traces of use where the business has them, a palette of 4-7 inks. A single icon, a bare grid, a lone diagram in a framed box or a decorative blob FAILS the hero. +- **S3 (scene) Art all the way down.** Every section below the fold carries its own drawn plate at real size (at least 40% of the section's width (never under a third — the gate fails plates under 35%) and 280px tall at desktop, never thumbnail-only, and fully rendered with the same finish pass as the hero: gradient, glow, grain, at least 3 value steps; never a thin, small, pale line diagram in a tinted box, which judges read as "monochrome line drawings in tinted boxes"), drawn from that section's facts (place, product or menu, process, people or history); a catalogue, menu or services list draws EACH item (the art is a function of the item record). A section that is only cards, text or a form fails unless it is the contact block. Plus the micro scale (icons, bullets, dividers drawn in the same hand). +- **S4 (scene) Finish (G2).** Apply `graphics.md` §1d ("Depth recipe" has the numbers; flat fills with thin uniform strokes read as clip art): a value plan (one key light, one glow, the brightest value on the focal fact), at least 3 value steps per material, grain at 3-8%, and no empty region over 20% of the hero. +- **S5 (scene) Type in the art (G3).** No frosted, translucent or SOLID card, chip row or SaaS button pair over the hero art: an opaque headline container covering more than 6% of the art area fails (judges: "a headline card floats over the art, covering it"). The headline is a real `

` and the primary action a real link or button, set over a drawn surface (the scene's open sky, wall or sheet by default; a real sign, title block or board at real scale) as in the §1b pattern; phone and hours may be painted in the svg. Text on a surface that is part of the drawing is fine; a headline that exists only as svg `` fails the fold check. +- **S7 (scene) Objects and body composition.** (a) Every drawn object has a real silhouette (a train has bogies, windows and a roof line; a book has spine, boards and headband; never a box standing for a thing), at least 3 value steps, and people have a posture or gesture and a cast shadow (`graphics.md` §1e has a carriage and a bound book built from the kit). (b) No two consecutive sections share a layout skeleton. The brief assigns one of six per section: full-bleed plate with caption; catalogue of drawn items; process strip of 3-5 drawn steps; map or plan; big quote over art; ticket, label or form drawn as an object (`graphics.md` §7). At least half the sections below the fold are full-width drawn plates (full-bleed plate with caption, catalogue of drawn items, process strip, map or plan, quote over art); "a card with a small drawing" counts as the card skeleton and appears at most twice per page. Copy left with a small boxed diagram right, repeated, is the failure. (c) Every section plate is a drawing of that section's subject, never a labelled box with words in it. (d) Identity assets (headshots, sponsor or partner logos, client photos of people, product screenshots) are shown unaltered and are exempt from "draw each item". Data sections (schedule, pricing table, comparison, code) are exempt from plates. +- **S8 Mood fit (every treatment).** The art brief states the audience's mood need in one line (children: bright, safe, daytime; bereavement: calm, quiet, dignified; B2B lab: precise, trustworthy) and the chosen concept and world must serve it. If the critic finds they break it, re-roll the world with `--lock concept=` before building further. +- **S6 No collisions (every treatment).** Every label, callout and sign text is fully visible at both viewports: no overlap with drawn objects or other labels. + +**Art crew (scene only; Step 3a, after the brief).** Read [`references/art-crew.md`](references/art-crew.md). Dispatch illustrator subagents IN PARALLEL with the Agent tool: one for the hero scene and one per section plate (cap 6), each handed ONLY the art-brief path, its plate's row (subject, facts, skeleton, size), the style object and core kit paths, and `art-crew.md`. Each builds its plate module, renders it at real size, critiques its own screenshot against the richness bar (objects not boxes, posture, shared ground and light, 3+ values, texture, facts in the art), fixes, and returns the module path and final PNG path. The lead integrates and does not redraw. If the Agent tool is unavailable, the lead draws the plates itself in the same order. The lead then builds the gallery and runs the two critique rounds below on the integrated result. + +Build the art module and an `art-gallery.html` that renders every subject at both sizes, plus the hero scene composed whole at 1440x900 and 390x844. Screenshot it and read the screenshots. Write concrete critique (what reads wrong, not "could be better"). **Each critique round compares the gallery screenshot against S1-S3 explicitly and against the §0b benchmark descriptions in `graphics.md`, and lists what is missing: objects (count vs 5), layers (vs 3), light, value steps, grain, empty zones (vs 20%), facts-in-art, coverage, a plate per section below the fold, text collisions.** Fix it. Do two rounds, no more (with the art crew the illustrators' own rounds are the plate pass and your gallery review is one read plus one fix): the art loop has a time budget (hero first, plates second, then build; a shipped page with good art beats a perfect gallery and no page). Write a style-rule test for the world's constraint (for example "litho: no opacity, no gradients"). Under the scene treatment, no UI page code until round 2 passes. ## Step 3 — Build, fully committed @@ -66,7 +122,10 @@ While building: - **Borrow the skeleton, not the clothes.** Structure comes from the ARCHETYPE and the world's GRAMMAR. A committed surface over the category's standard marketing grid is the most common failure and the hardest to see. - **Full commitment beats a hedged half.** A perfectly executed conventional page beats a half-executed distinctive one; a fully executed distinctive one beats both. Use all the weights of the assigned faces. Let the colour strategy own the page area it claims. - **One authored motion moment** (the MOTION MOMENT line). Nothing else animates on entrance. -- **Real content.** Author illustrative material at full fidelity and label it synthetic where a visitor could mistake it; never invent prices, customers, testimonials, statistics or capabilities. +- **Real content.** Content comes from the dossier verbatim (menu, prices, hours, specs, quotes with source). Demo values are flagged as demo. Never invent prices, customers, testimonials, statistics or capabilities. +- **Every control in the world's material** (persuade/experience): nav, buttons, chips, cart and confirmation follow FUNCTIONAL RESKIN. A default underline nav or flat pill button reads as vibe-coded. Operate surfaces keep platform conventions for nav, forms and errors and skin them through tokens. +- **Decorative fields stay on graphic plates.** Reading, prices and forms sit on a high-contrast field. +- **Verify glyphs** for the content language (for example þ ð Á Í) by rendering the hero copy; swap or patch a face that fails. - **Effort clusters where the content matters.** Everything equally detailed means nothing is important. - When the world's grammar conflicts with a craft-floor rule, the grammar wins on aesthetics and the floor wins on accessibility, states and keyboard. @@ -75,21 +134,31 @@ Build the whole thing, then inspect once in a batched round (desktop and mobile ## Step 4 — Mechanical check ```bash -node /scripts/check.mjs --json +node /scripts/check.mjs --json ``` -Fix every `FAIL` in one batch (contract present, contrast, focus-visible, 9 states, reduced motion, image dimensions, font weights, arbitrary Tailwind values, AI vocabulary and em-dash density, template tells). Never satisfy a check by loosening it. Keep the JSON: the evaluator reads it. +Before running it, check the content by eye: **each fact (price list, hours, address, offer) appears once in the page body**; the footer may repeat contact details only. A primary action may repeat (a donation ask, ticket prices in the hero and the form); mark intentional repeats `data-repeat-ok`, the evaluator audits them. Any other repeated price list is a duplication bug: keep the one with the best container and delete the other. + +Craft-bug rules (check by eye and in the screenshots before the gate): content is visible with no JS-triggered reveal (an element may animate in only from a visible state, never from opacity 0 that a script must clear); no full-viewport overlay or backdrop left on the page; nothing in the hero is cropped by the nav or the viewport edge; every dial, axis or scale is monotonic and labelled with real values; every callout leader ends on its object. + +Fix every `FAIL` in one batch (contract present, contrast, focus-visible, 9 states, reduced motion, image dimensions, font weights, arbitrary Tailwind values, AI vocabulary and em-dash density, template tells, and the project checks: concept-contract, facts-in-build, and, for scene, pattern and data-visual treatments only, bespoke-graphics, seeded-art, svg-a11y; for any other treatment those three are INFO, not FAIL, and until check.mjs routes by treatment a bespoke-graphics FAIL on a typography, photography, product-ui or utility build is expected and is not fixed by adding art). A user directive outranks any check row (precedence item 3): never add an illustration to satisfy a check against a "no illustrations" directive. Never satisfy a check by loosening it. Keep the JSON: the evaluator reads it. + +## Step 4b — Critic before shipping + +Spawn a FRESH subagent (Agent tool) with the desktop and phone fold and full-page screenshots, the dossier facts and the critic prompt and rubric from [`references/art-crew.md`](references/art-crew.md) section 5, with `{TREATMENT}` filled from the contract (specificity, signature visual, craft, wow, each 1-10; anchors from `graphics.md` §0b at 9-10 for scene and the §9-§12 bars otherwise; "pill figures / boxes for objects / template sections / clipped text / dead space" at 4-5). It returns scores and the 5 highest-impact fixes. The lead applies the fixes (plates go back to their illustrator) and re-runs a new critic; stop when the mean is 7.5 or more or after 2 critic rounds, logging open items in NOTES.md. After applying fixes, re-run `check.mjs` and the fold check and refresh the evaluator JSON before Step 5; record the last critic mean in NOTES.md. Critic rounds sit outside the art budget and may be skipped when time is short. + +**Blocking craft fails.** Before Step 5 or shipping, these fold-check rows must be PASS: collisions, clipped-text, hidden-content, stray-overlay, duplicate-content, page-overflow (nothing wider than the viewport at 1440 or 390 unless inside its own scroll container), meta-copy (no designer or scaffold language in visible text; exempt with data-meta-ok), headline-on-fold (operate/read: title-on-fold), plus headline-in-scene for the scene treatment only (the check's `BLOCKING:` line lists any that fail). They are cheap to fix and the time budget never excuses them. Coverage, depth and plate-size may ship as logged open items (`NOTES.md`) if time runs out. ## Step 5 — Separate evaluation Mandatory for anything the user will ship; skippable only when the user said "just something quick". -1. Read [`evaluator-prompt.md`](evaluator-prompt.md) and copy it **verbatim** into a new subagent (`general-purpose`, `model: sonnet`, `mode: bypassPermissions`). Append: the original brief, the absolute paths of the built files, the path of the `check.mjs` JSON, and screenshot paths if any (`npx agent-browser` or the browser MCP when available; otherwise say none). -2. Do **not** pass this SKILL.md, the references, the catalogs, or any of the generating conversation. The evaluator audits the build against its own contract block and tests distinctiveness; feeding it the generator's rules collapses that separation. +1. Read [`evaluator-prompt.md`](evaluator-prompt.md) and copy it **verbatim** into a new subagent (`general-purpose`, `model: sonnet`, `mode: bypassPermissions`). Append: the original brief, the absolute paths of the built files, the path of the `check.mjs` JSON, `.vary/dossier.json`, `.vary/art-brief.md`, `.vary/critique-r2.json` (the round-2 art critique) and the gallery screenshots (these three only if the treatment produced them; photography, typography and utility builds have none), plus the page screenshots (`npx agent-browser` or the browser MCP). Screenshots are **required** for persuade and experience (ios/android: device screenshots); without them the evaluator returns RECAPTURE. Optionally append `SIBLINGS:` with the contract blocks of other variants for the same project. +2. The dossier, art brief and critique are project evidence, not generator rules. Pass them. Still withhold this SKILL.md, the references, the catalogs, and any of the generating conversation. The evaluator audits the build against its own contract block and tests distinctiveness; feeding it the generator's rules collapses that separation. 3. Dispositions are closed: **SHIP / FIX / REBUILD / RECAPTURE**. FIX gets exactly one batched round, re-checked by the evaluator, not by the builder. REBUILD means a structural promise broke (skeleton, a grammar rule, thesis vs execution): return to Step 3 with the same contract. RECAPTURE means the evidence was bad (contract stripped, no source): fix the evidence, not the design. 4. The builder never marks its own SHIP. If no subagent tool exists, run `check.mjs`, re-read only the contract and source in a fresh turn, cap the outcome at FIX, and tell the user an independent review did not happen. -Report to the user: the SEED KEY (so the roll is reproducible), the world and skeleton in one line, the disposition, and the re-roll command. +Report to the user: the SEED KEY (so the roll is reproducible), the concept name and signature device, the world and skeleton in one line, the disposition, and the re-roll command. --- @@ -100,10 +169,18 @@ Report to the user: the SEED KEY (so the roll is reproducible), the world and sk | Repo has DESIGN.md / tokens / themed components | Extend mode (`references/extend-mode.md`); no world roll | | New section or component inside an existing surface | Inherit the surface; no roll at all | | User pins an aesthetic, era, font or palette | `--lock …`; the brief wins over any catalog warning | +| User directive about the look ("no illustrations", "B&W") | `dossier.directives`; outranks the bars | +| Client supplies photography | `photography` concept | +| SaaS with a real product | `product-ui` concept | +| Dashboard or docs | `utility`, no art brief | +| Native platform (ios/android) | `mobile-design` + device screenshots | | User says "safer" / "bolder" while a roll is open | `--from --reroll n --register safer|bolder` | | User says "just do the normal thing" | `--canon`; ask for 2-3 peer products; their craft is the bar | | Operate surface (dashboard, settings, checkout) | Mode `operate`: quiet worlds, conventional skeleton, distinctiveness in details | -| Thin category | `references/thin-domain.md` → `--grounded` | +| Any new project | Dossier + concepts (Step 0-0b); a thin category simply has more `artefact` and `place` facts | +| Client has a live site | Scrape copy and media into the dossier; throttle; Wayback fallback | +| N variants for one client | One dossier, one concepts file, one key, `--reroll 0..N-1`; concepts and worlds are excluded per round | +| User vetoes a world for the trade | Record in dossier.refuse, `--register safer`; after 2 misses show 3 previews to the user | | Needs Cyrillic/Greek/Vietnamese body text | `--scripts latin,cyrillic` (filters the font pool before rolling) | | Email / RN / no webfonts | `--no-webfonts` (system stack; hierarchy from weight and size) | | Coherence row FAIL | Lock the failing axis and re-run; never proceed on a known-bad assignment | @@ -111,11 +188,24 @@ Report to the user: the SEED KEY (so the roll is reproducible), the world and sk ## What breaks this skill -- **Choosing among options.** Presenting three directions and picking one, or "picking the boldest", reinstates the argmax. The roll assigns; the user may steer. +- **Choosing among options.** Presenting three directions and picking one, or "picking the boldest", reinstates the argmax. The roll assigns; the user may steer. Writing several concepts is required. Choosing among them is not yours. +- **Subject-blind direction**: rolling without concepts on a new project. +- **Art that stops at the fold (scene treatment).** One hero, then text, cards and a form. Every section carries its own plate (S3). +- **A UI card over the art (scene treatment).** A frosted card, chip row or button pair laid over the hero (S5). +- **Template body (scene treatment).** After a good hero, every section repeats copy left and a small boxed line diagram right, price lists appear twice, plates are labelled boxes. Assign a different skeleton to each section (S7). +- **No headline on the fold.** (scene: over a drawn surface) No real `

` over a drawn surface, only svg text, no value proposition or action, or a stock card floating over the art (S1, S5). +- **Wrong mood for the audience.** A concept or world that breaks the audience's mood need (bright primary shapes for a funeral home, a gloomy night street for a children's clinic, a murky page for a friendly accountant) is a critic FAIL; re-roll with `--lock concept=` before building further (S8). +- **Boxes for objects (scene treatment).** A train, drawer or book drawn as a rectangle, a person as a blob (S7a). +- **A thin hero (scene treatment).** One icon, one diagram in a framed box, a bare grid or a text-only first screen on a persuade surface. The benchmark heroes are whole scenes or sheets (S1-S3). +- **Hunting for scripts.** Searching the filesystem for `roll.mjs` or running the installed plugin's copy instead of the base directory. +- **Decoration instead of drawing (scene treatment)**: blobs, gradient meshes or noise-only heroes where a drawing of the subject belongs. +- **Invented facts**: a number, quote or customer not in the dossier. - **Guardrails inside the brief.** Loading `build-craft.md` before the contract, or writing "be bold but not too bold", produces the hedged 65% page. - **Naming replacements.** Writing "use Satoshi or Geist instead of Inter" anywhere creates the next default. Fonts come from the roll. - **Skin-only variety.** New font and palette on the category's standard grid. The evaluator's silhouette test exists to catch this; do not argue with it. - **Randomness on an operate surface.** A maximalist checkout is worse than a plain one. The mode gate is not optional. - **Independent axes.** Picking a palette from one list and a style from another produces dark style + light palette contradictions. Everything is conditioned on the world. +- **Overriding a user directive.** Illustrating after "no illustrations", adding colour after "black and white". +- **Transforming client photos or redrawing real people** when the treatment is photography. - **Self-certification.** The thread that built it may not ship it. - **Premium by effect.** Gradient text, decorative glass, uniform `rounded-2xl` and `shadow-lg` signal a template, not craft. Weight, size, position and material carry hierarchy. diff --git a/plugins/design/skills/vary/data/worlds.json b/plugins/design/skills/vary/data/worlds.json index e17ee58..ecf3bd7 100644 --- a/plugins/design/skills/vary/data/worlds.json +++ b/plugins/design/skills/vary/data/worlds.json @@ -1,6 +1,7 @@ [ { "id": "swiss-international", + "temperaments": ["civic-calm", "technical-precise", "avant-garde"], "name": "Swiss / International Style", "origin": "Basel/Zürich, late 1940s-1950s — Müller-Brockmann, Hofmann, Ruder", "one_line": "Fixed baseline grid; hierarchy by weight/size/position, never color.", @@ -79,6 +80,16 @@ "kids", "luxury fashion" ], + "sectors": [ + "enterprise-saas", + "maritime-logistics", + "culture-arts", + "trades-construction" + ], + "avoid_sectors": [ + "kids-education", + "fashion-beauty-luxury" + ], "confidence": "medium", "sources": [ "https://en.wikipedia.org/wiki/International_Typographic_Style", @@ -88,6 +99,7 @@ }, { "id": "bauhaus", + "temperaments": ["avant-garde", "technical-precise"], "name": "Bauhaus", "origin": "Weimar/Dessau, 1919-1933 — Herbert Bayer, László Moholy-Nagy", "one_line": "Primary-color geometric primitives as literal structural units — form states its function.", @@ -163,6 +175,17 @@ "luxury fashion", "finance/trading" ], + "sectors": [ + "kids-education", + "retail-ecommerce", + "culture-arts", + "hospitality-travel", + "trades-construction" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "finance-insurance" + ], "confidence": "high", "sources": [ "https://eyeondesign.aiga.org/5-examples-of-bauhaus-graphic-design-that-shaped-the-movement/", @@ -171,6 +194,7 @@ }, { "id": "de-stijl", + "temperaments": ["avant-garde", "playful-bright"], "name": "De Stijl", "origin": "Netherlands, 1917-1931 — Mondrian, Rietveld, van Doesburg", "one_line": "The orthogonal grid itself is the artwork, in flat red/yellow/blue on black-and-white.", @@ -245,6 +269,10 @@ "dense data dashboards", "long-form editorial" ], + "sectors": [ + "culture-arts" + ], + "avoid_sectors": [], "confidence": "high", "sources": [ "https://www.theartstory.org/movement/de-stijl/", @@ -253,6 +281,7 @@ }, { "id": "constructivist", + "temperaments": ["avant-garde", "nocturnal-social"], "name": "Constructivist / Soviet avant-garde", "origin": "USSR, 1919-1930s — Rodchenko, Lissitzky", "one_line": "Diagonal photomontage and red-on-cream type running as a graphic shape, not just text.", @@ -329,6 +358,16 @@ "children's products", "enterprise B2B" ], + "sectors": [ + "nonprofit-activism", + "music-events-nightlife", + "dev-tools" + ], + "avoid_sectors": [ + "healthcare-clinical", + "kids-education", + "enterprise-saas" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Alexander_Rodchenko", @@ -337,6 +376,7 @@ }, { "id": "dutch-structuralism", + "temperaments": ["avant-garde", "civic-calm"], "name": "Dutch Structuralism (Total Design / Experimental Jetset)", "origin": "Amsterdam, 1963-present — Total Design; Experimental Jetset (1997-)", "one_line": "The grid made visible; deadpan Helvetica captions describe the system itself.", @@ -408,6 +448,14 @@ "consumer e-commerce", "playful youth brands" ], + "sectors": [ + "culture-arts", + "media-publishing" + ], + "avoid_sectors": [ + "retail-ecommerce", + "kids-education" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Experimental_Jetset", @@ -416,6 +464,7 @@ }, { "id": "memphis", + "temperaments": ["playful-bright", "avant-garde", "nocturnal-social"], "name": "Memphis", "origin": "Milan, 1980-1987 — Ettore Sottsass and the Memphis Group", "one_line": "Clashing neon-and-pastel geometry, deliberately off-grid, refusing a single safe accent.", @@ -493,6 +542,18 @@ "healthcare", "enterprise operate-mode tools" ], + "sectors": [ + "kids-education", + "culture-arts", + "music-events-nightlife", + "retail-ecommerce", + "food-drink" + ], + "avoid_sectors": [ + "finance-insurance", + "healthcare-clinical", + "enterprise-saas" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Memphis_Group", @@ -501,6 +562,7 @@ }, { "id": "y2k-frutiger-aero", + "temperaments": ["playful-bright", "technical-precise", "nocturnal-social"], "name": "Y2K / Frutiger Aero", "origin": "Global consumer tech, 2004-2013 — Windows XP/Mac OS X-era skeuomorphism", "one_line": "Glossy aqua gradients and bokeh nature-tech collage — 2004-era optimism, never flat.", @@ -578,6 +640,16 @@ "healthcare", "B2B tools" ], + "sectors": [ + "retail-ecommerce", + "music-events-nightlife", + "dev-tools", + "gaming" + ], + "avoid_sectors": [ + "enterprise-saas", + "healthcare-clinical" + ], "confidence": "medium", "sources": [ "https://frutiger-aero.org/frutiger-aero", @@ -586,6 +658,7 @@ }, { "id": "brutalist-web", + "temperaments": ["avant-garde", "natural-raw", "technical-precise"], "name": "Brutalist web", "origin": "Web, ~2014-present — Pascal Deville's brutalistwebsites.com", "one_line": "Raw, unstyled browser defaults left exactly as the browser draws them.", @@ -659,6 +732,15 @@ "e-commerce checkout flows", "healthcare" ], + "sectors": [ + "culture-arts", + "music-events-nightlife", + "nonprofit-activism" + ], + "avoid_sectors": [ + "retail-ecommerce", + "healthcare-clinical" + ], "confidence": "high", "sources": [ "https://www.brutalistwebsites.com/", @@ -667,6 +749,7 @@ }, { "id": "neo-brutalism", + "temperaments": ["playful-bright", "avant-garde", "technical-precise"], "name": "Neo-brutalism (product UI)", "origin": "Web/product UI, ~2020-present — popularized by Michał Malewicz's March 2022 essay (no single originator is universally credited)", "one_line": "Thick black borders and flat offset shadows — a rubber stamp pressed into pixels.", @@ -744,6 +827,15 @@ "luxury fashion", "healthcare" ], + "sectors": [ + "dev-tools", + "enterprise-saas", + "finance-insurance" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "healthcare-clinical" + ], "confidence": "medium", "sources": [ "https://www.nngroup.com/articles/neobrutalism", @@ -752,6 +844,7 @@ }, { "id": "japanese-editorial", + "temperaments": ["civic-calm", "natural-raw", "warm-domestic"], "name": "Japanese editorial (Idea magazine / MUJI)", "origin": "Tokyo, 1980-present — MUJI's product and print design language. (Idea magazine, the namesake 1953 typography journal, in fact runs a more colorful, maximalist editorial register per its own citation below; this card follows the MUJI/negative-space lineage specifically, not Idea's editorial pages.)", "one_line": "Negative space as an active element; hand-tuned kerning carries the hierarchy.", @@ -825,6 +918,17 @@ "dense operate-mode dashboards", "youth/playful consumer apps" ], + "sectors": [ + "retail-ecommerce", + "wellness", + "culture-arts", + "food-drink", + "hospitality-travel", + "nature-outdoor" + ], + "avoid_sectors": [ + "kids-education" + ], "confidence": "verify", "sources": [ "https://www.idea-mag.com/en/about/", @@ -833,6 +937,7 @@ }, { "id": "scandinavian-functionalism", + "temperaments": ["warm-domestic", "civic-calm", "natural-raw"], "name": "Scandinavian functionalism", "origin": "Nordic countries, 1930s-present — Stockholm Exhibition 1930; Aalto, Jacobsen", "one_line": "Light birch and pale grey, generous margins, ornament only where it states a function.", @@ -907,6 +1012,19 @@ "nightlife/music", "high-energy sports products" ], + "sectors": [ + "retail-ecommerce", + "kids-education", + "nature-outdoor", + "food-drink", + "hospitality-travel", + "trades-construction", + "wellness" + ], + "avoid_sectors": [ + "music-events-nightlife", + "sports-fitness" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Scandinavian_design", @@ -915,6 +1033,7 @@ }, { "id": "italian-rationalist", + "temperaments": ["civic-calm", "avant-garde", "technical-precise"], "name": "Italian Rationalist / Radical design", "origin": "Italy, 1930s (rationalism) and 1966-1978 (radical) — Terragni; Superstudio (1966-1978), Archizoom (1966-1974)", "one_line": "An infinite black-on-white grid receding to a vanishing point, punctured by one red intrusion.", @@ -988,6 +1107,15 @@ "consumer e-commerce", "kids products" ], + "sectors": [ + "culture-arts", + "media-publishing", + "trades-construction" + ], + "avoid_sectors": [ + "retail-ecommerce", + "kids-education" + ], "confidence": "verify", "sources": [ "https://architizer.com/blog/inspiration/stories/superstudio-gridded-architecture/", @@ -996,6 +1124,7 @@ }, { "id": "mid-century-corporate", + "temperaments": ["civic-calm", "technical-precise"], "name": "Mid-century American corporate", "origin": "USA, 1950s-60s — Paul Rand, Saul Bass", "one_line": "One geometric motif repeated at every scale, anchored by a confident flat logotype.", @@ -1071,6 +1200,13 @@ "cutting-edge tech startups wanting to feel new", "gaming" ], + "sectors": [ + "finance-insurance" + ], + "avoid_sectors": [ + "dev-tools", + "gaming" + ], "confidence": "high", "sources": [ "https://www.designyourway.net/blog/paul-rand/", @@ -1079,6 +1215,7 @@ }, { "id": "op-art", + "temperaments": ["avant-garde", "playful-bright", "nocturnal-social"], "name": "Op-art", "origin": "1960s — Bridget Riley, Victor Vasarely; MoMA's 'The Responsive Eye' 1965", "one_line": "A single mathematical pattern engineered to vibrate — the pattern is the entire content.", @@ -1148,6 +1285,14 @@ "long-session operate tools", "healthcare" ], + "sectors": [ + "culture-arts", + "music-events-nightlife", + "fashion-beauty-luxury" + ], + "avoid_sectors": [ + "healthcare-clinical" + ], "confidence": "medium", "sources": [ "https://www.britannica.com/biography/Bridget-Riley", @@ -1156,6 +1301,7 @@ }, { "id": "psychedelic", + "temperaments": ["nocturnal-social", "avant-garde", "playful-bright"], "name": "Psychedelic (Fillmore poster)", "origin": "San Francisco, 1965-1971 — Wes Wilson, Victor Moscoso, the Fillmore Auditorium", "one_line": "Melting hand-lettered type fused into Art-Nouveau illustration at full complementary saturation.", @@ -1233,6 +1379,15 @@ "healthcare", "finance" ], + "sectors": [ + "music-events-nightlife", + "culture-arts" + ], + "avoid_sectors": [ + "enterprise-saas", + "healthcare-clinical", + "finance-insurance" + ], "confidence": "medium", "sources": [ "https://www.smithsonianmag.com/arts-culture/psychedelic-concert-poster-rocked-world-180958780/", @@ -1241,6 +1396,7 @@ }, { "id": "punk-zine", + "temperaments": ["nocturnal-social", "avant-garde", "natural-raw"], "name": "Punk / zine (Jamie Reid)", "origin": "UK, 1976-1980s — Jamie Reid, Sex Pistols", "one_line": "Ransom-note cut-and-paste lettering on photocopier-degraded black and white.", @@ -1318,6 +1474,17 @@ "finance", "luxury fashion" ], + "sectors": [ + "music-events-nightlife", + "nonprofit-activism", + "culture-arts" + ], + "avoid_sectors": [ + "enterprise-saas", + "healthcare-clinical", + "finance-insurance", + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://www.theartnewspaper.com/2023/08/10/jamie-reid-graphic-designer-who-defined-the-visual-character-of-the-punk-movement-has-died-aged-76", @@ -1326,6 +1493,7 @@ }, { "id": "terminal-hacker", + "temperaments": ["technical-precise", "nocturnal-social"], "name": "Terminal / hacker", "origin": "1970s-80s green-phosphor terminals (VT100, IBM 3270), revived 2015-present", "one_line": "Monospace-only, phosphor-on-black, a blinking cursor as the literal interface metaphor.", @@ -1400,6 +1568,16 @@ "wellness/lifestyle brands", "kids products" ], + "sectors": [ + "dev-tools", + "engineering-technical" + ], + "avoid_sectors": [ + "retail-ecommerce", + "wellness", + "kids-education", + "food-drink" + ], "confidence": "high", "sources": [ "https://designbycurio.com/learn/bloomberg-terminal-green", @@ -1408,6 +1586,7 @@ }, { "id": "industrial-datasheet", + "temperaments": ["technical-precise", "civic-calm"], "name": "Industrial / datasheet", "origin": "Engineering/electronics documentation, 1950s-present", "one_line": "Hairline-ruled tables, decimal-aligned tabular figures, labeled technical diagrams instead of photos.", @@ -1485,6 +1664,16 @@ "luxury fashion", "kids products" ], + "sectors": [ + "industrial-hardware", + "engineering-technical", + "trades-construction" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "kids-education", + "retail-ecommerce" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Datasheet", @@ -1493,6 +1682,7 @@ }, { "id": "aviation-hud", + "temperaments": ["technical-precise", "nocturnal-social"], "name": "Aviation / cockpit (HUD)", "origin": "Military avionics from WWII, standardized 1960s-70s; commercial glass cockpits from early 1980s (Boeing 757/767, Airbus A310)", "one_line": "Thin glowing outline gauges on near-black, framed by corner brackets, signal colors only.", @@ -1569,6 +1759,17 @@ "kids products", "wellness" ], + "sectors": [ + "engineering-technical", + "finance-insurance", + "gaming", + "dev-tools" + ], + "avoid_sectors": [ + "wellness", + "kids-education", + "food-drink" + ], "confidence": "medium", "sources": [ "https://skybrary.aero/articles/head-display-hud", @@ -1577,6 +1778,7 @@ }, { "id": "art-deco-geometric", + "temperaments": ["heritage-craft", "nocturnal-social", "avant-garde"], "name": "Art Deco geometric", "origin": "France, early-to-mid 1920s, crystallized at the 1925 Paris Exposition; peaked in the US through the 1930s", "one_line": "Stepped, sunburst geometry in gold and jewel tone, with a confident vertical axis.", @@ -1654,6 +1856,17 @@ "enterprise operate dashboards", "minimal utilitarian apps" ], + "sectors": [ + "hospitality-travel", + "music-events-nightlife", + "fashion-beauty-luxury", + "food-drink", + "wellness" + ], + "avoid_sectors": [ + "dev-tools", + "enterprise-saas" + ], "confidence": "medium", "sources": [ "https://www.britannica.com/art/Art-Deco", @@ -1662,6 +1875,7 @@ }, { "id": "dieter-rams-braun", + "temperaments": ["technical-precise", "civic-calm", "warm-domestic"], "name": "Dieter Rams / Braun", "origin": "Frankfurt/Kronberg, Germany, 1955-1995 — Dieter Rams at Braun; Otl Aicher's Ulm-school graphic system", "one_line": "A strict modular grid, one signal color, and nothing that doesn't serve a stated function.", @@ -1738,6 +1952,17 @@ "maximalist fashion", "entertainment" ], + "sectors": [ + "industrial-hardware", + "retail-ecommerce", + "engineering-technical", + "trades-construction" + ], + "avoid_sectors": [ + "kids-education", + "fashion-beauty-luxury", + "culture-arts" + ], "confidence": "medium", "sources": [ "https://www.vitsoe.com/us/about/dieter-rams", @@ -1746,6 +1971,7 @@ }, { "id": "letterpress-wood-type", + "temperaments": ["heritage-craft", "warm-domestic", "nocturnal-social"], "name": "Letterpress / wood type (Hatch Show Print)", "origin": "Nashville, since 1879 — Hatch Show Print", "one_line": "Mismatched wood-type sizes and uneven hand-inking on a carved-illustration handbill.", @@ -1823,6 +2049,19 @@ "fintech", "healthcare" ], + "sectors": [ + "music-events-nightlife", + "food-drink", + "retail-ecommerce", + "hospitality-travel", + "trades-construction", + "nature-outdoor" + ], + "avoid_sectors": [ + "enterprise-saas", + "finance-insurance", + "healthcare-clinical" + ], "confidence": "high", "sources": [ "https://hatchshowprint.com/about", @@ -1831,6 +2070,7 @@ }, { "id": "scientific-journal", + "temperaments": ["technical-precise", "civic-calm", "natural-raw"], "name": "Scientific journal (Nature / LaTeX two-column)", "origin": "Scientific publishing, 19th century-present — Nature, LaTeX two-column convention", "one_line": "A strict two-column grid where every citation and figure number is a real cross-reference.", @@ -1904,6 +2144,12 @@ "consumer marketing pages", "playful youth brands" ], + "sectors": [ + "engineering-technical", + "media-publishing", + "kids-education" + ], + "avoid_sectors": [], "confidence": "high", "sources": [ "https://www.nature.com/nature/for-authors/final-submission", @@ -1912,6 +2158,7 @@ }, { "id": "mid-century-travel-poster", + "temperaments": ["heritage-craft", "playful-bright"], "name": "Mid-century travel poster", "origin": "1920s-1960s — Golden Age of railway/ocean-liner travel through the Jet Age; David Klein for TWA", "one_line": "A flat-litho destination image, one confident wordmark, one signature geometric motif.", @@ -1989,6 +2236,18 @@ "fintech", "dev tools" ], + "sectors": [ + "hospitality-travel", + "legal-public", + "maritime-logistics", + "food-drink", + "nature-outdoor" + ], + "avoid_sectors": [ + "enterprise-saas", + "finance-insurance", + "dev-tools" + ], "confidence": "high", "sources": [ "https://www.orsonandwelles.co.uk/blogs/articles/the-evolution-of-travel-posters-from-railway-romance-to-the-jet-age", @@ -1997,6 +2256,7 @@ }, { "id": "cartographic", + "temperaments": ["heritage-craft", "natural-raw", "technical-precise", "civic-calm"], "name": "Cartographic", "origin": "Ordnance Survey / USGS map conventions, 19th c.-present", "one_line": "The page reads as a surveyed map: legend as first-class UI, content organized by location, not narrative order.", @@ -2082,6 +2342,17 @@ "checkout/transaction flows", "kids' products" ], + "sectors": [ + "maritime-logistics", + "hospitality-travel", + "retail-ecommerce", + "trades-construction", + "nature-outdoor" + ], + "avoid_sectors": [ + "kids-education", + "media-publishing" + ], "confidence": "medium", "sources": [ "https://docs.os.uk/more-than-maps/geographic-data-visualisation/guide-to-cartography/map-legends" @@ -2089,6 +2360,7 @@ }, { "id": "apothecary-label", + "temperaments": ["heritage-craft", "warm-domestic", "natural-raw"], "name": "Apothecary / Label", "origin": "Victorian pharmacy labels, 1800s-1930s", "one_line": "A single centered, symmetrical label in small-caps serif and one rule — trust through restraint, not romance.", @@ -2171,6 +2443,17 @@ "gaming", "enterprise SaaS" ], + "sectors": [ + "wellness", + "food-drink", + "fashion-beauty-luxury", + "hospitality-travel", + "nature-outdoor" + ], + "avoid_sectors": [ + "gaming", + "enterprise-saas" + ], "confidence": "verify", "sources": [ "https://creativemarket.com/blog/design-trend-apothecary-style" @@ -2178,6 +2461,7 @@ }, { "id": "museum-gallery", + "temperaments": ["civic-calm", "avant-garde", "heritage-craft"], "name": "Museum / Gallery", "origin": "Institutional wall-text conventions, 1970s-present", "one_line": "Extreme whitespace treats content like an exhibited object; quiet caption type never competes with the work.", @@ -2255,6 +2539,16 @@ "dense SaaS dashboards", "urgency-driven campaigns" ], + "sectors": [ + "culture-arts", + "retail-ecommerce", + "hospitality-travel", + "wellness" + ], + "avoid_sectors": [ + "dev-tools", + "enterprise-saas" + ], "confidence": "verify", "sources": [ "https://www.goldustdesign.co.uk/post/typographic-design-in-galleries" @@ -2262,6 +2556,7 @@ }, { "id": "sports-broadcast", + "temperaments": ["nocturnal-social", "playful-bright", "technical-precise"], "name": "Sports Broadcast", "origin": "ESPN/Sky Sports lower-thirds, 1990s-present", "one_line": "Angular sheared panels and broadcast-style data call-outs make static content feel live.", @@ -2339,6 +2634,16 @@ "meditative/wellness apps", "enterprise B2B tools" ], + "sectors": [ + "sports-fitness", + "gaming", + "media-publishing" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "wellness", + "enterprise-saas" + ], "confidence": "verify", "sources": [ "https://www.newscaststudio.com/setstudio/sportscenter/" @@ -2346,6 +2651,7 @@ }, { "id": "transit-signage", + "temperaments": ["civic-calm", "technical-precise"], "name": "Transit Signage", "origin": "NYC Subway (Vignelli), London Underground (Johnston/Kinneir Calvert), 1930s-1970s", "one_line": "One geometric sans at every scale, a strict route-color system, and pictograms standing in for words.", @@ -2421,6 +2727,16 @@ "narrative storytelling sites", "children's products" ], + "sectors": [ + "maritime-logistics", + "legal-public", + "engineering-technical", + "trades-construction" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "kids-education" + ], "confidence": "high", "sources": [ "https://standardsmanual.com/products/nyctacompactedition", @@ -2429,6 +2745,7 @@ }, { "id": "luxury-fashion-editorial", + "temperaments": ["avant-garde", "heritage-craft", "nocturnal-social"], "name": "Luxury Fashion Editorial", "origin": "Vogue/haute-couture lookbooks, 1960s-present", "one_line": "Extreme whitespace and one oversized didone headline make the interface nearly disappear.", @@ -2505,6 +2822,17 @@ "budget/discount retail", "kids' products" ], + "sectors": [ + "retail-ecommerce", + "hospitality-travel", + "fashion-beauty-luxury", + "food-drink", + "wellness" + ], + "avoid_sectors": [ + "enterprise-saas", + "kids-education" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Didone_(typography)" @@ -2512,6 +2840,7 @@ }, { "id": "childrens-book", + "temperaments": ["playful-bright", "warm-domestic", "natural-raw"], "name": "Children's Book Illustration", "origin": "Golden Book / Eric Carle tradition, 1940s-present", "one_line": "Flat, hand-textured shapes and a rounded, friendly type carry one warm subject per screen.", @@ -2595,6 +2924,17 @@ "finance/trading tools", "luxury fashion" ], + "sectors": [ + "kids-education", + "retail-ecommerce", + "food-drink", + "nature-outdoor" + ], + "avoid_sectors": [ + "enterprise-saas", + "finance-insurance", + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Little_Golden_Books", @@ -2603,6 +2943,7 @@ }, { "id": "risograph-zine", + "temperaments": ["playful-bright", "avant-garde", "natural-raw"], "name": "Risograph Print / Zine", "origin": "Riso Kagaku duplicators (Japan, 1986), adopted by indie publishers, 2000s-present", "one_line": "2-3 fluorescent spot colors misregister by a few pixels, echoing a duplicator print run.", @@ -2687,6 +3028,21 @@ "finance/trading tools", "medical/clinical products" ], + "sectors": [ + "media-publishing", + "music-events-nightlife", + "culture-arts", + "retail-ecommerce", + "food-drink", + "hospitality-travel", + "nature-outdoor", + "wellness" + ], + "avoid_sectors": [ + "enterprise-saas", + "finance-insurance", + "healthcare-clinical" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Risograph" @@ -2694,6 +3050,7 @@ }, { "id": "editorial-newspaper", + "temperaments": ["heritage-craft", "civic-calm"], "name": "Editorial Newspaper (Broadsheet)", "origin": "19th-c. newspaper layout, still visible in NYT/WSJ digital", "one_line": "Rule-lined multi-column serif text with a huge headline jump and byline convention, borrowed straight from print.", @@ -2769,6 +3126,13 @@ "gaming", "children's products" ], + "sectors": [ + "media-publishing" + ], + "avoid_sectors": [ + "gaming", + "kids-education" + ], "confidence": "verify", "sources": [ "https://en.wikipedia.org/wiki/Broadsheet" @@ -2776,6 +3140,7 @@ }, { "id": "corporate-memphis", + "temperaments": ["playful-bright", "civic-calm"], "name": "Corporate Memphis / Flat Illustration", "trap": true, "origin": "Facebook/Slack/Mailchimp-era SaaS illustration boom, ~2015-2021 (now itself a cliché, coined by Mike Merrill as 'Corporate Memphis')", @@ -2829,6 +3194,8 @@ "avoid_for": [ "every context — catalog only to help the model recognize and reject it on sight" ], + "sectors": [], + "avoid_sectors": [], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Corporate_Memphis", @@ -2837,6 +3204,7 @@ }, { "id": "grunge-carson", + "temperaments": ["nocturnal-social", "avant-garde", "natural-raw"], "name": "Grunge / David Carson Editorial", "origin": "Ray Gun magazine, 1992-1996", "one_line": "Deliberately illegible, overlapping type and torn-paper texture treat photography as texture, not image.", @@ -2918,6 +3286,16 @@ "financial services", "accessibility-first products" ], + "sectors": [ + "music-events-nightlife", + "fashion-beauty-luxury", + "media-publishing" + ], + "avoid_sectors": [ + "enterprise-saas", + "healthcare-clinical", + "finance-insurance" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/David_Carson_(graphic_designer)", @@ -2926,6 +3304,7 @@ }, { "id": "board-game-rulebook", + "temperaments": ["playful-bright", "technical-precise", "warm-domestic"], "name": "Board Game / Tabletop Rulebook", "origin": "Modern hobby board-game manuals, 1990s-present", "one_line": "Numbered callouts cross-reference diagrams; an iconography glossary is a first-class section, not a footnote.", @@ -3008,6 +3387,15 @@ "fast-scrolling social apps", "poster-style landing pages" ], + "sectors": [ + "engineering-technical", + "legal-public", + "kids-education", + "gaming" + ], + "avoid_sectors": [ + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://www.onpressbookprinting.com/resources/designing-a-board-game-manual-guide", @@ -3016,6 +3404,7 @@ }, { "id": "wayfinding-aicher", + "temperaments": ["civic-calm", "technical-precise", "playful-bright"], "name": "Wayfinding / Signage Systems (Otl Aicher)", "origin": "Munich Olympics 1972, airport signage lineage", "one_line": "A modular pictogram grid and one accent color per zone make legibility-at-a-glance the entire design system.", @@ -3091,6 +3480,16 @@ "narrative storytelling sites", "children's illustrated products" ], + "sectors": [ + "maritime-logistics", + "healthcare-clinical", + "retail-ecommerce", + "kids-education", + "trades-construction" + ], + "avoid_sectors": [ + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Otl_Aicher" @@ -3098,6 +3497,7 @@ }, { "id": "blueprint", + "temperaments": ["technical-precise", "heritage-craft"], "name": "Blueprint / Technical Drawing", "origin": "Architectural/engineering blueprints, 1900s-present", "one_line": "White line-work on deep blue — everything is stroke, dimensioned with arrowheads and a drafting title block.", @@ -3177,6 +3577,15 @@ "children's products", "warm/lifestyle brands" ], + "sectors": [ + "engineering-technical", + "industrial-hardware", + "trades-construction" + ], + "avoid_sectors": [ + "fashion-beauty-luxury", + "kids-education" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Blueprint" @@ -3184,6 +3593,7 @@ }, { "id": "newsprint-tabloid", + "temperaments": ["heritage-craft", "nocturnal-social", "civic-calm"], "name": "Newsprint Tabloid", "origin": "NY Post / Bild-style front pages", "one_line": "One enormous shock headline and halftone-dot photography dominate the page like a front page on a newsstand rack.", @@ -3263,6 +3673,17 @@ "enterprise SaaS", "luxury brands" ], + "sectors": [ + "media-publishing", + "music-events-nightlife", + "nonprofit-activism", + "food-drink" + ], + "avoid_sectors": [ + "healthcare-clinical", + "enterprise-saas", + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Tabloid_(newspaper_format)" @@ -3270,6 +3691,7 @@ }, { "id": "trading-floor", + "temperaments": ["technical-precise", "nocturnal-social"], "name": "Trading Floor / Financial Terminal", "origin": "Bloomberg Terminal, 1980s-present", "one_line": "Black-background dense multi-panel layout where color means only one thing: red or green delta.", @@ -3349,6 +3771,16 @@ "children's products", "luxury editorial brands" ], + "sectors": [ + "finance-insurance", + "dev-tools", + "enterprise-saas" + ], + "avoid_sectors": [ + "wellness", + "kids-education", + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Bloomberg_Terminal" @@ -3356,6 +3788,7 @@ }, { "id": "vintage-analog", + "temperaments": ["heritage-craft", "warm-domestic", "nocturnal-social", "natural-raw"], "name": "Vintage Analog / Retro Film", "origin": "Kodachrome-era print and 8mm film, 1960s-70s photography", "one_line": "A warm, lifted-black color grade and physical print-border framing evoke Kodachrome-era photography.", @@ -3435,6 +3868,19 @@ "dev tools", "clinical/medical products" ], + "sectors": [ + "hospitality-travel", + "culture-arts", + "media-publishing", + "food-drink", + "nature-outdoor", + "wellness" + ], + "avoid_sectors": [ + "finance-insurance", + "dev-tools", + "healthcare-clinical" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Kodachrome" @@ -3442,6 +3888,7 @@ }, { "id": "pharmaceutical-clinical", + "temperaments": ["technical-precise", "civic-calm"], "name": "Pharmaceutical / Clinical", "origin": "Medical device and clinical-trial documentation", "one_line": "Cool desaturated blue-grey and literal iconography make regulatory fine print a visible, respected element.", @@ -3524,6 +3971,15 @@ "gaming", "luxury/fashion brands" ], + "sectors": [ + "healthcare-clinical", + "finance-insurance", + "engineering-technical" + ], + "avoid_sectors": [ + "gaming", + "fashion-beauty-luxury" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Package_insert" @@ -3531,6 +3987,7 @@ }, { "id": "field-guide-naturalist", + "temperaments": ["natural-raw", "heritage-craft", "warm-domestic", "technical-precise"], "name": "Field Guide Naturalist", "origin": "Roger Tory Peterson's field guide system (1934-present) and Audubon's ornithological plate tradition (1827-1838)", "one_line": "Audubon/Peterson plates with arrow-pointed field marks and a specimen-label system replace hero photography with observed diagrams.", @@ -3613,6 +4070,18 @@ "luxury fashion", "gaming" ], + "sectors": [ + "nature-outdoor", + "kids-education", + "engineering-technical", + "hospitality-travel", + "wellness" + ], + "avoid_sectors": [ + "finance-insurance", + "fashion-beauty-luxury", + "gaming" + ], "confidence": "high", "sources": [ "https://en.wikipedia.org/wiki/Peterson_Field_Guides", @@ -3621,6 +4090,7 @@ }, { "id": "nautical-chart", + "temperaments": ["heritage-craft", "natural-raw", "technical-precise"], "name": "Nautical Chart", "origin": "Admiralty chart conventions (UK Hydrographic Office, 19th c.-present) and NOAA nautical chart standards", "one_line": "Admiralty-chart soundings, a compass rose, and a title block turn a page into a navigational document.", @@ -3703,6 +4173,15 @@ "children's products", "luxury fashion" ], + "sectors": [ + "maritime-logistics", + "nature-outdoor", + "sports-fitness" + ], + "avoid_sectors": [ + "kids-education", + "fashion-beauty-luxury" + ], "confidence": "medium", "sources": [ "https://www.admiralty.co.uk/charts", @@ -3711,6 +4190,7 @@ }, { "id": "japanese-consumer-electronics-80s", + "temperaments": ["technical-precise", "nocturnal-social", "playful-bright"], "name": "Japanese Consumer Electronics (1980s)", "origin": "Japanese consumer electronics industrial design and print catalogs — Sony, Casio, Yamaha, Technics, 1979-1989", "one_line": "Sony/Casio/Yamaha spec-sheet graphics: metallic grey bodies with exactly one signal color calling out the one feature that matters.", @@ -3793,6 +4273,17 @@ "children's products", "luxury fashion" ], + "sectors": [ + "industrial-hardware", + "retail-ecommerce", + "music-events-nightlife" + ], + "avoid_sectors": [ + "wellness", + "kids-education", + "fashion-beauty-luxury", + "food-drink" + ], "confidence": "verify", "sources": [ "https://en.wikipedia.org/wiki/Walkman", @@ -3801,6 +4292,7 @@ }, { "id": "video-game-menu", + "temperaments": ["playful-bright", "nocturnal-social", "technical-precise"], "name": "Video Game Menu (PS2 / Persona / Splatoon era)", "origin": "Late-PS2-through-PS4-era Japanese console game UI design — Persona series (Atlus) and Splatoon (Nintendo), 2006-2020s", "one_line": "Diagonal panels and kinetic type stack into a UI that moves like it's alive.", @@ -3884,6 +4376,16 @@ "healthcare/clinical products", "luxury editorial brands" ], + "sectors": [ + "gaming", + "music-events-nightlife", + "sports-fitness" + ], + "avoid_sectors": [ + "enterprise-saas", + "healthcare-clinical", + "fashion-beauty-luxury" + ], "confidence": "medium", "sources": [ "https://ousiadroid.medium.com/what-you-can-learn-from-persona-5s-ui-design-4a4a646245b1", @@ -3892,6 +4394,7 @@ }, { "id": "illuminated-manuscript", + "temperaments": ["heritage-craft", "avant-garde"], "name": "Illuminated Manuscript", "origin": "European illuminated manuscript tradition, monastic scriptoria, c. 500-1500 CE", "one_line": "Rubricated headings, versal initials, and marginalia turn the page edge into an active decorative field.", @@ -3975,6 +4478,16 @@ "gaming", "fast consumer apps" ], + "sectors": [ + "culture-arts", + "media-publishing", + "legal-public", + "fashion-beauty-luxury" + ], + "avoid_sectors": [ + "enterprise-saas", + "gaming" + ], "confidence": "high", "sources": [ "https://www.britannica.com/topic/rubrication", diff --git a/plugins/design/skills/vary/evaluator-prompt.md b/plugins/design/skills/vary/evaluator-prompt.md index 0770584..7706283 100644 --- a/plugins/design/skills/vary/evaluator-prompt.md +++ b/plugins/design/skills/vary/evaluator-prompt.md @@ -9,13 +9,15 @@ You are reviewing a UI design you did not build and have never seen before this ## What you have (this is everything) -1. **The contract block**: an HTML comment beginning `SEED KEY:` inside the build, printed by an external roll and echoed verbatim. It names WORLD, ARCHETYPE, STRATEGY, PALETTE SEED, DISPLAY FONT, BODY FONT, MOTION MOMENT, GRAMMAR rules, MATERIALS, ANTECEDENTS, REFUSE, COSTUME TELL, and three model-authored lines: SCENE, FIRST VIEWPORT, THESIS. A GROUNDED ANTECEDENT line may be present. +1. **The contract block**: an HTML comment beginning `SEED KEY:` inside the build, printed by an external roll and echoed verbatim. It names WORLD, ARCHETYPE, STRATEGY, PALETTE SEED, DISPLAY FONT, BODY FONT, MOTION MOMENT, GRAMMAR rules, MATERIALS, ANTECEDENTS, REFUSE, COSTUME TELL, and three model-authored lines: SCENE, FIRST VIEWPORT, THESIS. It may also carry CONCEPT, FACTS, FUNCTIONAL RESKIN, SIGNATURE DEVICE, HERO TREATMENT, DIRECTIVES and a model-authored SIGNATURE GRAPHIC line; audit each as its own row. A broken DIRECTIVE (the user's own words, e.g. "no illustrations") is a structural BROKEN, which means REBUILD. Precedence, highest first: truth (no invented facts); accessibility; user directives; existing brand or design system; platform conventions; the rolled contract; treatment-specific bars and skill defaults. 2. **The build source** (paths appended below). Read every file in full. 3. **A `check.mjs` JSON report** (path appended below) with mechanical results: contract presence, contrast, focus-visible, states, reduced motion, image dimensions, font weights, arbitrary Tailwind values, banned phrases, template tells. 4. **Screenshots**, if paths are appended. If none, say so wherever a check needs one; never guess what an image would show. 5. **The original brief**, verbatim. +6. **The project dossier** (`dossier.json`), the client's own facts, audience and refusals, when appended. +7. **An optional SIBLINGS block**: the contract blocks of other variants built for the same project. -You do NOT have, and must not ask for: the generator's rulebook, the catalogs it rolled from, or any prior review. If any of those leak into your context, disregard them. Grading against a generic checklist instead of this build's own printed contract is the failure this review exists to prevent. +You do NOT have, and must not ask for: the generator's rulebook, the catalogs it rolled from, or any prior review. If any of those leak into your context, disregard them. The dossier is evidence about the client and may be used. Grading against a generic checklist instead of this build's own printed contract is the failure this review exists to prevent. ## Bias warning — read before anything else @@ -52,14 +54,29 @@ Read the `check.mjs` JSON. Spot-check two or three cited lines against the sourc 1. **Silhouette**: describe the page as blocks, lines and circles only. Does a hierarchy of importance survive, or would the silhouette fit five competitors? Skeleton reuse fails here regardless of skin. 2. **Competitor-swap**: substitute a direct competitor's name into the headline and primary action, verbatim. Still reads fine → FAIL (no product-specific claim). 3. **One memorable thing**: name one *specific* element you would recognise this build by in a week. A category label ("a fintech dashboard") → FAIL. -4. **Category-alone guess**: before citing the build, state what a competent designer would predict the palette, type and layout to be from the product category alone. Compare. Divergence → PASS. Note: a `WORLD: canon` contract is exempt from this test and from test 5; judge it on craft and the peers it named. +4. **Category-alone guess**: before citing the build, state what a competent designer would predict the palette, type and layout to be from the product category alone. Compare. Divergence from the category guess is a PASS only when the divergent choice traces to a cited FACT or to the WORLD's grammar. Divergence with no source is noise and fails. Note: a `WORLD: canon` or `WORLD: system: ` contract is exempt from this test and from test 5; judge it on craft and the peers it named. Axes locked by a dossier directive are excluded from test 5 (same-designer). 5. **Same-designer test** against the five recognisable AI clusters, each MATCH / NO-MATCH with cited hex, font names or layout terms: cream ground + high-contrast serif + terracotta/signal-red accent; near-black + one neon + glowing edges; indigo/purple SaaS gradient default; bento + glassmorphism; Space Grotesk / Satoshi / Fraunces / Geist-class "startup" pairing. Two or more axes matching one cluster → FAIL. Distinctiveness PASS requires 4 of 5. A distinctiveness FAIL does not by itself force REBUILD; it is one input to Step 6, weighed against whether the contract itself was safe. +### Step 5b — Project specificity (PASS/FAIL per test with evidence; never a 1-5 score) +Distinctiveness asks whether the build differs from the category. This step asks whether it belongs to THIS project. A `WORLD: canon` or `WORLD: system:` contract is exempt from distinctiveness but not from this step. +- **P1 Subject-swap**: name a same-category business in another town. List every graphic, headline and control that would become false for it. Fewer than 3 items → FAIL. This is stronger than the headline-only competitor swap in Step 5. +- **P2 Signature element, per HERO TREATMENT**: name it, cite the source file and the dossier fact ids it encodes, and confirm it appears at hero scale in the first viewport and at least one other scale. `scene`/`pattern`: the drawn or generated device at hero scale. `photography`: the named dossier assets plus the art-direction rule (crop, sequence, grade). `product-ui`: the real capture and the workflow it shows. `typography`: the lettering idea and the fact it carries. `data-visual`: the chart and its data. `utility`: judged at detail scale (numerals, empty states, icons, copy); a FAIL here is FIX, never REBUILD. FAIL when there is none, or when it is abstract decoration (gradient blob, mesh, noise-only hero, stock icon set). No HERO TREATMENT line: treat as `scene` for persuade/experience. +- **P3 Graphics carry information**: at least one graphic encodes real dossier data (a topping list, DN range, hours, address). Cite the element and the fact. Also PASS when the hero visual is the client's own dossier asset, or when a typography or data-visual element (lettering, chart, specimen) encodes a dossier fact. N/A under utility. Otherwise FAIL. +- **P4 Trade credibility**: read the dossier audience and decide whether that paying customer would find the look credible for this trade, and whether reading surfaces (prices, forms, body copy) stay legible. A FAIL names the specific element, not "feels off". Example of a FAIL: a riso zine for an HVAC firm whose customers read it as "uber unprofessional". +- **P5 Art craft (screenshots)**: no banned forms (faces drawn on objects such as food or products, mascots, emoji-blobs; real photographed faces are fine); subjects distinguishable at thumbnail size; style consistent with the world's grammar (ink count, stroke, no gradients if refused); every item in `critique-r2.json`, if supplied, closed. Photography and product-ui add craft items: fidelity (no filter, blend mode or overlay on client images), natural resolution at least 1.5x rendered, crop (focal subject not cut by the nav). No screenshots → UNVERIFIABLE → RECAPTURE. +- **P6 Richness against the benchmark** (only when HERO TREATMENT = scene; for any other treatment quote the fold rows the gate table marks as gated for it, and a row printed `[INFO]` is not gated; native platforms ios/android: device screenshots, fold rows N/A): compare the first-viewport screenshot to the benchmark descriptions (ara-bar: the restaurant at night, 10+ drawn objects, 3+ depth layers, a light source, brand painted on the sign, real phone number in the art; hitataekni: a full blueprint sheet with grid, coordinates, traced unit, dimension lines, callouts, a title block holding the real name, year, address and phone). Score: (a) the hero is a scene or sheet, not a boxed diagram, single icon, empty grid or text-only (drawing covers >=55% of the viewport at 1440x900, >=45% at 390x844; >=5 distinct subject objects across >=3 depth layers; headline set into the art; >=2 dossier facts rendered inside the art); (b) richness floor: filled shapes, one consistent light source, texture, people or traces of use, 4 to 7 inks; (c) page graphic budget: >=3 more drawn plates down the page, each tied to a different fact group, plus micro-scale marks in the same hand. Run `node scripts/fold-check.mjs --mode --treatment ` (or `check.mjs --fold=`) and cite its metric lines; its FAILs are evidence, the eye decides what counts as a distinct object. Under the scene treatment a text-only or boxed-diagram hero in persuade is a FAIL. List what is missing (objects, layers, light, texture, facts-in-art). + +- **P6 fold checks** (the seven rows and the benchmark comparison apply only when HERO TREATMENT = scene; under other treatments RECAPTURE for a missing row only if that row is gated) — Named fold checks the evaluator must report, each PASS/FAIL with the `fold-check.mjs` line quoted (a missing line is a RECAPTURE for a scene build with a built page): (1) **Text collisions** (`collisions-desktop`, `collisions-phone`): no visible text box, HTML or svg ``, overlaps another or is more than 30% covered by an opaque later-painted element; name the two texts on a FAIL. (2) **Dead zone** (`dead-zone`): no near-uniform-colour rectangle larger than 20% of the desktop fold; cite its size and position. (3) **Card over art** (`card-over-art-desktop`, `card-over-art-phone`): no translucent (alpha < 0.95), backdrop-filter or solid-background element with text covering more than 6% of the hero art area; headline, phone, hours and the primary action live on drawn surfaces inside the art (a sign, plate or title block in the svg is fine), never on a card floating over it. (4) **Art down the page** (`art-down-page`): at least 60% of the sections below the fold (contact/footer excluded) carry a drawn plate at least 40% of their width. (5) **Full bleed** (`full-bleed-desktop`, `full-bleed-phone`): the hero art box spans at least 95% of the viewport width with margins of at most 3% each side; a framed picture, card or polaroid with page margins fails; name the art box and its margins. (6) **Rendering depth** (`rendering-depth`): in the desktop hero art area the luminance range p95 - p5 is at least 90 and at least 5 of 16 luminance bins hold at least 2% of the pixels; flat fills with thin uniform strokes read as clip art. The flat-block texture number is printed for the report but not gated. (7) **Plate size** (`plates-size`): at least 50% of the below-fold section plates are at least 35% of the section width, at least 280px tall and carry at least 3 value steps; name the thin, small or pale ones. Under the scene treatment any of these seven FAILing is a Specificity FAIL under P6 and is listed in the fix batch; under other treatments only the rows the gate table marks as gated count and the others are informational. + +Specificity PASS requires 5 of 6. Two or more FAILs, a P2 FAIL (except utility), or a P6 FAIL under the scene treatment → REBUILD, written as "promised , shipped ". No dossier supplied → say so; P1 to P4 and P6 are then judged against the brief only and flagged lower confidence. + +**Sibling Variety** (only when a SIBLINGS block is appended): concept angle, world, archetype and device technique must each differ from every sibling, and the silhouettes must not be confusable. Any pair matching on 2 or more axes → FIX, naming which variant to re-roll. + ### Step 6 — Disposition (computed, not a fresh gut call) -- **RECAPTURE**: `SEED KEY:` missing, or evidence (source, screenshots) insufficient to complete Steps 2-5. Say exactly what is missing and how to get it. -- **REBUILD**: any structural BROKEN (ARCHETYPE, a GRAMMAR rule, FIRST VIEWPORT, THESIS "promised X, shipped Y"), or distinctiveness failed 2+ tests. Name the broken promise and what must change structurally. +- **RECAPTURE**: `SEED KEY:` missing, or evidence (source, screenshots) insufficient to complete Steps 2-5b, or a persuade/experience build arrived with no screenshots. Say exactly what is missing and how to get it. +- **REBUILD**: any structural BROKEN (ARCHETYPE, a GRAMMAR rule, FIRST VIEWPORT, THESIS "promised X, shipped Y"), or distinctiveness failed 2+ tests, or project specificity failed 2+ tests, or no signature element was found (P2, except utility), or a DIRECTIVE was broken. Name the broken promise and what must change structurally. - **FIX**: only cosmetic BROKENs and/or mechanical FAIL/WARNs, structure sound, distinctiveness ≥4/5. List every fix in one batch: this is the only fix round that will happen. - **SHIP**: nothing above triggered. @@ -86,6 +103,24 @@ Distinctiveness PASS requires 4 of 5. A distinctiveness FAIL does not by itself |---|---|---| Distinctiveness: PASS | FAIL (n/5) +## Project Specificity +| Test | Verdict | Evidence | +|---|---|---| +| P1 Subject-swap | | false items: … | +| P2 Signature element | | treatment, name, file, fact ids, scales | +| P3 Graphics carry information | | element + fact | +| P4 Trade credibility | | element | +| P5 Art craft | | screenshot region | +| P6 Richness vs benchmark | | (scene only; else gated fold rows) fold-check lines; missing objects/layers/light/texture/facts-in-art | +Specificity: PASS | FAIL (n/6) + +## Contract rows +Hero treatment: +Directive compliance: + +## Sibling Variety +(only when SIBLINGS present: one row per sibling, axes matched, which variant to re-roll) + ## Disposition: SHIP | FIX | REBUILD | RECAPTURE - FIX → the complete batched list - REBUILD → which structural promise broke ("promised X, shipped Y") and what must change @@ -93,4 +128,4 @@ Distinctiveness: PASS | FAIL (n/5) ``` ## Final reminder -Do not soften a structural gap into a polish note. Do not let clean contrast numbers talk you into leniency on distinctiveness: craft and sameness are independent failures, and a beautifully executed identical design still fails this review's purpose. Conversely, do not penalise a genuinely unusual choice because it is unfamiliar to you: name the specific problem with it, or pass it. +Do not soften a structural gap into a polish note. Do not let clean contrast numbers talk you into leniency on distinctiveness or on project specificity (a different look is not the same as a look that belongs to this client): craft and sameness are independent failures, and a beautifully executed identical design still fails this review's purpose. Conversely, do not penalise a genuinely unusual choice because it is unfamiliar to you: name the specific problem with it, or pass it. diff --git a/plugins/design/skills/vary/references/art-crew.md b/plugins/design/skills/vary/references/art-crew.md new file mode 100644 index 0000000..42df9ba --- /dev/null +++ b/plugins/design/skills/vary/references/art-crew.md @@ -0,0 +1,65 @@ +# Art crew and critic + +One session that plans, draws every plate, builds and polishes produces thin art and template sections below the fold. The benchmarks came from a dedicated art process with many look-and-fix rounds. This file splits that process: illustrator subagents draw the plates in parallel (Step 3a), a fresh critic scores the built page (Step 4b). `graphics.md` is the engine; this file is the dispatch, the checklists and the rubric. + +## 1. Dispatch (Step 3a, after the art brief) + +Dispatch only for treatment = scene (pattern: the lead draws, no crew). Use the Agent tool (`subagent_type: general-purpose`, `model: sonnet`, `mode: bypassPermissions`), all illustrators in ONE message so they run in parallel: one for the hero scene, one per section plate, capped at 6 (merge the smallest plates into one illustrator beyond that). Each illustrator is handed ONLY: + +- the absolute path of `.vary/art-brief.md`; +- its plate's row from the brief (subject, fact ids and facts, skeleton, size at desktop and phone, objects-table rows); +- the absolute paths of the style object file and the core kit (`art/styles.js`, `art/core.js`), and of `art/finish.js` and `art/objects.js` (the lead creates both before dispatch; illustrators import them, never edit them); +- the path of this file, and the output path `art/.js` it owns. Besides that file it may write only scratch renders (a harness html or script and PNGs) under `.vary/art-shots//`, named `desktop.png` and `phone.png` at the real plate sizes from its row. + +The lead does not redraw. It integrates what comes back. If the Agent tool is unavailable, the lead draws the plates itself in the same order (hero, plates, micro) and runs section 3 on each. + +## 2. Illustrator brief (copy, fill the braces) + +``` +You are an illustrator on a design team. Draw ONE plate: {PLATE NAME}. +Read: {ART_BRIEF_PATH}, {CORE_PATH}, {STYLE_PATH}, {FINISH_PATH}, {OBJECTS_PATH}, and {ART_CREW_ABS_PATH} section 3. +Your row: {SUBJECT / FACTS / SKELETON / SIZES}. +Write {OUT_PATH} and scratch files under {SHOTS_DIR} only: {OUT_PATH} is a pure function (S, r, W, H) -> svg string, seeded, colours only via swatch names from the style object. +Shared with the other plates (do not change): ground line at {GROUND_Y}% of plate height, key light from {LIGHT_DIR}, glow {GLOW_SOURCE}, grain {GRAIN}, the style object swatches. The lead fills these from the brief's shared plate frame. +Loop: render at real size ({W}x{H} desktop and phone) with Playwright (or rsvg-convert) to {SHOTS_DIR}/desktop.png and phone.png, Read the PNGs, run the checklist, list what is wrong in concrete terms, fix, re-render. Do 2 render-and-fix rounds (3 for the hero plate) and stop within about 20 minutes; a finished plate beats a perfect one. +Return: the module path, the final PNG path(s), the checklist with pass/fail per line, and any open item. Do not touch any other file. +``` + +## 3. Self-critique checklist (every render) + +1. Objects, not boxes: each object has a real silhouette and its parts (roof line, bogies, spine, headband), never a box or pill standing for a thing. +2. Posture: every person has a stance or gesture, a cast shadow, hands doing something; no pill figures. +3. Shared ground and light: objects stand on one ground line, cast shadows fall the same way, one key light and one glow; nothing floats. +4. Values: at least 3 steps per material, the brightest value on the focal fact. +5. Texture: grain 3-8%, hatching, halftone or paper; no flat fills with thin uniform strokes. +6. Facts in the art: the plate's dossier facts are drawn or lettered in it (numbers, names, dates). +7. No dead space: no empty region over 20%; no tool or prop without context (a surface, a hand, a trace of use). +8. Craft: no text clipped by the svg edge, no label over an object, every dial, axis or scale monotonic with real labelled values, every callout leader ends on its object. +9. Size: the plate is at the size in the brief (at least 40% of section width, 280px tall). + +## 4. Integration rules (lead) + +- One shared ground line and one light direction across all plates; the brief's shared plate frame (graphics.md §7 step 1, item 10) fixes both and the lead rejects a plate that breaks them. +- Plate sizes come from the brief; do not rescale a plate to fit a layout, change the layout. +- Plates are composed unchanged. A needed fix goes back to the same illustrator (or is a one-line swatch or size edit); the lead does not redraw. +- Re-render the gallery once with every plate together and read it once, apply one fix pass, then start page code. + +## 5. Critic (Step 4b) + +A FRESH subagent (Agent tool, `general-purpose`, `model: sonnet`), never the builder, never one that saw the generating conversation. Give it the audience mood need line from the art brief (fills {MOOD_NEED}) and the contract's hero treatment (fills {TREATMENT}), plus: desktop and phone fold screenshots, desktop and phone full-page screenshots (absolute paths), the `.vary/dossier.json` facts, and the prompt below. Withhold SKILL.md and the other references. + +``` +You are a hard design judge. Read the screenshots at {PATHS} and the facts at {DOSSIER}. Score each 1-10: +- specificity: is every picture a function of this client's own facts, or could it be any client's? +- graphics: the signature visual, judged against the {TREATMENT} bar (scene: richness and finish of the drawings; otherwise the matching `graphics.md` §9-§12 checklist). +- craft: collisions, clipping, overlays, hidden content, scales, callouts. +- mood: does the concept, world and palette fit the audience's mood need ({MOOD_NEED} from the art brief, e.g. children: bright, safe, daytime; bereavement: calm, quiet, dignified; B2B lab: precise, trustworthy)? A world that breaks it scores mood 1-3 and is a FAIL regardless of the other scores. +- headline: is there a real headline, value line and action in the fold (scene: on a drawn surface)? +- wow: would a stranger remember it? +Anchors show the quality level, not the subject matter: a client from a different domain is judged against the same level of detail, not against pizza or air handlers. 9-10: for scene, {PASTE the "What was drawn" text of graphics.md §0b rows 1, 2, 5 and 7}; for other treatments, {PASTE the matching graphics.md §9-§12 bar and checklist}. 4-5: pill figures; boxes standing for objects; template sections (scene only: card left, table right, pill CTA, divider, repeated); text clipped by the nav, viewport or svg edge; dead space; a dark overlay left over the hero; a hero missing in the full-page shot. +Judge only what the screenshots show; quote what you see. "mean" is the arithmetic mean of the four scores specificity, graphics, craft, wow (mood is reported separately) to one decimal. If an image is unreadable, return {"error":"unreadable: "} instead of guessing. Return JSON: {"specificity":n,"graphics":n,"craft":n,"wow":n,"mood":n,"mood_fail":bool,"mean":n,"fixes":[5 items, highest impact first, each naming the element and the concrete change]}. +``` + +Mood FAIL: if `mood_fail` is true the lead stops building on this world, re-rolls with `--lock concept=` (a concept that serves the mood) and rebuilds before any other fix. Blocking craft fails (fold-check rows collisions, clipped-text, hidden-content, stray-overlay, duplicate-content, page-overflow, meta-copy, headline-on-fold (title-on-fold for operate/read), plus headline-in-scene for scene only) must be PASS before shipping; the time budget never excuses them. + +Loop: the lead applies the 5 fixes (plates go back to their illustrator), rebuilds, re-shoots, and spawns a NEW critic. Stop when the mean is 7.5 or more, or after 2 critic rounds. Open items go into NOTES.md as `critic: open — `; the page ships. If no subagent tool exists, the lead scores itself against the same rubric, caps the outcome at FIX, and says the independent critic did not run. diff --git a/plugins/design/skills/vary/references/build-craft.md b/plugins/design/skills/vary/references/build-craft.md index 3e2866e..825ab62 100644 --- a/plugins/design/skills/vary/references/build-craft.md +++ b/plugins/design/skills/vary/references/build-craft.md @@ -5,6 +5,8 @@ description: Loaded AFTER the contract is written and BEFORE any UI code. How to # Build craft +When a user directive collides with any rule here, the directive wins except on accessibility. + The contract decided *what*. This file is *how*, at the level a design director checks. Commit to the world first; every rule below is a floor, not a brake. When a rule and the world's grammar collide, the grammar wins on aesthetics and the floor wins on accessibility, states and keyboard. ## 1. Palette from the seed @@ -30,10 +32,10 @@ The seed is one OKLCH anchor with a role hint. Compose five roles around it: **g Rules that hold under every strategy: -- **Tint the neutrals** toward the seed hue (C 0.005-0.02). Pure grey reads as un-designed. Over ~C 0.03 it stops being neutral. +- **Tint the neutrals** toward the seed hue (C 0.005-0.02). Pure grey reads as un-designed (unless a directive or the brand specifies pure black/white). Over ~C 0.03 it stops being neutral. - **Text on colour is never grey.** Derive it from the field's hue at the far end of L. On saturated mid-luminance fills (L 0.42-0.78, C ≥0.08) use white, even where the maths permits dark: saturated colours look brighter than they measure (Helmholtz-Kohlrausch). - **Gamut is a default engine.** Blue/purple tolerate the widest chroma across L; yellow cannot be dark and saturated; cyan caps near C 0.145. Check every hue's ceiling at its L before committing a number, or the palette drifts toward purple on its own. -- **Dark mode is its own system.** Base L 0.08-0.12 with a faint tint, never `#000`. Elevation by +3-6% L per tier (5 tiers to about L 0.28), not shadows. Desaturate accents by hue: warm −10-15%, cool −20-30%; yellow shifts toward amber instead of desaturating. +- **Dark mode is its own system.** Base L 0.08-0.12 with a faint tint, never `#000` (unless a directive or the brand specifies pure black/white). Elevation by +3-6% L per tier (5 tiers to about L 0.28), not shadows. Desaturate accents by hue: warm −10-15%, cool −20-30%; yellow shifts toward amber instead of desaturating. - **Contrast**: WCAG 2 as the legal floor (4.5:1 body, 3:1 large and UI); on coloured fields and dark mode aim for APCA Lc ≥75 body, ≥60 UI, ≥45 large headlines. `check.mjs` computes the literal pairs; variable cascades need a screenshot. - Every colour is a token; no raw hex in components. @@ -75,12 +77,12 @@ Load exactly what the contract names. ## 5. States, copy, and the parts nobody draws -- Nine states for every interactive element and every view: default, hover, focus-visible, active, disabled, loading, error, success, empty. A state you cannot describe does not exist. Empty and error states are designed screens, not a sentence in grey. +- Nine states for every interactive element and every view (hover is N/A on touch-only platforms): default, hover, focus-visible, active, disabled, loading, error, success, empty. A state you cannot describe does not exist. Empty and error states are designed screens, not a sentence in grey. - `:focus-visible` ring at ≥3:1 against its surroundings, distinct from hover; tab order equals reading order; focus trapped in modals and returned on close. - Touch targets ≥44px; colour is never the only signifier. -- **Browser surfaces**: `::selection`, `caret-color`, scrollbar colours, underline offset/thickness, form control accent, the focus ring. These ship with browser defaults that belong to no design; theming them is the cheapest signal that the page was built rather than assembled. +- **Browser surfaces (web only)**: `::selection`, `caret-color`, scrollbar colours, underline offset/thickness, form control accent, the focus ring. These ship with browser defaults that belong to no design; theming them is the cheapest signal that the page was built rather than assembled. - Copy: buttons name their outcome ("Send invoice", not "Submit"); errors say what happened and what to do. Run the competitor-swap test on the headline: if it still reads true with a rival's name, it made no claim. No "seamless", "unlock", "revolutionise", "the future of", "trusted by 10,000+". Em dashes: fewer than 7 per 600 words. -- Every `` has `width` and `height` (or `aspect-ratio`); `loading="lazy"` below the fold; real product imagery over stock; no emoji as icons; one icon system, one stroke weight. +- Every `` has `width` and `height` (or `aspect-ratio`); `loading="lazy"` below the fold; photos in a scene or pattern treatment transformed into the world's material (multiplied onto paper, greyscaled, edge-traced, one-ink halftone); under photography or product-ui, and for identity assets (headshots, partner logos, product screens), the image ships faithfully with art direction by crop, sequence, size and grade only (`graphics.md` §2o); drawings from `graphics.md` where no photo exists; no emoji as icons; one icon system, one stroke weight. ## 6. Craft floor — verify on the built result @@ -95,15 +97,30 @@ Run these together in one inspection round on the real render (desktop and mobil 7. Browser surfaces themed. 8. Copy: outcome-named controls, product-specific claims, zero AI vocabulary. 9. Every brief requirement present and findable within seconds. +10. (scene) The signature device is visible at hero, divider and micro scale; the art gallery passed two critique rounds. +11. (scene) Art down the page: every section below the fold (contact and footer excepted) has its own drawn plate at 50% of the section's width or more and 280px tall; every catalogue or menu item has its own drawing. A section of only cards, text or a form is a defect. +12. (scene) Type in the art: no frosted, translucent or blurred card, chip row or SaaS button pair over the hero art; the headline is a real `

`; it, the phone, hours and the primary action sit over in-scene surfaces (graphics.md §1b; a sign wrapped in an `` with a focus ring and an accessible name) never on a solid card or band over the scene. +13. (scene) Collisions and dead zones at 1440x900 and 390x844: every text (HTML and svg) is fully visible with no overlap with another text or a drawn object; no empty region over 20% of the hero; the finish pass (value plan, grain) is present. +14. No duplicated content: each fact (price list, hours, address, offer) appears once in the page body; the footer may repeat contact details only. Exempt: amounts inside a `
`, anything marked `data-repeat-ok`, and `pre`/`code` blocks. Section skeletons differ from one section to the next ((scene) full-bleed plate, catalogue, process strip, map or plan, quote over art, ticket or label as an object); no plate is a labelled box with words in it. +15. Headline on the fold: the first viewport carries the business name AND a one-line value proposition, a real `

` plus value line and primary action, never svg-only text, never omitted. (scene) It is positioned over a drawn surface in the scene (sign, board, title block, ticket; graphics.md §1b), never on a floating card. +16. Time: the page ships. Inspect once, fix in one batch; do not reopen the art loop during the build. +16b. The element the brief cannot do without (every treatment): name it in NOTES.md before building (the trend a supervisor asked for, the safety line on every onboarding screen, the photos that must carry the page, the install command) and build it first, at full strength. A required element that is missing, faked or reduced to a note fails the build. +16c. Loudest equals most important (every treatment): the element with the most visual weight (size, colour, position) is the one the user most needs: the problem right now on a dashboard (never a routine next departure), the product itself on a SaaS hero (never a stat card), the install command on a docs home, the safety line in a health app. Alert colour and the biggest type go there and nowhere else. +16d. Body copy is set in a text face (every treatment): the display face is for display sizes only; paragraphs, table cells, captions and form labels use a face built for reading at 16-18px (never an extended, wide-tracked, condensed or monospace display face for prose; mono only for code and figures). Captions stay at 13px or more at 4.5:1. Nav text over a photo or gradient sits on a scrim or a solid bar that gives 4.5:1. +17. Phone width (every treatment): at 390px nothing scrolls the page sideways and nothing is clipped. Wide grids and tables scroll inside their own container or reflow into a list; the header at 390px is one row of at most 64px (brand plus a menu button or at most three short links), so the nav never eats the fold; text keeps a 16px side gutter; device frames shown side by side stack. +18. The client's page only (every treatment): no designer notes in the build. No reference codes, "signature device" or "motion" labels, design-review annotations, keys explaining how to read the page, or internal tags. A chart's text equivalent is a real table or visually hidden, captioned in the client's words. +19. (photography) The photos carry the page: the hero photo is full-bleed or fills at least 60% of the first viewport; no frames, hatching, stitched borders or ornament around photos; products appear as photo slots, never as drawn stand-ins. When photos are described but not supplied, render each slot at its final size and crop as a quiet neutral frame (one flat tone from the palette's ground, no gradient, no fake shadow, no drawn object) with the file name and the client's description set inside it as a visible caption ("hero.jpg · the Oriel tote on a stone bench, morning light"), so the client sees exactly which photo goes where and the layout is right the moment the file lands. +20. (product-ui, data-visual, utility) Native conventions and honest data: app screens use the platform's own controls (iOS: a full-width primary button above the home indicator, a standard nav bar); a dashboard plots only data the dossier has, except when the brief itself is a mock with sample data and asks for a view the data does not cover (a 4-hour trend): then build that view with sample values consistent with every given number, labelled "sample data" in the chart caption, never skipped and never replaced by a "will plot here" note; status colour stays calm (one alert colour, on the problem only, not full-width slabs); the first screen answers the user's first question without scrolling. ## 7. Refuse — the category defaults this world did not choose These are defaults, not bans: the contract's grammar can earn any of them back explicitly. Reaching for one when the axis was free means no decision was made; rewrite the element rather than soften it. - Same-size icon-plus-heading-plus-text cards as page structure; nested cards. -- The hero-metric template (big number, small label, sparkline). +- The hero-metric template (big number, small label, sparkline) (persuade/experience; an operate surface may use a KPI summary). - Eyebrow/kicker labels that repeat what the heading says. - Gradient text; glass as decoration; a coloured left border thicker than 1px as a callout; hard offset block shadows outside a world that is actually neobrutalist. - Mono as costume; a system display face as the display voice of an own-world page; emoji or Unicode glyphs as icons. - Light or dark chosen by category instead of by the scene sentence. +- Abstract decoration where a drawing of the subject belongs (gradient blob, mesh, noise-only hero, stock icon set) (persuade/experience; an operate surface uses one consistent icon library). - The waterfall: hero → logo strip → three features → testimonial → pricing → FAQ → CTA. diff --git a/plugins/design/skills/vary/references/concepts.md b/plugins/design/skills/vary/references/concepts.md new file mode 100644 index 0000000..c5a6fd9 --- /dev/null +++ b/plugins/design/skills/vary/references/concepts.md @@ -0,0 +1,106 @@ +--- +name: concepts +description: Step 0b of vary and design. How to turn the dossier into 4-7 grounded concepts (each the site as a physical thing from this trade or place, each a different angle on the business), written to .vary/concepts.json for roll.mjs to pick from. Includes the physical-object method and worked examples from two client sites. +--- + +# Concepts: the site as a thing from this trade, or the client's own pictures, product or numbers + +A concept is the site as a thing from this trade, or this client's own pictures, product or numbers set with a point of view. It is a one-sentence idea in which the site **is** that thing. The roll picks one of your concepts, so write every one as if it will win. + +- ARA (pizzeria and sports bar): "the site IS that room at night, a paper menu on a dark wooden table." +- Hitatækni (HVAC shop): the shop as a set of engineering drawing sheets. +- Hitatækni, a different roll: a picture book about where a building's air goes. + +## 1. Method + +**a) List the business's halves.** Who uses it, when, and for what. ARA: the cozy evening room, the family's Friday take-away, the sports bar on match night, the street-corner pride of a neighbourhood landmark. Each half is a different angle. Four concepts that are four palettes of one half are not four angles. + +**b) Apply the physical-object method to each half.** Ask: *what would this be as a physical artefact, and what did that world look like before the web?* + +1. **Name three real antecedents, not one.** The first is the cliché (bank → vault; fitness → gym poster; dashboard → glass cards). The good answer lives at #2 or #3. +2. **Extract a constraint, not a texture.** A ledger is not "beige and lined"; it forces every figure to reconcile in a paired column. A cockpit panel is flush, not floating. A rulebook numbers things because they are cross-referenced. The constraint becomes a layout rule. +3. **Include graphic traditions, not only objects**: the notation, publications, identity programmes, data graphics and interfaces this audience reads daily (a surveyor's field book, a racing programme, a broadcast lower-third, a pharmacy label, a shipping manifest). +4. Reject any antecedent that is the category's standard page or its predictable opposite. + +**c) Prefer the trade's own documents and hardware**: datasheet, drawing sheet, guest check, kitchen ticket, price tag, route card, work order, handbill. The dossier's `artefacts` list is the pool. + +**d) Re-skin the functional objects.** Re-skin functional objects on persuade/experience surfaces; operate surfaces keep conventional controls. Cart, confirmation, timetable, nav, buttons, empty and error states each become an object of the concept: ARA's cart is a guest-check pad and its confirmation a kitchen ticket on a rail; hitataekni D's buttons are corner-tick drafting symbols and its nav is bracketed. This is `functional_reskin`, and it is where a concept stops being a wallpaper. + +**e) Pick devices and worlds.** Each concept offers 2-3 signature graphic devices from the technique list in `graphics.md` §2, naming what each would draw from which facts, and 2-3 `world_hints` whose grammar fits the artefact. The roll picks one; write all of them at full strength. + +Worked method on thin categories: + +| Subject | Cliché to skip | Better antecedents | Constraint extracted | +|---|---|---|---| +| SaaS monitoring dashboard | glass KPI cards | control-room gauge panel; ship's bridge cluster; household electricity meter | gauges with tick-marked ranges, flush console grid, colour only for signal state | +| Invoicing tool | fintech blue + gradient button | double-entry ledger; carbon-copy invoice pad; rubber date stamp | hairline-ruled rows, right-aligned tabular amounts, a stub region for totals, stamped status | +| Dev-tool docs | three feature tiles over a sidebar tree | aircraft maintenance manual; board-game rulebook; lab notebook | numbered callouts cross-referenced from prose; flat numbered index instead of a nested tree | +| Fitness app | hero photo + "Start your journey" | boxing-gym chalk round timer; flip-digit scoreboard; coach's logbook | flip-digit counters, a dated ledger spine for history, no progress rings | + +A thin category simply has more `artefact` and `place` facts in the dossier and fewer `product` facts. + +## 2. Schema of `.vary/concepts.json` + +A JSON array. `roll.mjs --concepts` validates it and exits 2 on any failure below. + +```json +[{"id":"kvold","name":"Kvöld á ARA","angle":"the room on a dark evening", + "antecedent":"paper menu on a wooden table; waiter's guest check", + "treatment":"scene", + "facts":["F1","F7","F12"], + "temperament":"nocturnal-social","audience_mood":"hungry, social, evening: warm, loud-ish, convivial", + "functional_reskin":"cart = guest-check pad; confirmation = kitchen ticket on a rail", + "devices":[{"technique":"procedural-subject","what":"every pizza drawn from its menu topping list"}, + {"technique":"material-texture","what":"deckled paper, wood grain, ruled check"}], + "world_hints":["letterpress-wood-type","vintage-analog","mid-century-travel-poster"], + "memorable":"the menu lying on the table","p":0.35}] +``` + +| Field | Rule | +|---|---| +| `id` | unique, short, lowercase | +| `name` | what the concept is called in the contract | +| `angle` | a distinct audience moment, half of the trade, time of day or artefact; unique across the file (case-insensitive) | +| `antecedent` | the trade artefact it borrows | +| `temperament` | required, one of: heritage-craft, warm-domestic, civic-calm, technical-precise, playful-bright, natural-raw, nocturnal-social, avant-garde. The business's own register as the dossier shows it (a Victorian bindery is heritage-craft, a remote geothermal pool natural-raw), not the style you would like to use. `roll.mjs` removes every world whose `temperaments` exclude it, before the sector and hint narrowing; the wildcard never widens temperament | +| `audience_mood` | the mood the audience needs from this page, read from the dossier (children's dentist: bright, safe, daytime; funeral home: calm, quiet, dignified; B2B lab: precise, trustworthy; fish auction: cold, brisk, practical). The `temperament` must serve it, and a world that breaks it is a re-roll (`--lock concept=`) | +| `facts` | at least 3 ids that exist in `dossier.facts[].id`; at least one is visible in the device | +| `functional_reskin` | non-empty: cart, confirmation, nav, buttons in the concept's material | +| `treatment` | one of scene, pattern, photography, product-ui, typography, data-visual, utility; optional (otherwise derived from the dealt device); photography needs a usable photo asset, product-ui a screenshot or app path | +| `devices` | at least 2 for scene/pattern, 1 for the other treatments, 0 for utility; `technique` is one of: seeded-illustration, procedural-subject, traced-line, mechanism-diagram, annotated-photo, photo-direction, product-capture, scroll-scene, data-diagram, material-texture, print-process, typographic, generative-pattern, shader. With a `treatment` other than typography or utility, at least one device maps to it (photo-direction/annotated-photo to photography, product-capture to product-ui) | +| `world_hints` | required, 2-3 existing world ids (`roll.mjs` exits 2 with fewer than 2), never one whose `avoid_sectors` includes the dossier's sector. Take ids from `data/worlds.json` (the one permitted read of it: ids, names, sectors, `avoid_sectors` only) | +| `memorable` | the one element someone will remember | +| `p` | optional, 0-1; logged only, never used to drop a concept | + +## 3. Rules + +- **4 to 7 concepts**, each with a distinct angle. +- At least 3 facts each, and at least one fact must be visible in the device. +- None may be the category's standard page or its predictable opposite, unless a directive or `--canon` asks for it. +- At most one concept may use the business's name as its metaphor. +- **Treatment by signal, scene by default.** A `persuade` or `experience` page for a business, place or service with no usable photos, no product UI and no style directive gets drawn art: every concept is `scene`, or `pattern` for at most one. A plain type page or a lone chart is the failure here: a children's clinic, a funeral home or a fish market each needs a picture of its own world. Other treatments need a signal: a directive (the SKILL.md directive table), supplied photos (`photography`), a real product UI (`product-ui`), or a genre whose best examples do not illustrate (SaaS landing: `product-ui`; dashboard: `data-visual` or `utility`; docs: `typography` or `utility`; photo-led luxury: `photography`). Write the signal into the concept's `angle` when it is not `scene`. In those genres the signal binds every concept, not one: all concepts of a SaaS landing are `product-ui`, of a dashboard `data-visual` or `utility`, of a docs home `typography` or `utility`, of a photo-led brand `photography`; the angles differ, the treatment does not. A software product's landing page is `product-ui` even with no screenshots: record each screen the brief describes in `dossier.product.screens` and rebuild it in HTML from the facts, with real names and numbers (`roll.mjs` accepts that as grounding). Under a non-drawn treatment no concept replaces the genre's native form with a drawn conceit (a hardware rack for a SaaS, a notebook with leader lines for a quiet luxury brand, roman-numeral plates for an onboarding flow). +- Spread the devices: not every concept leads with an illustration engine. (Under the scene default the range comes from the devices and worlds, not from dropping the art.) Mix drawn, traced, diagrammatic, textural and typographic devices so the roll has real range. +- Every concept names 2-3 `world_hints`: worlds whose grammar suits the trade artefact. They define the pool the roll draws the world from (with worlds listing the sector) and are weighted 8x; at least one must list the concept's `temperament` or `roll.mjs` exits 2. Hints must fit the audience mood: no Bauhaus primaries for a calm clinic, no sunburst ticket for a quiet service. The roll still picks, you do not. +- Write every concept as if it will win. Never leave a weak "safe" concept as a decoy. +- **Test for each:** "Would this concept still be true for the competitor down the street?" If yes, rewrite it around a fact only this client has. +- **The signature fact leads.** Every concept's hero shows the dossier's `signature_fact` (the one thing only this client has: the 1868 wool office and its cast-iron sample drawers, the reference kilogram on its isolated plinth, the night-sky ceiling of a clinic called the Moon) or the client's own place, product or people. A concept built on the client's customers, an adjacent trade or a pun on the name (a market stall for an accountant whose clients trade at a market, a circus tent for a clinic, saws for a funeral director who was once a joiner) fails; so does a concept that breaks the audience mood. +- A concept must not require anything the dossier refuses (`refuse`), or content it lacks. + +## 4. Worked examples + +| Client | Concept (angle) | Rests on facts like | +|---|---|---| +| ARA A "Kvöld á ARA" | the site is the room at night: paper menu on a dark wooden table, hand-inked dishes; cart is a guest-check pad, confirmation a kitchen ticket | dark October evenings in Kópavogur; Edison bulbs and oven glow; the real menu lines and prices | +| ARA B "Heimsækið Búðakór" | the neighbourhood pizzeria sold as a 1950s travel destination: litho poster, "Farseðill" ticket as cart, boarding pass as confirmation | family on a sofa deciding on Friday; the real building at dusk; the Esja-like ridge; pizza as the rising sun | +| ARA C "Boltinn & barinn" | letterpress match-night handbills; the sports-bar half of the business | real beer and wine prices; open to 01:00 Fri and Sat; the big screen; real photos as one-ink halftones | +| ARA D "Hverfisstaðurinn" | Nordic daylight diner for the neighbourhood: deals as hanging price tags with string | turquoise booths in the real photo; deal prices 8.600 / 5.500 / 6.000 / 3.600 / 2.800; a verbatim review about regular take-away | +| Hitatækni A "Loftið í húsinu" | picture book about where a building's air goes, each spread ending on the product that does the job | air handling in Icelandic apartment blocks; filter change monthly; hitaveita water through valves; sensors at 19-21 °C; since 1963 | +| Hitatækni B "Tækniblað" | the shop as an HVAC datasheet binder: ruled grid, mono data, size ruler | Belimo ranges with DN sizes; sheet numbers from brand and position | +| Hitatækni C "Spec-sheet" | 1980s Japanese component brochure, brushed aluminium, one signal colour, a model code as big as the name | Belimo actuators as machined units; brand blue; Swegon rooftop unit spec labels | +| Release-notes SaaS "The release-notes ledger" | `product-ui`: the real changelog and the capture of the workflow it ships, set as a ledger | real app screens; the named workflow; version dates | +| Fashion house, own campaign photography "The contact sheet" | `photography`: the client's frames shown unaltered as a contact sheet, crop and sequence as the signature | usable photo assets; collection names; season dates | +| Developer docs "The maintenance manual" | `utility` or `typography`: numbered procedures, torque-table style parameters, no hero | real API entities; install steps; version facts | +| Swiss black-and-white studio (directive: no illustrations) | `typography`: a grid of the studio's own lettering carrying its founding year | project list; directive `treatment=typography`; the studio's name and city | +| Hitatækni D "Teikning" | the shop as engineering drawing sheets: blue plates, dimension lines, a title block, spec tables on vellum | air handling documented as drawings; a heat wheel, damper, coil, valve, filter; real DN and voltage spec rows | + +Same client, four angles: variants of one client use one dossier and one concepts file, and `--reroll 0..N-1` deals a different concept each round. diff --git a/plugins/design/skills/vary/references/dossier.md b/plugins/design/skills/vary/references/dossier.md new file mode 100644 index 0000000..2a3bdcf --- /dev/null +++ b/plugins/design/skills/vary/references/dossier.md @@ -0,0 +1,62 @@ +--- +name: dossier +description: Step 0 of vary and design. How to read a project (repo, the client's live site, supplied assets) into .vary/dossier.json, a list of sourced facts with ids that concepts, graphics and the evaluator all cite. Load before anything else; nothing is invented. +--- + +# Dossier: read the project before designing it + +Every later step cites this file. Concepts cite fact ids, graphics are seeded from fact values, and the evaluator checks the build against it. A thin dossier makes a generic site, so the work here is reading, not writing. + +## 1. Sources, in this order + +1. **Repo content.** README, data or CMS fixtures, i18n strings, existing pages, `assets/`, `public/`. If a data module already exists (`menu.ts`, `company.ts`, `catalog.json`), read it whole. +2. **The client's live site.** Page text, plus the media library. If the product store is thin, pull the uploads folder instead (hitataekni: the WooCommerce store held 1 product, the media library held 172 images and 37 PDFs). Save page copy as plain text files next to the images. + - Throttle downloads: 1-2 requests at a time with a pause. A parallel burst got the origin firewall to ban the client. On a ban, fall back to the Wayback Machine (`web.archive.org/web/id_/`). +3. **Assets the user supplies**: logo, photos, PDFs, brand sheet. Look at each image (Read it) and note what it shows. +4. **Public reviews**, quoted verbatim with the source and date. +5. **The user's memory files** for standing preferences ("no staff portraits", "no underline nav"). They go in `refuse`. + +Look at the place too: signage, the building, the room, the climate, the light. Photographs and reviews carry these facts; write them as `place` and `material` facts. + +## 2. Schema of `.vary/dossier.json` + +```json +{"project":"ARA","subject":"neighbourhood pizzeria and sports bar, Búðakór, Kópavogur","sector":"food-drink", + "audience":["families ordering take-away on Friday","match-night regulars"], + "place":{"city":"Kópavogur","climate":"dark winters","light":"Edison bulbs, oven glow","landmarks":["Esja-like ridge"]}, + "language":{"code":"is","glyphs":"þðÞÐÁÍÓÚÝÆÖ"}, + "brand":{"name":"ARA","hex":["#..."],"logo":"assets/scraped/logo.png","signage":"gold serif ARA on navy cladding, 554 3050"}, + "facts":[{"id":"F1","kind":"product","text":"Formaggi — fjórir ostar …","source":"ararestaurant.is/matsedill"}, + {"id":"F2","kind":"price","text":"Pizza Margarita 2.790 kr.","source":"ararestaurant.is/matsedill","demo":false}], + "assets":[{"path":"shared/img/basar.webp","kind":"photo","usable":true,"notes":"turquoise booths"}], + "artefacts":["paper menu","guest-check pad","kitchen ticket rail","match-night handbill"], + "refuse":["no status strip","no staff portraits"], + "directives":[{"text":"plain, black and white, no illustrations, Swiss style","source":"user brief","locks":["treatment=typography","world=swiss-international","strategy=ink-on-paper"]}], + "product":{"app_path":"apps/web"}} +``` + +- `sector` is exactly one id from the SECTORS enum that `roll.mjs` accepts: food-drink, hospitality-travel, retail-ecommerce, trades-construction, industrial-hardware, engineering-technical, healthcare-clinical, finance-insurance, legal-public, kids-education, culture-arts, music-events-nightlife, fashion-beauty-luxury, sports-fitness, media-publishing, dev-tools, enterprise-saas, nature-outdoor, maritime-logistics, nonprofit-activism, wellness, gaming. +- `facts[].id` is `F1`, `F2`, ... and unique. These ids are what `concepts.json` cites and what `roll.mjs` resolves. +- `facts[].kind` is one of: `product`, `price`, `hours`, `place`, `process`, `history`, `quote`, `material`, `artefact`, `person`, `feature`, `metric`, `entity`, `workflow`, `spec`, `event`. The roll counts distinct kinds, not this enum, so the list is guidance. +- `facts[].source` is a URL, file path or "user brief". `demo: true` marks a value that is placeholder, not the client's. +- `language.glyphs` lists the non-ASCII letters the content needs; Step 3 verifies the font against it. +- `assets[].kind` is `photo`, `screenshot`, `logo` or `headshot` (plus others as needed). `notes` records consent for identifiable people and "show unaltered" for identity assets (headshots, logos, product screens). +- `directives[]` holds what the user said about the look, verbatim (`text`), its `source` and the `locks` it implies as `key=value` strings; `roll.mjs` merges them into its locks and they outrank skill defaults. `signature_fact` is the id of the one fact only this client has and a stranger would remember (a place, an object, a ritual), picked before any concept is written; concepts must show it in the hero. `product.app_path` points at the runnable app when product UI is a hero candidate; `product.screens` lists screens the brief describes ("a rota grid, colour by shift") when there is no app or screenshot, and grounds a `product-ui` concept that rebuilds them in HTML. +- `refuse` collects user vetoes and standing rules, and grows when the user rejects a world for the trade. + +## 3. Minimums + +- At least **12 facts**, at least **4 kinds**, at least **3 artefacts** (the trade's own documents and hardware: datasheet, guest check, route card, price tag). +- Every fact has a `source`. +- `roll.mjs --dossier` enforces these: a `facts` array must exist, at least 12 facts, at least 4 distinct `kind`s, a `source` on every fact, and `dossier.sector` equal to `--sector` when both are given. It exits 2 with the failing rule otherwise. +- If the minimums cannot be met, ask the one question round from SKILL.md (the real proof and content available), then stop. Never pad with plausible-sounding facts. + +## 4. Rules + +- **Text is verbatim.** Menu lines, spec rows, hours, quotes: copy them exactly, in the client's language. Demo prices carry `"demo": true`. +- **Never invent** customers, testimonials, statistics, capabilities or dates (hitataekni spec: "never invent customers, testimonials or statistics"; ara-bar: "Real content only (no inventing)"). +- **Facts are small and checkable.** One fact is one claim with one source: "Open Fri-Sat until 01:00", "Belimo valve DN 15-25". Split a paragraph into several facts. +- **Content lands in the build as a data module** (`content.ts`, `company.ts`, `menu.ts`) holding the dossier values, so pages and graphics are generated from the same source (ara-bar `shared/menu.ts`, hitataekni `shared/company.ts`). `check.mjs` then finds the cited facts in the build. +- **Photos are material, not decoration.** Record which assets are usable and what each shows; `graphics.md` §2 says how to transform them. Mark any with a baked-in badge or watermark in `notes`. +- **People.** Staff or owner portraits only when the dossier says the client wants them. +- Write the file once the sources are read, and keep it small enough for the evaluator to read. diff --git a/plugins/design/skills/vary/references/extend-mode.md b/plugins/design/skills/vary/references/extend-mode.md index 590a399..aedc813 100644 --- a/plugins/design/skills/vary/references/extend-mode.md +++ b/plugins/design/skills/vary/references/extend-mode.md @@ -5,6 +5,8 @@ description: What to do when the repo already has a visual system (DESIGN.md, to # Extend mode +`` below is the base directory defined in SKILL.md Step 1: use it literally, never search the filesystem for the scripts, never run a copy under `~/.claude/plugins`. + Visual authority is evidence, not a filename. A missing DESIGN.md does not make a project greenfield: a coherent identity already in code (tokens, a themed config, consistent components across pages) is the strongest possible lock. A local addition inherits its surface; it never becomes a new identity exercise. ## 1. Detect @@ -16,7 +18,7 @@ Look for, in this order: `DESIGN.md` or `design.md`; a token file (`tokens.*`, ` | Situation | Roll? | Command | |---|---|---| | New component / section / state inside an existing page | No | — | -| New whole page inside an established system | Skeleton only | `node roll.mjs --mode --lock display= --lock body= --lock palette=#`; if the system's fonts are not catalog ids, run `--canon` for the skeleton and keep the system's faces | +| New whole page inside an established system | Skeleton only | `node /scripts/roll.mjs --mode --lock display= --lock body= --lock palette=#`; if the system's fonts are not catalog ids, run `--canon` for the skeleton and keep the system's faces | | Redesign explicitly requested ("replace the look") | Yes, full roll | Treat the old look as evidence of what the subject is, not authority over what it becomes; replace DESIGN.md at the end from the built result | | Incomplete brand (a logo and one hex) | Yes, with locks | `--lock palette=#hex`; the roll supplies world, skeleton and type | @@ -26,7 +28,7 @@ Never split the difference: polish on a discarded look, or a new identity smuggl The system owns palette, type and components. The page still has to be designed: -- **Skeleton**: the archetype is a free axis. A docs page in an existing system can be an index-list or a broadsheet; a settings page can be card-less tables. Pick from `data/archetypes.json` by the surface's mode; do not default to the system's other pages' skeleton unless consistency is the explicit brief. +- **Skeleton**: the archetype is a free axis. A docs page in an existing system can be an index-list or a broadsheet; a settings page can be card-less tables. The roll chooses the skeleton: `roll.mjs --lock ...` (never pick from `data/archetypes.json` yourself) by the surface's mode; do not default to the system's other pages' skeleton unless consistency is the explicit brief. - **Spacing rhythm and density** tuned to this page's content, inside the token scale. - **The one authored motion moment**, in the system's easing. - **States**: empty, error, loading and success screens designed for this page's real cases, in the system's vocabulary. @@ -34,7 +36,7 @@ The system owns palette, type and components. The page still has to be designed: - **Copy** that only this product could say; buttons that name outcomes. - **Numerals and data typography** where the page carries figures. -Then Step 3 onward of SKILL.md applies unchanged (build fully, `check.mjs`, separate evaluation). The evaluator receives a contract block written by hand in the same field order, with WORLD set to `system: ` and GRAMMAR listing the system's own rules as found in code. +Step 3a applies only if the system's existing pages use that treatment. Record `HERO TREATMENT:` in the hand-written contract as what the system already does. Icons come from the system. Then Step 3 onward of SKILL.md applies (build fully, `node /scripts/check.mjs`, separate evaluation). The evaluator receives a contract block written by hand in the same field order, with WORLD set to `system: ` and GRAMMAR listing the system's own rules as found in code. ## 4. Recording diff --git a/plugins/design/skills/vary/references/graphics.md b/plugins/design/skills/vary/references/graphics.md new file mode 100644 index 0000000..be1ec21 --- /dev/null +++ b/plugins/design/skills/vary/references/graphics.md @@ -0,0 +1,1431 @@ +--- +name: graphics +description: Step 3a of vary (Phase 3b of design). The playbook for custom, subject-specific graphics written in code: how to derive one signature device from the dossier, the art-engine architecture, a technique catalog with a working code pattern per technique, banned forms, budgets, accessibility, and the art-brief, gallery and screenshot-critique loop that must pass before any UI page code. +--- + +# Graphics: draw the subject + +When the treatment is scene or pattern, the graphics are the identity. A palette and fonts over CSS blocks is not a design. Every build carries one **signature device** drawn from the subject, shown at three scales, built from dossier facts. The roll assigned the technique; this file says how to execute it. The two client sites that set the bar did it this way: ara-bar drew every dish and its own building as seeded SVG; hitataekni drew its air handling unit as a cutaway and traced its products into drafting-sheet line art. The patterns below are those engines' real methods, generalised: feed them the subject's own facts, because a pattern copied with placeholder data looks generic (a navy slab with a gold blob). Section 0b is the bar. The patterns teach techniques, not motifs: never draw a pizza, a carton, a storefront or an air-handling unit unless it is THIS client's subject — take the method (projection, char spots, zoned fills, tracing, dimension lines) and point it at the nouns in this dossier. + +## 0. Derive the device from the subject + +1. Pull 5-10 nouns from the dossier facts: place, material, mechanism, product, history. +2. Find the ONE primitive they share: a contour, a stroke, a ring, a tile, a sheet, an ink layer. +3. Make it carry data, seeded from real values: menu toppings, DN ranges, founding year, hours. +4. Place it at **3 scales**: hero, section divider, micro (icon, bullet, empty state, cursor). +5. One device, one texture, one motion rule. + +Test: *"Could this graphic only belong to this business?"* If a competitor could ship it, redraw it from a fact they do not have. + +| Project | Device | Data it carries | +|---|---|---| +| ara-bar | procedural pizzas | topping lists from `menu.ts` (`pizza/specs.ts`) | +| ara-bar | storefront scene | the real signage and phone number | +| hitataekni | air-handling-unit cutaway | real parts cross-referenced to the shop's categories | +| hitataekni | traced Belimo line art | real spec rows (DN range, voltage) in the dimension labels | +| hitataekni | air ribbons through an Icelandic apartment block | the company's process | +## 0b. What cool looks like (the bar to aim at) + +Ten signature devices from the two benchmark sites. "Built" is what exists in their code; "carry" is how the same idea scales where the code did not (marked as such). Aim for this density of specificity: every picture is a function of a fact the competitor does not have. + +| # | Device | What was drawn | From which fact | Three scales | +|---|---|---|---|---| +| 1 | ara-bar pizzas | Top-down pies with leopard-char crust and scorched rim, overlapping melted-cheese blobs with sauce between, zoned toppings (the four-cheese pizza has four wavy quarters), a lifted slice with cheese strands (`lift: true`) | The topping list of each dish in `menu.ts` | Hero: the lifted slice. Divider: the pizza as a rising sun over hills, 24-wedge sunburst. Micro: the 96px thumb with fewer, bolder toppings | +| 2 | ara-bar storefront | The real building at dusk and night: navy corrugated cladding with a string-light wire, a dusk gradient sky of glowing bands and cloud lenses, a drip-edged snow ridge, a crest and gold "ARA" pinned with `textLength` over a "RESTAURANT" ribbon, "554 3050" on a second sign and the door, pendant bulbs, faceless diners at tables, glass shine, paving slabs and a street lamp with a halo | The client's own photo of the premises | Hero: the whole scene. Divider: the ridge strip with the sky cut away by string surgery. Micro (carry): the sign alone as a sticker | +| 3 | ara-bar takeaway carton | Oblique-projection box with lid, receipt with zig-zag edge, squashed-ellipse "ARA" sticker, cast shadow, two tapered steam wisps | The real take-away service | Hero: the cart or confirmation page. Divider (carry): the corrugated wall as a repeated strip. Micro: the sticker as the basket icon | +| 4 | ara-bar oven | Brick dome in running bond, fire-glow clamped inside the mouth, five flames and an ember scatter, a peel in perspective, a woodpile with offset tree rings | The wood-fired oven in the room | Hero: the oven glow. Divider (carry): a row of flames. Micro: one flame as a bullet | +| 5 | hitataekni cutaway | Air handling unit in section A-A: heat wheel with honeycomb rings and 16 spokes, pleated filter, coil, backward-curved fan, hatched casing, scale bar, eleven numbered callouts | Real parts = the shop's product categories (the parts list links into the shop) | Hero: sheet 00 (`sheet()` in section d draws the frame, zone markers, title block, leader callouts, flow labels and the L/H dimensions around a `unit()`). Divider: the hatched wall strip with the scale bar. Micro: one numbered callout circle or one part as a nav glyph | +| 6 | hitataekni traced line art | Belimo products edge-traced from the scraped photos (warranty roundel painted out first), white line on blue, dimension lines at the traced bounds | Spec rows: `DN 15-25`, `2-VEGA`, voltages | Hero: the cover sheet, a traced rooftop unit drafted in over 1.1s with zone markers A-F and 1-8. Plate: each product. Micro (carry): a small mask in a cart line | +| 7 | hitataekni air ribbons | A flat-roofed Icelandic block with snow on the parapet and lit windows; a tapered blue fresh-air ribbon in, a grey stale one out | The company's trade (air through a building) | Hero: spread 1 of the picture book. Divider: the ribbon alone as a swash that draws on scroll. Micro (carry): a ribbon tick as a list marker | +| 8 | hitataekni filter clogging | A filter bed that fills from the bottom with specks, "Manudur 1 to 12" from a typed CSS integer counter | The filter-subscription interval it sells | Hero: the pinned spread. Divider (carry): the bed as a progress bar. Micro: the counter digit | +| 9 | hitataekni hood section | A kitchen-hood duct in section, grease built up as perpendicular hatch ticks along the walls, dimensions "6 MAN." and "3-5 AR" | The duct-cleaning service intervals | Hero: the service sheet. Divider (carry): the tick row. Micro: a single tick-lined pipe icon | +| 10 | hitataekni title block | A drawing-sheet title block that behaves like one: sheet counter updates as plates cross the viewport middle, founding year 1963, emergency number, address, margin ruler | The firm's own details | Hero (carry): the cover. Divider: the margin ruler. Micro: the sheet counter chip | + +What these have in common, and what a review should check: + +1. **The picture is a function of a fact.** Remove the logo and the hero still says whose it is. +2. **One drawing, three scales**, from the same function, not three different pieces of clip art. +3. **Authored imperfection**: wobble, misregistration, char spots, grease ticks; reproducible from a seed. +4. **A restriction that is tested**: six inks and no opacity, or strokes only on one blue. +5. **Objects with a job**: the cart is a guest check or a ticket, the callout opens the product page, the gauge is the explanation of what the company sells. +6. **Craft the template skips**: a mask for the badge baked into the photo, hand-patched glyphs for the content language, a finished state for narrow and reduced-motion, a text equivalent. + +A scene-treatment hero must pass the scene bar (S1-S3 in the vary Step 3a, composed per §1b): a full-bleed scene or sheet, 5+ subject objects, 3+ depth layers, one light, headline set into it, 2+ facts drawn in it. A single icon, a bare grid, a lone diagram in a framed box or a text-only hero is a FAIL for a scene-treatment persuade page. + +If your hero could be mistaken for a stock illustration or a gradient blob with a gold shape on it, it is below the bar: redraw it from a fact. + +## 1. Art-engine architecture + +- **Plain functions that return SVG strings.** No DOM, so the engine runs in Bun, Node and the browser, and a test can render it. Pages inline the string, or a build script writes `.svg` files. +- **Seeded PRNG keyed per item**: `rng("pizza:" + id)`. Never `Math.random` in art (a re-render must be identical; `check.mjs` warns on it). +- **Draw once, style many (the swatch bridge).** A drawing never contains a colour. It names swatches (`crust`, `sauce`, `shadow`) and calls `shape(S, d, 'sauce')`. A **style object** `S = { name, palette, outline, wobble, shade, misregister }` maps every name to an ink hex for one world: `ink` is warm line and hatch, `litho` is flat fills with offset shadows and no outline, `press` snaps the line palette to six inks. `shape()` writes a **literal** `fill="#hex"` plus `data-sw="sauce"`, so the ink-set test, `[fill="#hex"]` selectors and `[data-sw]` selectors all work. Never write `fill="var(--x)"` in a drawing: attribute selectors cannot see through it, and a test cannot check it. CSS variables stay for page chrome; the picture's colours come from the style object. +- **Leave composition markers** in the output (the first path is always the sky) so pages can cut a scene apart by string surgery, as ara-bar's poster strips the storefront's sky with `svg.indexOf('` : ''; + return `${under}`; +} +export const strokeSw = (S, d, sw, w = 1.4) => ``; +export const shade = (S, d) => S.shade === 'hatch' && d ? strokeSw(S, d, 'ink', (S.outline?.width ?? 2) * .4) : ''; // hatching exists only in line worlds +export const svg = (vb, body, title = '', cls = '') => + `${body}`; + +// Two styles for the same drawings. 'ink' = warm line-and-hatch; 'litho' = flat fills, offset shadows, no outline, no opacity. +const base = { paper: '#f4ead2', ink: '#2a1d14', shadow: '#c9b48a' }; +export const STYLES = { + ink: { name: 'ink', outline: { color: '#2a1d14', width: 2 }, wobble: 1, shade: 'hatch', misregister: 0, + palette: { ...base, crust: '#d9a95c', crustDark: '#a8702f', sauce: '#b8402a', cheese: '#f1d27a', cheeseDark: '#c99a3b', pepperoni: '#a8322a', fleck: '#6e1d18', + olive: '#3c3a1d', basil: '#4f8a3c', bacon: '#c8745a', wood: '#7a5230', woodDark: '#4e3320', box: '#d8b988', boxDark: '#a98a5c', wall: '#e8dcc0', wallDark: '#6a5a46', steam: '#f4ead2', + skyDark: '#4b4a72', sky: '#b9677a', skyLow: '#e0925f', sun: '#f7d98f', ridge: '#7a6283', cladding: '#2e3c58', rib: '#1a2338', gold: '#d9a82e', glass: '#f3c76a', glow: '#fbe9b0', snow: '#fbf6e8', + air: '#3f86b3', stale: '#8f8a80', screen: '#3a5a6e' } }, + litho: { name: 'litho', outline: null, wobble: .6, shade: 'flat', misregister: 3, + palette: { paper: '#f4ead2', ink: '#1d2a4a', shadow: '#1d2a4a', crust: '#ffc417', crustDark: '#e0643f', sauce: '#e0643f', cheese: '#f4ead2', cheeseDark: '#e0643f', pepperoni: '#e0643f', fleck: '#1d2a4a', + olive: '#1d2a4a', basil: '#4a8a5a', bacon: '#e0643f', wood: '#ffc417', woodDark: '#1d2a4a', box: '#e0643f', boxDark: '#1d2a4a', wall: '#f4ead2', wallDark: '#1d2a4a', steam: '#f4ead2', + skyDark: '#1d2a4a', sky: '#5fb0cf', skyLow: '#e0643f', sun: '#ffc417', ridge: '#4a8a5a', cladding: '#1d2a4a', rib: '#5fb0cf', gold: '#ffc417', glass: '#ffc417', glow: '#f4ead2', snow: '#f4ead2', + air: '#5fb0cf', stale: '#f4ead2', screen: '#4a8a5a' } }, +}; +export const inkSet = S => new Set(Object.values(S.palette)); +// Point-in-polygon: the `inside` predicate for hatchIn over any face. +export const inPoly = p => (x, y) => { let c = false; + for (let i = 0, j = p.length - 1; i < p.length; j = i++) if ((p[i][1] > y) !== (p[j][1] > y) && x < (p[j][0] - p[i][0]) * (y - p[i][1]) / (p[j][1] - p[i][1]) + p[i][0]) c = !c; + return c }; +``` + +The torn edge: set `clip-path: var(--torn)` from `torn(seed)`, with a lighter back plate behind it and a `drop-shadow` filter on the wrapper. + +## 1b. Composing a scene + +The hero of a persuade or experience page is a **scene**, not an object: ara-bar's is the restaurant at night, hitataekni's is a whole blueprint sheet. Both are 10+ drawn objects, 3+ depth layers and one light source, with the brand set into the picture. Generalise the method to this dossier's place, never the motifs. + +**Layer order** (paint back to front; each layer is a function returning SVG, all seeded): + +1. **Sky / backdrop**: gradient bands or flat strips, stars or paper tone, a horizon. (Sheet: grid, border, zone markers.) +2. **Architecture / landscape**: ridge, skyline, building facade, wall, cladding with rib hatching. (Sheet: the traced or drafted subject.) +3. **Subject**: the thing the business is, drawn large and lit: the oven, the unit, the shopfront, the workbench. +4. **People and props**: faceless silhouettes (only for people not in the dossier's photos; never redraw an identifiable real person) at tables, tools in use, a match on the screen, steam, a street lamp, parts on a shelf; traces of use where people are not allowed. +5. **Lighting overlay**: warm window glow, a halo round the lamp, a cast shadow under every object, a darkened edge vignette. One light source; lit faces and shaded faces share one direction. +6. **Lettering**: sign, ribbon, painted wall text, title block. Real dossier strings only. + +**Headline into the scene.** Default: the h1 is large display type (at least 72px at 1440, 44px at 390) set straight into the scene's open area (night sky, wall, open sheet), left or top third, as the benchmarks do (ARA's name over the dark sky, Hitatækni's title on the sheet); the art leaves that area open on purpose. A sign, plaque or board is the headline surface only when it is the business's real signage drawn at real scale (at least a third of the hero's width), never a small framed box in the middle of the art. Colour: give the scene a value range (a lit focal point against a dark or deep field, or saturated accents against a pale field); one muddy tone across the whole hero fails. The brand or headline is the scene's own lettering (the sign over the door, painted wall type, a title block). The page headline is always live text (a real `

`) positioned over that drawn surface (pattern in §1b). Set it over a quiet zone of the scene (sky, wall, empty sheet area) and keep that zone flat. **Readable over art**: put a plate behind copy (a darker band, a card of the scene's own material) or put the copy on the scene's own board or sign; measure contrast over the darkest and lightest pixel under the text, not the average. Body copy and prices never sit directly on detail. + +**Facts in the art.** Draw at least two dossier strings inside the picture: the phone number painted on the sign, the founding year and address in the title block, hours on a door card, the real product names on shelf labels. + +**Skeleton**: seeded layers, a style object, objects at three depths, a light. Adapt the nouns to the dossier; this one is deliberately generic. It is the **minimum**, not the target: add the dossier's own props (the TV, the shelf, the product) until the checklist below passes. Swatches it uses (`ground`, `cladding`, `rib`, `glass`, `wallDark`, `gold`, `glow`, `ink`, sky tones) must exist in the style's palette. + +```js +// art/scene.js: compose a hero scene. Every layer is a pure function of (S, r, W, H); S is a style object from core.js. +import { rng, rr, blob, hatchIn, inPoly, scatter, shape, strokeSw, svg, f } from './core.js'; + +const sky = (S, r, W, H) => { // layer 1: first element is always the sky (composition marker) + const bands = [0, .25, .5, .72].map((t, i) => ``).join(''); + const stars = scatter(r, 40, (x, y) => y < H * .3, W / 60, [0, 0, W, H * .3]).map(([x, y]) => ``).join(''); + return bands + stars; +}; +const ridge = (S, r, W, H) => { // layer 2: far landscape, lit side + hatched shade side + const pts = Array.from({ length: 14 }, (_, i) => [i / 13 * W, H * (.34 + rr(r, -.07, .07) - Math.sin(i / 13 * Math.PI) * .08)]); + const outline = [[0, H * .5], ...pts, [W, H * .5]]; + return shape(S, `M${outline.map(p => p.map(f).join(' ')).join('L')}Z`, 'ridge') + + strokeSw(S, hatchIn(inPoly(outline), [0, 0, W, H * .5], 60, W / 120, 3), 'ink', W / 700); +}; +const facade = (S, r, W, H) => { // layers 2-3: the building the business lives in, with lit windows, a sign plate and a ground band + const y0 = H * .5, y1 = H * .88; let out = shape(S, `M0 ${f(y1)}H${W}V${H}H0Z`, 'ground'); // ground band with its own tone + out += shape(S, `M0 ${f(y0)}H${W}V${f(y1)}H0Z`, 'cladding'); + for (let x = 0; x < W; x += W / 90) out += ``; // corrugation + out += shape(S, `M0 ${f(y1)}H${W}v${f(H * .02)}H0Z`, 'wallDark', { line: false }); // cast shadow of the building on the ground + const n = 5, ww = W / n; + for (let i = 0; i < n; i++) { + const x = i * ww + 12, w = ww - 24; + out += shape(S, `M${f(x)} ${f(H * .66)}h${f(w)}v${f(H * .2)}h${f(-w)}Z`, 'glass'); // lit windows + out += shape(S, `M${f(x)} ${f(H * .66)}h${f(w)}v${f(H * .015)}h${f(-w)}Z`, 'wallDark', { line: false }); // lintel shadow + out += ``; // pendant bulb + } + return out; +}; +const sign = (S, W, H) => shape(S, `M${f(W * .22)} ${f(H * .4)}H${f(W * .78)}V${f(H * .56)}H${f(W * .22)}Z`, 'wallDark', { shadow: 'ink' }); // plate behind the lettering, so copy never sits on detail +const lamp = (S, W, H) => { // prop at the foreground depth: street lamp with its own light pool + const x = W * .93, y = H * .88; + return shape(S, `M${f(x - 3)} ${f(y)}V${f(H * .5)}h6V${f(y)}Z`, 'wallDark') + + shape(S, `M${f(x - W / 60)} ${f(H * .5)}h${f(W / 30)}l${f(-W / 200)} ${f(-H * .03)}h${f(-W / 40 + W / 100)}Z`, 'wallDark') + + `` + + ``; +}; +const people = (S, r, W, H) => Array.from({ length: 4 }, (_, i) => { // layer 4: faceless silhouettes at depth (never an identifiable real person from the dossier's photos), each with a cast shadow + const x = W * (.12 + i * .24 + rr(r, -.02, .02)), y = H * .88, s = W / 40; + return `` // cast shadow, offset away from the light + + shape(S, `M${f(x - s * .9)} ${f(y)}L${f(x - s * .6)} ${f(y - s * 2.6)}Q${f(x)} ${f(y - s * 3.1)} ${f(x + s * .6)} ${f(y - s * 2.6)}L${f(x + s * .9)} ${f(y)}Z`, 'wallDark') // torso + + shape(S, blob(x, y - s * 3.5, s * .55, s * .65, r, .05), 'wallDark'); // head +}).join(''); +const glow = (S, W, H) => S.shade === 'flat' ? '' : // layer 5: light overlay (flat/litho worlds forbid gradients: use offset shadow shapes instead) + ``; +const lettering = (S, W, H, facts) => // layer 6: real strings from the dossier, pinned with textLength + `${facts.name}` + + `${facts.phone}`; + +export function heroScene(S, facts, { W = 1440, H = 900 } = {}) { + const r = rng('scene:' + facts.id); // one seed; layers draw from it in a fixed order + return svg(`0 0 ${W} ${H}`, [sky(S, r, W, H), ridge(S, r, W, H), facade(S, r, W, H), sign(S, W, H), lamp(S, W, H), people(S, r, W, H), glow(S, W, H), lettering(S, W, H, facts)].join(''), facts.alt); +} +// Mobile: call heroScene(S, facts, { W: 780, H: 900 }) with the same seed and fewer, larger objects, not a scaled-down desktop. +``` + +**Checklist for the gallery's whole-scene render**: at least 5 distinct objects counted aloud; at least 3 depth layers; one visible light source with cast shadows; texture on at least two surfaces (hatching, grain, halftone, paper); people or traces of use; at least 2 dossier facts legible inside the art; covers at least 55% of the 1440x900 first viewport (45% at 390x844); the §1d finish pass is applied (value plan, 3 value steps per material, grain, no empty region over 20% of the hero); every label, callout and sign text is fully visible (collision rule in §7). Fail any and the round is not closed. + +**Real `

` over a drawn surface (the fold pattern).** The art leaves a flat sign/board/title-block area; the `

`, value line and action are absolutely positioned over exactly that rectangle and styled as its lettering (the surface's own font, colour and ink). Never svg-only text, never a card: + +```html +
+ + +
+``` + +The percentages are the board rectangle over the viewBox (x/1440, y/900, w/1440, h/900) and are only correct when the wrapper has exactly the viewBox's aspect ratio and the svg uses `meet` (never `slice`, which crops and shifts the board): give the wrapper `aspect-ratio` equal to the viewBox, and fill any leftover viewport with the wrapper's background, not by cropping. On phones swap in the phone scene (its own viewBox, aspect-ratio and board rectangle) at a breakpoint. The wrapper has no fill of its own, so it reads as lettering, not a card; the board is drawn in the svg. + +**Lettering on a scene surface that stays legible and clickable (worked example).** The phone, hours and a painted action may live ON a drawn surface (sign, ticket, chalkboard, title block, label), never on a frosted card, chip row or button pair laid over the art. A painted sign can be wrapped in a real control, so the picture is the button; the page `

` still exists as live text (above). Do not repeat the business name in both: when the `

` carries the name, the sign's svg text is the phone only (or the whole sign is `aria-hidden` decoration with the `` named by the phone), so the name appears in the DOM once: + +```html + + + Hitataekni, repairs. Phone 555 0123 + + + HITATAEKNI + 555 0123 + + + +``` + +Rules: lettering against its board is at least 4.5:1 (7:1 for the primary action); the `` carries an accessible name that repeats the lettering; nothing drawn (pipe, lamp, person, steam) overlaps a glyph, so keep a clear margin of one letter-height around the text in the art; the sign is at least 44px tall on a phone; if the scene is a background ``, place the `` as a sibling positioned over the sign with percentages that match the sign's rectangle, and keep the lettering in live text so it scales and is selectable. + +## 1c. Scale up + +A scene is drawn at 1000-1500 units wide, so line weights, hatch gaps, label sizes and stipple dots scale with the canvas. Never a 1px hairline on a 1400px canvas. + +- Derive every stroke from the width: outline `W / 450` to `W / 300`, hatch lines `W / 700`, hatch gap `W / 120`, dimension lines `W / 600`, label text `W / 90` minimum for small print and `W / 9` to `W / 6` for painted lettering. +- Prefer filled shapes to strokes for large objects; strokes are for edges, hatching and dimension lines. +- Mobile gets its own composition at its own proportions (fewer, larger objects), not the desktop SVG scaled down. +- Use `vector-effect: non-scaling-stroke` only for deliberate hairlines (a grid), never for the subject. +- Plates that resize use the `--k` pattern in "Scaling line weight with the plate" below. + +## 1d. Finish pass (applied in the art loop, after the objects are drawn) + +Flat single-tone vectors read as clip-art. The benchmarks win on value structure: lit windows against a night sky, a gold sign, grain, a blueprint grid with a title block. Run this pass on the hero and on every plate before the critique screenshot. + +1. **Value plan.** Decide the light before shading: time of day, ONE key light (direction), ONE glow source (window, lamp, oven mouth, screen). Lit planes face the key light, shaded planes the other way, cast shadows fall away from it. The brightest value on the plate goes to the focal fact (the sign, the product, the price tag); everything else sits at least two steps darker. Squint test: blur the screenshot, the focal fact must still be the brightest spot. +2. **No flat muddy fills.** Every material (wall, roof, cloth, wood, metal, figure) has at least 3 value steps from the palette: lit, mid, shade, plus an edge or cast shadow. Hatching on one brown is not three steps. Never fill a large area with a single mid-value brown or grey. +3. **Grain / texture overlay** over the whole plate (feTurbulence with its contrast stretched, or a tiled noise pattern), so flat fills carry paper or film tooth. Check it, do not assume it: on a flat fill in the screenshot the pixel standard deviation must be at least 3/255 (a 6% unstretched turbulence measures under 1 — invisible). +4. **No dead zone.** No empty region larger than 20% of the hero area (an empty floor, an empty right third, a blank sky): fill it with environment (more street, a counter, shelves, people, reflections) or move and rescale the subject until it fills the frame. + +```js +// art/finish.js: value plan + glow + grain, appended after the drawing and BEFORE the lettering layer (sign text, phone, +// title block): the finish must not lift or tint the letters — draw them last, on their own solid plate. +// Centre the glow on the focal fact (the sign, the product), never on an empty floor. +export const plan = { key: { x: .72, y: .18 }, glow: { x: .5, y: .62, r: .55 } }; // light direction + glow centre (move it onto the focal fact), fractions of W/H + +export const finish = (S, W, H, id) => ` + + + + + + + + + + + + + + + + + + + + `; +// Worlds that forbid gradients or opacity (litho, flat): replace the glow with an offset lighter shape, the vignette with a darker +// border band, and the grain with a hatch or halftone pattern in an existing ink. Still three value steps per material. +``` + +Critique additions for this pass: name the light source, point at the brightest value and say whether it is the focal fact, count value steps on the two largest materials, and measure the largest empty region as a percentage of the hero. + +### Depth recipe (the numbers the fold gate measures) + +Measured on the desktop screenshot of the art area (hero, and each plate on its own box). Judges fail "flat fills with thin uniform strokes" as clip art; the benchmarks have glow, texture, gradient skies and hatch used as shading. + +| Measure | Target | How to hit it in SVG | +|---|---|---| +| Value range (luminance p95 minus p5, 0-255) | at least 90 | A real dark and a real light in the same frame: night sky or deep shade against a lit window, sign or lamp glow | +| Value steps (of 16 luminance bins, bins holding at least 2% of pixels) | at least 5 (a plate: at least 3) | 3-5 value ramps per material (lit, mid, shade, edge, cast shadow); a gradient sky; a glow disc; a vignette | +| Texture (mean local std-dev, 8x8 blocks, flattest 30% of blocks) | reported, not gated (a smooth gradient sky is legitimately flat); aim for 2 or more over flat fills | Grain from the finish pass, stretched so it reads; hatch used as SHADING on the shaded face, never as the fill; paper or halftone tooth | + +The targets are the gate's thresholds (`TH` in `fold-check.mjs`), and the gate prints the three numbers. Flat worlds (litho, flat) forbid gradients and opacity: use the §1d substitutes (offset lighter shape for the glow, border band for the vignette, hatch or halftone in an existing ink for the grain) and meet the same numbers with those. Recipe: (1) gradient sky or backdrop, never a flat rect; (2) 3-5 stops per material, light from the one key; (3) one glow with `mix-blend-mode:screen` on the focal fact; (4) grain over everything; (5) hatch lines follow the form and darken one face; (6) strokes vary in weight (heavier at the shaded edge), never one uniform hairline. + +## 1e. Objects, not boxes + +A rectangle with a word in it is a label, not a drawing. Every drawn object needs: (1) a **real silhouette** of the thing (a train carriage has a roof line with a curve, bogies with wheels, windows, a door, couplers; a bound book has a spine with raised bands, boards that stand proud of the pages, a headband, a visible page block; a drawer has a face, a handle, a gap and a shadow line), (2) **at least 3 value steps** (lit, mid, shade) plus a cast shadow, and (3) for **people**, a posture or gesture (leaning on a counter, reaching, carrying, bending over work), a head and hands or arms placed in it, and a cast shadow on the ground. A lone upright blob with a round head is a placeholder and fails the critique. Both examples use only the §1 kit and swatches that exist in `STYLES` (`cladding`, `rib`, `glass`, `glow`, `wallDark`, `gold`, `ink`, `sauce`, `paper`, `wood`, `woodDark`). + +```js +// art/objects.js: a train carriage (side view). Roof curve, body panel, window band, door, skirt, two bogies, couplers, cast shadow. +import { shape, strokeSw, hatchIn, inPoly, f } from './core.js'; + +export function carriage(S, x, y, w, h) { // (x, y) = top-left of the body; w x h is the body, bogies hang below + const P = S.palette, rail = y + h + h * .22; + const roof = `M${f(x)} ${f(y + h * .16)}Q${f(x + w / 2)} ${f(y - h * .12)} ${f(x + w)} ${f(y + h * .16)}V${f(y + h * .3)}H${f(x)}Z`; // curved roof, one lit plane + const body = `M${f(x)} ${f(y + h * .3)}H${f(x + w)}V${f(y + h)}H${f(x)}Z`; + const lower = `M${f(x)} ${f(y + h * .72)}H${f(x + w)}V${f(y + h)}H${f(x)}Z`; // shaded lower panel: value step 2 + let out = ``; // cast shadow, offset away from the key light + out += shape(S, body, 'cladding') + shape(S, lower, 'rib', { line: false }) + shape(S, roof, 'wallDark'); + out += strokeSw(S, `M${f(x)} ${f(y + h * .3)}H${f(x + w)}`, 'gold', h * .035); // livery stripe + const n = 2 * Math.max(2, Math.round(w / (h * 1.1))), ww = w / n; // even count: the door sits in the centre gap + for (let i = 0; i < n; i++) { // window band: glass, a lit top edge, a shaded sill + const wx = x + ww * i + ww * .2, wwid = ww * .6; + out += shape(S, `M${f(wx)} ${f(y + h * .38)}h${f(wwid)}v${f(h * .26)}h${f(-wwid)}Z`, 'glass'); + out += shape(S, `M${f(wx)} ${f(y + h * .38)}h${f(wwid)}v${f(h * .04)}h${f(-wwid)}Z`, 'glow', { line: false }); + out += shape(S, `M${f(wx)} ${f(y + h * .6)}h${f(wwid)}v${f(h * .04)}h${f(-wwid)}Z`, 'wallDark', { line: false }); + } + out += shape(S, `M${f(x + w / 2 - h * .07)} ${f(y + h * .3)}h${f(h * .14)}V${f(y + h * .94)}h${f(-h * .14)}Z`, 'rib'); // door + out += strokeSw(S, hatchIn(inPoly([[x, y + h * .72], [x + w, y + h * .72], [x + w, y + h], [x, y + h]]), [x, y + h * .72, x + w, y + h], 60, 5), 'ink', h * .012); // hatch shades the skirt only + for (const bx of [x + w * .14, x + w * .86]) { // bogies: frame, two wheels with hubs, axle line + out += shape(S, `M${f(bx - h * .32)} ${f(y + h)}h${f(h * .64)}v${f(h * .1)}h${f(-h * .64)}Z`, 'wallDark'); + for (const dx of [-h * .17, h * .17]) out += ``; + } + return out + shape(S, `M${f(x - h * .1)} ${f(y + h * .84)}h${f(h * .1)}v${f(h * .06)}h${f(-h * .1)}ZM${f(x + w)} ${f(y + h * .84)}h${f(h * .1)}v${f(h * .06)}h${f(-h * .1)}Z`, 'wallDark'); // couplers +} +``` + +```js +// A bound book, three-quarter view. Cover board, spine with raised bands, page block with edge lines, headband, tooled title, cast shadow. +export function boundBook(S, x, y, w, h, title, tilt = -4) { // (x, y) = top-left of the front cover + const P = S.palette, t = h * .12, g = `rotate(${tilt} ${f(x + w / 2)} ${f(y + h)})`; + let out = ``; + out += ``; + out += shape(S, `M${f(x + w)} ${f(y)}l${f(t)} ${f(-t * .6)}V${f(y + h - t * .6)}l${f(-t)} ${f(t * .6)}Z`, 'paper'); // page block (right edge), lit + for (let i = 1; i < 7; i++) out += strokeSw(S, `M${f(x + w + t * .1)} ${f(y + h * i / 7 - t * .05)}l${f(t * .8)} ${f(-t * .5)}`, 'shadow', .8); // leaf edges + out += shape(S, `M${f(x)} ${f(y)}h${f(w)}V${f(y + h)}H${f(x)}Z`, 'sauce'); // front board: mid + out += shape(S, `M${f(x)} ${f(y)}h${f(t * .9)}V${f(y + h)}h${f(-t * .9)}Z`, 'woodDark', { line: false }); // spine shade: dark step + out += shape(S, `M${f(x + t * .9)} ${f(y)}h${f(t * .25)}V${f(y + h)}h${f(-t * .25)}Z`, 'wood', { line: false }); // hinge groove, lit step + for (const k of [.18, .4, .62, .84]) out += strokeSw(S, `M${f(x)} ${f(y + h * k)}h${f(t * .9)}`, 'gold', h * .018); // raised bands + out += shape(S, `M${f(x)} ${f(y - t * .3)}h${f(w + t * .2)}l${f(t * .6)} ${f(-t * .3)}H${f(x + t * .4)}Z`, 'gold', { line: false }); // headband and head edge + out += shape(S, `M${f(x + w * .28)} ${f(y + h * .2)}h${f(w * .56)}v${f(h * .2)}h${f(-w * .56)}Z`, 'paper'); // title label + out += `${title}`; + out += strokeSw(S, `M${f(x + w * .2)} ${f(y + h * .12)}H${f(x + w * .9)}V${f(y + h * .88)}H${f(x + w * .2)}Z`, 'gold', h * .012); // blind-tooled border + return out + ''; +} +``` + +Check both at 96px and at plate size in the gallery. A train that reads as a long box, or a book that reads as a red rectangle, has failed: add the missing parts until each object is recognisable from its silhouette alone (fill it black and look). + +## 2. Technique catalog + +These ids match the roll's `TECHNIQUES`. Each entry: when it fits, a working pattern, the motif to take from the subject, and the performance and accessibility notes. Whatever the technique, ship a finished static state (no JS, no animation). + +### a. `seeded-illustration`: flat-shape scenes of the real place or object + +Fits: a hero scene of the actual building, room, landscape or product; spot illustrations; process scenes. This is the ara-bar engine's real method, not a sketch of it: layered flat shapes, a limited ink set, oblique-projection objects, shading as hatching clipped by geometry, tapered strokes, and every detail taken from a fact. Copied as-is the patterns below look plain; what makes them specific is what you feed them (the real sign, the real phone number, which windows are lit, which cheese is on which quarter). + +```js +import { rng, rr, blob, poly, bez, taper, hatchIn, inPoly, scatter, shape, strokeSw, shade, svg, f, STYLES } from './core.js'; + +// ---- oblique projection: one affine map turns a flat plan into a lid, a wall or a floor --------- +// plane(O, U, V) maps plan (u, v) to the screen with origin O and basis vectors U, V. Use it for lids and labels +// (SVG transform), and pt(u, v) for polygon corners, so every face of the object shares one projection. +export const plane = (O, U, V) => ({ m: `matrix(${U[0]} ${U[1]} ${V[0]} ${V[1]} ${O[0]} ${O[1]})`, pt: (u, v) => [O[0] + u * U[0] + v * V[0], O[1] + u * U[1] + v * V[1]] }); +const rect = (x, y, w, h) => poly([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]); +const open = (p, close = false) => 'M' + p.map(q => `${f(q[0])} ${f(q[1])}`).join('L') + (close ? 'Z' : ''); +// Text with a PINNED width (textLength): a fallback font cannot change the layout. Swatch-named, so it is re-inked like every other shape. +const txt = (S, x, y, s, size, sw, { anchor = 'middle', len, weight = 700, mono = false } = {}) => + `${s}`; +const veil = (S, d, sw, op) => S.shade === 'flat' ? '' : ``; // tone: line worlds only; flat worlds use hard shapes + +// A labelled carton, drawn from three numbers and a real label: a pizza box, a parts crate, a sample bag, a parcel. +export function carton({ w = 175, d = 125, h = 36, label = 'ARA', sub = '', key = 'box' } = {}, S = STYLES.ink) { + const r = rng('carton:' + key), top = plane([50, 170], [1, -.257], [1, .4]); // lid plane: U runs right and up, V runs down and right + const A = top.pt(0, 0), B = top.pt(w, 0), C = top.pt(w, d), D = top.pt(0, d), dn = (p, k = h) => [p[0], p[1] + k]; + const lerp = (p, q, t) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]; + let o = shape(S, rect(0, 0, 400, 205), 'cladding', { line: false }); // dark backdrop: the steam must read against it + o += shape(S, rect(0, 205, 400, 155), 'wood', { line: false }); + o += shape(S, poly([dn(A), dn(D), [dn(D)[0] + 14, dn(D)[1] + 10], [dn(C)[0] + 22, dn(C)[1] + 6], dn(C)]), 'shadow', { line: false }); // cast shadow + o += shape(S, poly([A, D, dn(D), dn(A)]), 'boxDark') + shape(S, poly([D, C, dn(C), dn(D)]), 'boxDark', { shadow: 'shadow' }); // two visible walls + o += shade(S, hatchIn(inPoly([A, D, dn(D), dn(A)]), [A[0], A[1], D[0], dn(D)[1]], 75, 4.5)); // hatch the shaded wall + const ribs = (p, q) => Array.from({ length: 9 }, (_, i) => { const e = lerp(p, q, (i + 1) / 10); return `M${f(e[0])} ${f(e[1] + 8)}V${f(e[1] + h - 3)}` }).join(''); + o += strokeSw(S, ribs(A, D) + ribs(D, C), S.shade === 'flat' ? 'box' : 'ink', 1); // corrugated wall ribs + o += shape(S, poly([dn(A, 12), dn(D, 12), dn(D, 17), dn(A, 17)]), 'shadow', { line: false }); // lid lip shadow + o += shape(S, poly([A, B, C, D]), 'box'); // lid + o += ``; // lid panel line + // sticker: a squashed ellipse carrying the real name, rotated, sitting on the lid + o += `${shape(S, blob(0, 0, 50, 44, r, .02, 14), 'paper', { shadow: 'shadow' })}` + + txt(S, 0, 10, label, 31, 'sauce') + (sub ? txt(S, 0, 24, sub, 5, 'sauce') : '') + ''; + // receipt: clipped to the FRONT wall edge, hangs down the wall with a zig-zag tear at the bottom + const E = lerp(D, C, .66); + o += `${shape(S, 'M-15 2H15V52L10 57L5 52L0 57L-5 52L-10 57L-15 52Z', 'paper', { shadow: 'shadow' })}` + + strokeSw(S, 'M-9 14H9M-9 23H9M-9 32H5M-9 41H7', 'boxDark', 1.3) + ''; + // steam: two short tapered wisps rising from the vent slots, paper on navy + for (const [p, w2] of [[[[122, 165], [112, 148], [132, 134], [118, 112]], 4.4], [[[130, 164], [138, 150], [124, 138], [136, 122]], 3.4]]) + o += shape(S, taper(bez(...p, 18), w2, .7), 'steam', { line: false }); + return svg('0 0 400 360', o, `${label} carton`); +} + +// ---- a real building at dusk: sky bands, snow ridge, cladding, sign, glass, pendants, street, lamp -------------------- +// The FIRST path is the sky (starts with "M-10 "): a page can cut it away by string surgery (svg.indexOf(' (y - 714) * .12, trap = (a, b) => poly([[x0 - sp(a), a], [x1 + sp(a), a], [x1 + sp(b), b], [x0 - sp(b), b]]); + o += flat ? shape(S, trap(714, 750), 'glow', { line: false }) + shape(S, trap(757, 764), 'glow', { line: false }) + shape(S, trap(771, 775), 'glow', { line: false }) : veil(S, trap(714, 790), 'glow', .2) } + o += strokeSw(S, 'M0 740H1200M0 770H1200' + Array.from({ length: 14 }, (_, k) => `M${k * 96 + 10} 715L${k * 96 - 60 + (k % 2) * 40} 800`).join(''), flat ? 'sky' : 'wall', 1.2); + // street lamp with a halo (rings in flat worlds, tone in line worlds) + const lp = flat ? 'sun' : 'woodDark'; // the post must read against the cladding + o += shape(S, rect(134, 190, 12, 524), lp) + shape(S, rect(127, 690, 26, 26), lp) + strokeSw(S, 'M140 214C140 150 158 138 188 146', lp, 8); + o += flat ? strokeSw(S, 'M160 180a30 30 0 1 0 60 0a30 30 0 1 0 -60 0', 'sun', 3) + strokeSw(S, 'M174 180a16 16 0 1 0 32 0a16 16 0 1 0 -32 0', 'sun', 3) : veil(S, 'M190 180m-46 0a46 46 0 1 0 92 0a46 46 0 1 0 -92 0', 'glow', .12) + veil(S, 'M190 180m-24 0a24 24 0 1 0 48 0a24 24 0 1 0 -48 0', 'glow', .22); + o += shape(S, 'M177 175Q190 192 203 175Z', 'glow') + shape(S, 'M168 176C170 158 178 148 190 147C202 148 210 158 212 176Z', flat ? 'ink' : 'woodDark', { shadow: 'sauce' }); + return svg('0 0 1200 800', o, `${sign} storefront ${night ? 'at night' : 'at dusk'}`); +} + +// ---- air ribbons: tapered strokes that thread through a building --------------------------------- +// flows = [{ path: [p0,p1,p2,p3, ...], sw: 'air', width: 22, z: 'back' | 'front' }]. Each ribbon is a centre line fattened by taper(), so it swells and +// ends in points. `path` chains cubic segments (4 points, 7, 10 ...). Chevrons along the ribbon show the direction of flow. +const chain = path => { const pts = []; for (let i = 0; i + 3 < path.length; i += 3) pts.push(...bez(path[i], path[i + 1], path[i + 2], path[i + 3], 18).slice(i ? 1 : 0)); return pts }; +export function ribbons(flows, S = STYLES.ink) { + return flows.map(({ path, sw, width = 20 }) => { const c = chain(path); let o = shape(S, taper(c, width, .6), sw); + for (const t of [.3, .5, .7]) { const i = Math.floor(c.length * t), [x, y] = c[i], a = Math.atan2(c[i + 1][1] - c[i - 1][1], c[i + 1][0] - c[i - 1][0]), k = width * .22, q = (da, l) => [x + Math.cos(a + da) * l, y + Math.sin(a + da) * l]; + o += strokeSw(S, open([q(2.5, k), [x + Math.cos(a) * k * .6, y + Math.sin(a) * k * .6], q(-2.5, k)]), 'paper', 1.6) } + return o }).join(''); +} +// A flat-roofed apartment block: lit windows come from data (lit = Set of 'floor,col'), with frames, curtains, balconies, a stairwell with a door, a roof vent, a snow parapet. +// Returns { body, vent } so the caller can layer ribbons behind it and in front of it; `vent` is where a stale-air ribbon starts. +export function block(key, { floors = 4, cols = 4, lit = new Set(), x = 170, y = 110, w = 330, h = 250 } = {}, S = STYLES.ink) { + const r = rng('block:' + key), dk = S.shade === 'flat' ? 'sky' : 'wallDark', sw = 46, cw = (w - sw) / cols, fh = h / floors, mid = Math.floor(cols / 2), cx = j => x + j * cw + (j >= mid ? sw : 0); + let o = shape(S, rect(x, y, w, h), 'wall') + shape(S, rect(x - 8, y - 12, w + 16, 12), 'ink', { line: false }); + o += shape(S, `M${x - 8} ${y - 12}Q${x + 40} ${y - 30} ${x + 90} ${y - 12}Q${x + 150} ${y - 34} ${x + 210} ${y - 12}Q${x + 270} ${y - 28} ${x + w + 8} ${y - 12}Z`, 'snow', { line: false }); + const vx = x + w * .8; o += shape(S, rect(vx, y - 40, 22, 28), dk) + shape(S, poly([[vx - 4, y - 40], [vx + 26, y - 40], [vx + 18, y - 48], [vx + 4, y - 48]]), 'ink', { line: false }); // roof vent + const sx = x + mid * cw; // stairwell: tall slim windows at half-floor steps + o += shape(S, rect(sx, y, sw, h), dk, { line: false }); + for (let i = 0; i < floors; i++) o += shape(S, rect(sx + 14, y + i * fh + 14 + (i % 2) * 8, sw - 28, fh - 32), i % 2 ? 'glass' : 'cladding', { line: false }); + for (let i = 0; i < floors; i++) for (let j = 0; j < cols; j++) { + const ox = cx(j) + 10, oy = y + i * fh + 12, ww = cw - 20, hh = fh - 26, on = lit.has(`${i},${j}`); + o += shape(S, rect(ox - 3, oy - 3, ww + 6, hh + 6), 'wallDark', { line: false }) + shape(S, rect(ox, oy, ww, hh), on ? 'glass' : 'cladding', { line: false }); + if (on && r() < .6) o += shape(S, poly([[ox, oy], [ox + ww * .4, oy], [ox, oy + hh * .7]]), 'sauce', { line: false }); // a curtain, drawn back + o += strokeSw(S, `M${ox + ww / 2} ${oy}V${oy + hh}M${ox} ${oy + hh * .45}H${ox + ww}`, 'wallDark', 2); + if (i > 0 && r() < .4) o += shape(S, rect(ox - 6, oy + hh + 3, ww + 12, 5), 'wallDark', { line: false }) + strokeSw(S, Array.from({ length: 6 }, (_, k) => `M${f(ox - 4 + k * (ww + 8) / 5)} ${oy + hh - 4}V${oy + hh + 3}`).join(''), 'wallDark', 1.4); // balcony + } + o += shape(S, rect(sx + 10, y + h - 46, sw - 20, 46), S.shade === 'flat' ? 'skyLow' : 'woodDark') + shape(S, rect(sx + 4, y + h - 52, sw - 8, 6), 'ink', { line: false }) + shape(S, blob(sx + sw / 2, y + h - 58, 4, 4, r, .05, 6), 'glow', { line: false }); // door, canopy, lamp + o += shape(S, rect(x - 8, y + h, w + 16, 12), dk, { line: false }) + shade(S, hatchIn(px => px > x + w - 40, [x, y, x + w, y + h], 60, 6)); + return { body: o, vent: [vx + 11, y - 48] }; +} +// The finished picture: ribbons behind the facade, the block, ribbons in front. The ribbon colours are their own swatches ('air', 'stale'), +// chosen to differ from the wall, so the flow is the loudest thing on the page. +export function blockScene(key, opts = {}, S = STYLES.ink) { + const r = rng('scene:' + key), b = block(key, opts, S), [vx, vy] = b.vent; + const flows = opts.flows ?? [ + { path: [[-30, 290], [150, 380], [470, 170], [680, 300]], sw: 'air', width: 22, z: 'back' }, // passes BEHIND the facade: seen on both sides, hidden across the block + { path: [[-20, 70], [90, 60], [100, 190], [210, 205]], sw: 'air', width: 18, z: 'front' }, // fresh air swoops in FRONT of the upper floors to a window + { path: [[vx, vy], [vx - 30, 10], [520, 20], [680, 10]], sw: 'stale', width: 16, z: 'front' }]; // stale air leaves the roof vent + let o = shape(S, 'M-10 -10H650V430H-10Z', 'skyDark', { line: false }); + for (const [px, py] of scatter(r, 28, () => true, 30, [10, 10, 630, 100])) o += shape(S, blob(px, py, 1.5, 1.5, r, .2, 5), 'paper', { line: false }); + o += shape(S, rect(-10, 372, 660, 60), S.shade === 'flat' ? 'ridge' : 'wallDark', { line: false }); + o += ribbons(flows.filter(q => q.z === 'back'), S) + b.body + ribbons(flows.filter(q => q.z !== 'back'), S); + return svg('0 0 640 420', o, 'Air ribbons through an apartment block'); +} +``` + +How to adapt each function to another subject: + +- **`plane(O, U, V)` and `carton`.** One affine map per face makes lids, labels and floors share a projection: `pt(u, v)` for polygon corners, `m` for an SVG `transform` that lays flat artwork (a receipt, a sticker, a label with the real name) onto the face. The same trick draws a parts crate, a sample bag, a parcel, a book. Keep a cast shadow on the ground, a lip shadow under the lid and hatching on the shaded wall; those three cues are what make it read as an object. +- **`storefront`.** The recipe, in the order it is painted: sky as the FIRST path (dusk bands from dark to bright plus glowing cloud lenses, or stars at night), a ridge whose snowline is knotted with two drip tongues running into the gullies, a roof lip, cladding drawn as vertical ribs with a string-light wire sagging under the lip, a crest, the sign pinned with `textLength` over an offset shadow, a ribbon under it (`sub`), the phone number as a second sign, a glass front (pendants on cords, TV, faceless diners each with their own lean, a raised glass, glass shine), a door plate repeating the number, a street with light spilling from each bay and paving slabs, and a street lamp with a halo (rings in flat worlds, tone in line worlds). Swap the ridge for the real horizon, the sign for the real sign, the diners' count for the real seating. Faceless silhouettes only: head, shoulders, a table, a glass. +- **`block`, `ribbons` and `blockScene`.** A building whose lit windows come from data (`lit`: which units, which hours): framed windows with a seeded curtain here and a balcony there, a stairwell strip with half-floor windows and a lit door under a canopy, a roof vent and a snow parapet. `ribbons()` fattens a centre line with `taper()` so it swells and ends in points, and puts chevrons along it for the direction of flow. The ribbon inks are their OWN swatches (`air` is a saturated blue, `stale` a grey), chosen to differ from the wall, so the flow is the loudest thing on the page; a ribbon in the wall colour reads as a crack. Layering is the point: `blockScene` draws the ribbons with `z: 'back'` first, then the block, then `z: 'front'`, so one ribbon passes BEHIND the facade (seen on both sides, hidden across the block), one swoops in FRONT of the upper floors to a window, and the stale one leaves the roof vent. Replace `flows` with your own `{ path, sw, width, z }` list: air, water, a delivery route, a flow of orders. Chain 4, 7 or 10 bezier points to make a ribbon curl. +- **Textures that are marks, not noise.** Char spots, rim highlights, brick coursing in running bond, tree rings, snow caps: a few uneven shapes seeded from the item, placed where the material would show them. + +Motif: signage text, the phone number, the real facade, the real horizon. Silhouettes of people have no faces. Ship the three scales: a full scene (hero), a cropped strip of it, such as the ridge with the sky cut away (divider), the sign or sticker alone (micro). + +### b. `procedural-subject`: models generated from data + +Fits: any catalogue where each item is different (menu, parts, plans, rooms). The drawing is a function of the item's record, so a new item is drawn without new art. The ara-bar pizzas work this way: the topping list in `specs.ts` is transcribed from `menu.ts`, so each dish on the page is the dish on the menu. + +```js +import { rng, rr, blob, poly, bez, taper, scatter, shape, strokeSw, shade, hatchIn, svg, f, STYLES } from './core.js'; + +// One topping = one small drawing. Rules learned in critique: no eyes (flecks spread evenly), no open arcs that read as a smile. +const TOP = { + pepperoni: (S, r) => shape(S, blob(0, 0, 10, 10, r, .035, 12), 'pepperoni', { shadow: 'shadow' }) + + (S.shade === 'hatch' ? `` : '') // dished centre: a tone, so line worlds only + + Array.from({ length: 6 }, (_, i) => { const a = i * 2.4 + rr(r, 0, 6), d = 2.5 + 5.5 * Math.sqrt((i + .5) / 6), k = rr(r, .6, 1); + return `` }).join(''), + olive: (S, r) => shape(S, blob(0, 0, 4.6, 3.4, r, .06, 8), 'olive') + shape(S, blob(.4, .2, 1.5, 1, r, .1, 6), 'paper', { line: false }), + basil: (S, r) => shape(S, 'M-9 0Q-4-8 9 0Q-4 8-9 0Z', 'basil') + strokeSw(S, 'M-8 0L7 0', 'ink', 1), + bacon: (S, r) => { const ph = rr(r, 0, 6), w = (x, k) => Math.sin(x * .45 + ph + k), top = [], bot = []; + for (let i = 0; i <= 6; i++) { const x = -10 + i * 20 / 6; top.push([x, w(x, 0) - 3.6]); bot.unshift([x, w(x, 1.3) + 3.6]) } + return shape(S, poly([...top, ...bot]), 'bacon') }, +}; +// Zones: a topping may be restricted to a region. 'quad:i' = one of four wavy-bounded quarters (a four-cheese), 'ring:a-b' = a radius band. +const zone = (z, R) => { + if (!z) return () => true; + const [kind, arg] = z.split(':'); + if (kind === 'quad') return (x, y) => { const a = Math.atan2(y, x) + Math.PI + .35 + .28 * Math.sin(Math.hypot(x, y) / 9); + return ((Math.floor(a / (Math.PI / 2)) % 4) + 4) % 4 === +arg }; + const [a, b] = arg.split('-').map(Number); + return (x, y) => { const d = Math.hypot(x, y) / R; return d >= a && d <= b }; +}; +const TAU = Math.PI * 2; + +// item = { id, rich?: true (fewer cheese blobs, more sauce showing), lift?: true (pull one slice out on cheese strands), cut?: 8 (cut lines), +// toppings: [{ k: 'pepperoni', n: 9, zone?: 'ring:.2-.8' }] } <- transcribed from the real menu +// The pie is built from ONE radial function rad(angle), so a lifted wedge and the body it left share exactly the same edge. +export function pizza(item, S = STYLES.ink, size = 'hero') { + const r = rng('pizza:' + item.id), R = 90, thumb = size === 'thumb', lift = item.lift && !thumb, k = S.wobble; + const ph = [rr(r, 0, TAU), rr(r, 0, TAU), rr(r, 0, TAU)]; + const rad = a => R * (1 + .018 * k * (Math.sin(3 * a + ph[0]) + .6 * Math.sin(7 * a + ph[1]) + .4 * Math.sin(13 * a + ph[2]))); // puffed, uneven rim + const pts = (m, a0, a1) => { const p = []; for (let a = a0; a < a1 + .001; a += .05) p.push([Math.cos(a) * rad(a) * m, Math.sin(a) * rad(a) * m]); return p }; + const rs = (R - 14) / R, rc = R - 22; + const mid = -.9, half = .4, w0 = mid - half, w1 = mid + half; // the lifted slice: a 46-degree wedge, upper right + const side = (x, y, m) => { if (!lift) return 'main'; let d = Math.atan2(y, x) - mid; d = Math.atan2(Math.sin(d), Math.cos(d)); + const e = Math.atan2(m, Math.max(Math.hypot(x, y), m)); return Math.abs(d) < half - e ? 'wedge' : Math.abs(d) > half + e ? 'main' : 'edge' }; + const out = { main: '', wedge: '' }; + // 1. body: crust, hatch band, scorch arcs, leopard char, sauce. Built once per piece (the main body, or the wedge) from the shared rad(). + const chars = Array.from({ length: thumb ? 9 : 24 }, () => ({ a: rr(r, 0, TAU), d: R - rr(r, 3, 10), s: rr(r, 1.4, 4.6), dark: r() < .35 })); + const scorch = [[rr(r, 0, TAU), rr(r, .3, .9)], [rr(r, 0, TAU), rr(r, .3, .8)], [rr(r, 0, TAU), rr(r, .2, .6)]]; + const body = (a0, a1) => { + let o = shape(S, poly([[0, 0], ...pts(1, a0, a1)]), 'crust', { shadow: 'shadow' }); + if (!thumb) o += shade(S, hatchIn((x, y) => { const a = (Math.atan2(y, x) + TAU - a0 % TAU) % TAU + a0 % TAU, h = Math.hypot(x, y); // inside this piece only + return h > rs * R - 2 && h < R - 3 && x + y > 20 && a >= a0 && a <= a1 }, [-R, -R, R, R], 135, 5)); + for (const [a, len] of scorch) { const A = Math.max(a, a0 + .02), B = Math.min(a + len, a1 - .02); // scorched edge: a dark arc hugging the rim + if (B > A) o += strokeSw(S, 'M' + pts(.965, A, B).map(q => `${f(q[0])} ${f(q[1])}`).join('L'), 'crustDark', 5) } + for (const c of chars) { if (c.a < a0 || c.a > a1) continue; const x = Math.cos(c.a) * c.d, y = Math.sin(c.a) * c.d; + o += shape(S, blob(x, y, c.s, c.s * .7, r, .3, 6, c.a), c.dark ? 'fleck' : 'crustDark', { line: false }) } + return o + shape(S, poly([[0, 0], ...pts(rs, a0, a1)]), 'sauce'); + }; + out.main = lift ? body(w1, w0 + TAU) : body(0, TAU); + if (lift) out.wedge = body(w0, w1); + // 2. cheese: overlapping melted blobs (never one slab) with sauce showing between them, plus a few blisters + const cheese = scatter(r, thumb ? 8 : item.rich ? 11 : 17, (x, y) => Math.hypot(x, y) < rc - 6 && side(x, y, 16) !== 'edge', thumb ? 17 : 15, [-R, -R, R, R], 1500); + for (const [x, y] of cheese) { const q = rr(r, 12, 18), to = side(x, y, 0); + out[to] += shape(S, blob(x, y, q, q * .84, r, .2, 9), 'cheese', { shadow: 'cheeseDark' }); + if (!thumb && r() < .5) out[to] += shape(S, blob(x + rr(r, -5, 5), y + rr(r, -4, 4), rr(r, 1.6, 3), rr(r, 1.2, 2.4), r, .2, 6), 'cheeseDark', { line: false }) } + // 3. toppings: one shared `placed` list across kinds, so an olive never sits on a pepperoni; fewer and bolder at thumb size + const placed = []; + for (const t of item.toppings) { + const n = thumb ? Math.ceil(t.n / 3) : t.n, big = thumb ? 1.4 : 1, z = zone(t.zone, R), gap = thumb ? 19 : { pepperoni: 22, olive: 13, basil: 14, bacon: 20 }[t.k] ?? 14; + for (const [x, y] of scatter(r, n, (x, y) => Math.hypot(x, y) < rc - 4 && z(x, y) && side(x, y, 11) !== 'edge' && placed.every(q => Math.hypot(q[0] - x, q[1] - y) > gap), gap, [-R, -R, R, R], 1500)) { + placed.push([x, y]); + out[side(x, y, 0)] += `${TOP[t.k](S, r)}`; + } + } + let o = out.main; + if (item.cut && !lift) o += strokeSw(S, Array.from({ length: item.cut }, (_, i) => { const a = (i + .5) * TAU / item.cut; return `M0 0L${f(Math.cos(a) * rad(a) * .985)} ${f(Math.sin(a) * rad(a) * .985)}` }).join(''), 'shadow', 1.6); + if (lift) { // 4. the lifted slice pivots about the centre; cheese strands bridge the gap + const th = 9 * Math.PI / 180, dx = Math.cos(mid) * 30, dy = Math.sin(mid) * 30; + const T = ([x, y]) => [x * Math.cos(th) - y * Math.sin(th) + dx, x * Math.sin(th) + y * Math.cos(th) + dy]; + o += shape(S, poly([[2, 4], ...pts(1, w0, w1).map(([x, y]) => [x + 2, y + 5])].map(T)), 'shadow', { line: false }); + for (const [a, m, sag] of [[w1, .3, 12], [w1, .55, 16], [w1, .78, 10], [w0, .62, 14]]) { const p0 = [Math.cos(a) * rad(a) * m, Math.sin(a) * rad(a) * m], p1 = T(p0); + o += shape(S, taper(bez(p0, [p0[0] + (p1[0] - p0[0]) * .3, p0[1] + sag], [p0[0] + (p1[0] - p0[0]) * .7, p1[1] + sag * .6], p1, 14), 3.4, .5), 'cheese', { shadow: 'cheeseDark', line: false }) } + o += `${out.wedge}`; + } + return svg(lift ? '-105 -125 230 230' : '-100 -100 200 200', o, '', `pizza pizza--${size}`); +} +``` + +What the craft is (keep these when you swap the subject): + +- **Layered construction, back to front**: cast shadow, body, hatch shading on the side away from the light, scorch arcs and char spots only where the material chars (the rim), the sauce, the cheese as overlapping blobs (17 by default; `rich` uses 11 so more sauce shows) with blisters, then the toppings. The cheese is never one slab. +- **One radial function builds every piece.** `rad(angle)` draws the puffed, uneven rim; the body and a lifted wedge are both polygons built from it, so they share an edge exactly. `lift: true` pulls a 46-degree slice out, pivoted about the centre with a shadow, and bridges the gap with four tapered cheese strands; `cut: 8` draws cut lines. Toppings and cheese are assigned to the wedge or the body by angle, and anything straddling the cut is rejected, so nothing hangs in the gap. The lifted slice is the hero shot; the whole pie is the divider; the thumb is the micro scale. +- **Distinct inks for distinct materials.** In `litho`, crust is yellow, cheese is paper, sauce is coral and char is navy; if crust and cheese share an ink they merge into one blob. The test fails when they do. +- **Zoned fills.** A topping may be restricted to a zone: `quad:i` gives four wavy-bounded quarters (a four-cheese pizza with each cheese in its own quarter), `ring:a-b` a radius band. Zones turn a list in the data into a visible structure. +- **Spacing across kinds.** One `placed` list is shared by every topping so nothing stacks. +- **Rules from critique** (they were caught in review): fat flecks spread evenly so a pepperoni never gets "eyes"; olives have an off-centre hole, not an open arc that reads as a smile; every shape is a closed blob. +- **Two sizes from one function.** `thumb` uses a third of the toppings at 1.4x and no hatching so it stays distinguishable at 96px. + +Motif: the topping list, the layers of a burger (`layers: ['patty', 'cheese', 'lettuce', 'sauce']` plus a dome and a tilt), the DN size read from a product label (a filter drawing that reads W x H out of "592x592" and sizes itself, a heater that reads its kW). For a non-food catalogue the same skeleton holds: body shape from the record, parts placed in zones, marks that carry the record's fields. + +### c. `traced-line`: build-time edge detection of real photos + +Fits: any subject with real product photos; turns pasted catalogue photos into one-colour drafting line art. Run once at build time; the page ships a mask PNG per photo and a `bounds.json`. This is the hitataekni `design-d/scripts/trace.ts` pipeline, generalised. + +Pipeline: greyscale, **paint out baked-in junk first** (warranty roundel, floor reflection, watermark: set those regions to the background value so they never produce edges), gaussian blur, Sobel gradient, non-maximum suppression (edges one pixel wide), hysteresis (a weak edge survives only when connected to a strong one), despeckle, dilate, bounding box. Every stage is a pure function on typed arrays, so it runs and tests without an image library; only `main()` needs `sharp`. + +```js +// trace.mjs: build-time. Photo -> white-line RGBA PNG on transparent + bounds.json. Run: node trace.mjs +// Pipeline: grey -> mask baked-in junk -> gaussian blur -> Sobel + non-max suppression -> hysteresis -> despeckle -> dilate -> bbox. +// The stages are pure functions on Float32Array/Uint8Array so they run (and test) without an image library; only main() needs `sharp`. +export function gaussian(src, w, h) { // separable 5-tap binomial blur + const k = [1, 4, 6, 4, 1], tmp = new Float32Array(src.length), out = new Float32Array(src.length), cl = (v, m) => Math.min(m - 1, Math.max(0, v)); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let s = 0; for (let i = -2; i <= 2; i++) s += k[i + 2] * src[y * w + cl(x + i, w)]; tmp[y * w + x] = s / 16 } + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let s = 0; for (let i = -2; i <= 2; i++) s += k[i + 2] * tmp[cl(y + i, h) * w + x]; out[y * w + x] = s / 16 } + return out; +} +export function sobelNms(g, w, h) { // gradient magnitude, thinned to 1px along the gradient direction + const m = new Float32Array(g.length), dir = new Uint8Array(g.length), out = new Float32Array(g.length), O = [[1, 0], [1, 1], [0, 1], [-1, 1]]; + for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; + const gx = -g[i - w - 1] - 2 * g[i - 1] - g[i + w - 1] + g[i - w + 1] + 2 * g[i + 1] + g[i + w + 1]; + const gy = -g[i - w - 1] - 2 * g[i - w] - g[i - w + 1] + g[i + w - 1] + 2 * g[i + w] + g[i + w + 1]; + m[i] = Math.hypot(gx, gy); const d = ((Math.atan2(gy, gx) * 180 / Math.PI) % 180 + 180) % 180; + dir[i] = d < 22.5 || d >= 157.5 ? 0 : d < 67.5 ? 1 : d < 112.5 ? 2 : 3 } + for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x, [dx, dy] = O[dir[i]]; + if (m[i] >= m[i + dy * w + dx] && m[i] >= m[i - dy * w - dx]) out[i] = m[i] } + return out; +} +export function hysteresis(mag, w, h, lo, hi) { // Canny: a weak edge survives only when connected to a strong one + const out = new Uint8Array(mag.length), st = []; + for (let i = 0; i < mag.length; i++) if (mag[i] >= hi) { out[i] = 1; st.push(i) } + while (st.length) { const i = st.pop(); + for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const j = i + dy * w + dx; + if (j >= 0 && j < mag.length && !out[j] && mag[j] >= lo) { out[j] = 1; st.push(j) } } } + return out; +} +export function despeckle(mask, w, h, min = 1) { // keep a pixel only when >= min of its 8 neighbours are set + const out = new Uint8Array(mask.length); + for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) { const i = y * w + x; if (!mask[i]) continue; + let n = 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if ((dx || dy) && mask[i + dy * w + dx]) n++; + if (n >= min) out[i] = 1 } + return out; +} +export function dilate(mask, w, h) { // 2x2 grow: 1px lines become a legible stroke + const out = new Uint8Array(mask.length); + for (let y = 0; y < h - 1; y++) for (let x = 0; x < w - 1; x++) { const i = y * w + x; if (mask[i] || mask[i + 1] || mask[i + w] || mask[i + w + 1]) out[i] = 1 } + return out; +} +export function bbox(mask, w, h) { // [x0, y0, x1, y1, h] of the drawn pixels: where dimension lines attach + let x0 = w, y0 = h, x1 = 0, y1 = 0; + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (mask[y * w + x]) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y) } + return [x0, y0, x1, y1, h]; +} +// opts: hi (strong threshold; lo = 0.4 hi), blank: [[x0,y0,x1,y1] in fractions] regions painted background BEFORE edge detection +// (a "5-year warranty" roundel, a floor reflection), keep: [x0,y0,x1,y1] fractions outside which edges are dropped, thick: dilate twice. +export function trace(grey, w, h, { hi = 120, blank = [], keep, thick = false, bg = 255 } = {}) { + const g = Float32Array.from(grey); + for (const [a, b, c, d] of blank) for (let y = Math.floor(b * h); y < Math.min(h, Math.ceil(d * h)); y++) for (let x = Math.floor(a * w); x < Math.min(w, Math.ceil(c * w)); x++) g[y * w + x] = bg; + let mask = despeckle(hysteresis(sobelNms(gaussian(g, w, h), w, h), w, h, hi * .4, hi), w, h, 1); + if (keep) { const [a, b, c, d] = keep; for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (x < a * w || x > c * w || y < b * h || y > d * h) mask[y * w + x] = 0; mask = despeckle(mask, w, h, 2) } + mask = dilate(mask, w, h); if (thick) mask = dilate(mask, w, h); + return { mask, bounds: bbox(mask, w, h) }; +} +export const toRGBA = (mask, w, h) => { const o = Buffer.alloc(w * h * 4); for (let i = 0; i < mask.length; i++) { o[i * 4] = o[i * 4 + 1] = o[i * 4 + 2] = 255; o[i * 4 + 3] = mask[i] ? 255 : 0 } return o }; + +// ---- CLI: node trace.mjs (needs `npm i -D sharp`) ---- +const FILES = { 'valve-dn25': { src: 'scraped/valve-dn25.jpg', hi: 120, blank: [[.76, .7, 1, 1], [0, .84, 1, 1]] } }; // per-photo tuning lives here +async function main() { + const sharp = (await import('sharp')).default, { writeFile } = await import('node:fs/promises'), bounds = {}; + for (const [name, { src, ...o }] of Object.entries(FILES)) { + const { data, info } = await sharp(src).resize({ width: 1200 }).greyscale().raw().toBuffer({ resolveWithObject: true }); + const { mask, bounds: b } = trace(data, info.width, info.height, o); bounds[name] = b; + await sharp(toRGBA(mask, info.width, info.height), { raw: { width: info.width, height: info.height, channels: 4 } }).png({ compressionLevel: 9 }).toFile(`public/art/${name}.png`); + } + await writeFile('src/art/bounds.json', JSON.stringify(bounds, null, 1) + '\n'); +} +if (import.meta.url === `file://${process.argv[1]}`) await main(); +``` + +Tuning: `hi` is the strong threshold (lower it for pale products, raise it for busy backgrounds); `keep` drops edges outside a box (use it for a hero render with sky and roof deck); `thick` dilates twice for a cover-size line. Keep per-photo values in `FILES`, not in the pipeline. Look at every output over the real background colour and re-tune; also paint closure strokes by hand where a panel vanishes in the photo's shadow. + +Show it as a coloured mask, so the line colour stays a token: + +```css +.trace { background: var(--line); -webkit-mask: var(--src) center / contain no-repeat; mask: var(--src) center / contain no-repeat; } +``` +```html + +``` + +Pair it with real numbers: dimension lines at the traced bounds, labels copied from spec rows (`DN 15-25`), never invented. The `dim()` and `traceDims()` helpers are in `art/draft.js` (section d); `traceDims(bounds[name], { h: 'DN 15-25', v: '2-VEGA' })` puts a horizontal span under the object and a vertical span beside it, each with extension lines, arrowheads and a label, in the page's line colour. Perf: the PNG is one small mask per product. The whole drafting plate (the mask, the dimensions, the title block) draws in with the `pathLength` line-draw variant in section f. + +### d. `mechanism-diagram`: cutaways and exploded views with callouts + +Fits: products with parts, processes with steps (an air handling unit, a filter swap, a roof build-up, a lock). Plain SVG from a parts array: each part is a real drawn mechanism, not a labelled box; numbered callouts, a section cut with hatching, a scale bar, dimension lines, and a parts list cross-referenced to products or categories. The hitataekni cutaway is built exactly like this: wheel with honeycomb rings and 16 spokes, zig-zag pleated filter, coil with tube ends, backward-curved fan, eleven callouts, section A-A. + +```js +import { f, hatchIn, inPoly } from './core.js'; + +// Drafting kit: strokes only, one colour (`currentColor` / var(--line)), numbers copied from real spec rows. +export const polar = (cx, cy, r, deg) => [cx + r * Math.cos(deg * Math.PI / 180), cy + r * Math.sin(deg * Math.PI / 180)]; +export const spokes = (cx, cy, r0, r1, n, off = 0) => Array.from({ length: n }, (_, i) => { const [x0, y0] = polar(cx, cy, r0, off + i * 360 / n), [x1, y1] = polar(cx, cy, r1, off + i * 360 / n); return `M${f(x0)} ${f(y0)}L${f(x1)} ${f(y1)}` }).join(''); +export const ring = (cx, cy, r) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`; +// backward-curved fan blades: each is a quarter-arc from the hub to the rim, bent against the rotation +export const blades = (cx, cy, r0, r1, n) => Array.from({ length: n }, (_, i) => { const a = i * 360 / n, [x0, y0] = polar(cx, cy, r0, a), [x1, y1] = polar(cx, cy, r1, a + 38); + return `M${f(x0)} ${f(y0)}Q${f(polar(cx, cy, (r0 + r1) / 2, a - 10)[0])} ${f(polar(cx, cy, (r0 + r1) / 2, a - 10)[1])} ${f(x1)} ${f(y1)}` }).join(''); +export const zigzag = (x, y, w, h, n) => `M${x} ${y + h}` + Array.from({ length: n }, (_, i) => `L${f(x + (i + .5) * w / n)} ${y}L${f(x + (i + 1) * w / n)} ${y + h}`).join(''); +// perpendicular ticks along a wall: grease build-up, ruler marks, scale bars +export const ticks = (x0, y0, x1, y1, n, len = 6) => { const dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy), nx = -dy / L * len, ny = dx / L * len; + return Array.from({ length: n }, (_, i) => { const t = (i + .5) / n; return `M${f(x0 + dx * t)} ${f(y0 + dy * t)}l${f(nx)} ${f(ny)}` }).join('') }; + +const T = (x, y, s, o = {}) => `${s}`; + +// Dimension line: extension lines from the object's edge, an arrow-headed span, a label from a spec row. +// axis 'h': measures a..b along x, the line sits at y=at, extension lines start at y=from. axis 'v' is the same turned 90 degrees. +export function dim({ axis = 'h', a, b, at, from, label, head = 14 }) { + const dir = Math.sign(at - from) || 1, past = at + dir * 8, mid = (a + b) / 2, hh = head * .4; + return axis === 'h' + ? `${T(mid, at - 8, label, { anchor: 'middle' })}` + : `${T(at - 8, mid, label, { anchor: 'middle', rot: -90 })}`; +} +// Dimension a traced object from its bounds.json entry [x0, y0, x1, y1, h]: horizontal span under it, vertical span beside it. +export const traceDims = ([x0, y0, x1, y1], { h, v }, gap = 26) => dim({ axis: 'h', a: x0, b: x1, at: y1 + gap, from: y1 + 2, label: h }) + dim({ axis: 'v', a: y0, b: y1, at: x1 + gap, from: x1 + 2, label: v }); + +// Honeycomb: flat-topped hexagons on a grid, kept when their centre lies inside the ring r0..r1. A heat-recovery wheel, a filter media, a core. +export const honeycomb = (cx, cy, r0, r1, c = 7.5) => { let d = ''; + for (let i = -Math.ceil(r1 / (1.5 * c)); i <= Math.ceil(r1 / (1.5 * c)); i++) for (let j = -Math.ceil(r1 / (1.73 * c)) - 1; j <= Math.ceil(r1 / (1.73 * c)) + 1; j++) { + const x = i * 1.5 * c, y = (j + (i & 1) * .5) * Math.sqrt(3) * c, h = Math.hypot(x, y); if (h < r0 || h > r1 - c * .6) continue; + d += 'M' + Array.from({ length: 6 }, (_, k) => polar(cx + x, cy + y, c * .94, k * 60).map(f).join(' ')).join('L') + 'Z' } + return d }; + +// Air handling unit, section A-A. PARTS come from the dossier: each part is a real product category. +// unitBody() returns the markup alone (so sheet() can place it); unit() wraps it as a standalone . +export function unitBody(PARTS, { w = 760, title = 'Section A-A', scale = '1 m' } = {}) { + const x0 = 24, y0 = 40, x1 = x0 + w - 48, y1 = y0 + 200, cy = (y0 + y1) / 2, step = (x1 - x0) / PARTS.length; + const casing = ``; + // the cut wall, hatched: hatchIn() returns PATH DATA, so it must be wrapped in a ; bare, it prints as stray text inside the svg + const cut = [[x0, y0, x1, y0 + 8], [x0, y1 - 8, x1, y1]].map(([a, b, c, d]) => ``).join(''); + const bays = PARTS.map((p, i) => { const cx = x0 + step * (i + .5); + return `${i ? `` : ''}${p.draw(cx, cy, step)}` + + `${T(cx, y1 + 40, p.n, { anchor: 'middle', size: 12 })}${T(cx, y1 + 62, p.label, { anchor: 'middle', size: 10 })}` }).join(''); + const sb = `${T(x0 + 50, y1 + 100, scale, { anchor: 'middle', size: 11 })}`; + const air = ``; + return { body: casing + cut + air + bays + sb + T(x1, y1 + 100, title, { anchor: 'end' }), box: { x0, y0, x1, y1, cy } }; +} +export function unit(PARTS, { w = 760, h = 330, ...o } = {}) { + const title = o.title ?? 'Section A-A'; + return `${title}, ${PARTS.length} numbered parts: ${PARTS.map(p => p.label).join(', ')}${unitBody(PARTS, { w, ...o }).body}`; +} + +// A drawing SHEET: double border, zone markers (A-F across, 1-8 down), a faint grid, the unit with its dimensions and flow labels, extra leader callouts, +// and a title block fed by facts. Everything is strokes + text in currentColor, so the page sets one colour (and one background) for the whole plate. +// opts: { name, rows: [[key, value] x up to 3], flow: { in, out }, dimL, dimH, notes: [{ n, label, at: [x, y] in unit coordinates, label at: [tx, ty] }] } +export function sheet(PARTS, { w = 1100, h = 640, name = 'Section A-A', rows = [], flow, dimL, dimH, notes = [], zones = [6, 8], scale = '1 m' } = {}) { + const [zx, zy] = zones, X0 = 30, Y0 = 30, X1 = w - 30, Y1 = h - 30, ox = 130, oy = 120, u = unitBody(PARTS, { title: name, scale }), { x0, y0, x1, y1, cy } = u.box; + let g = ``; + let zm = ''; // zone markers in the border band + for (let i = 0; i < zx; i++) { const x = X0 + (i + .5) * (X1 - X0) / zx; zm += T(x, 24, 'ABCDEFGHIJ'[i], { anchor: 'middle', size: 11 }) + T(x, h - 14, 'ABCDEFGHIJ'[i], { anchor: 'middle', size: 11 }); if (i) g += `` } + for (let j = 0; j < zy; j++) { const y = Y0 + (j + .5) * (Y1 - Y0) / zy; zm += T(19, y + 4, j + 1, { anchor: 'middle', size: 11 }) + T(w - 19, y + 4, j + 1, { anchor: 'middle', size: 11 }); if (j) g += `` } + let grid = ''; for (let x = X0 + 20; x < X1; x += 20) grid += `M${x} ${Y0}V${Y1}`; for (let y = Y0 + 20; y < Y1; y += 20) grid += `M${X0} ${y}H${X1}`; + g = `` + g + zm; + g += `${u.body}`; + for (const { n, label, at: [ax, ay], to: [tx, ty] } of notes) // leader callouts: a dot on the part, a leader, a numbered circle and a label + g += `${T(tx, ty, n, { anchor: 'middle', size: 12 })}${T(tx + 16, ty, label, { size: 11 })}`; + g += ''; + if (flow) g += T(ox + x0 - 22, oy + cy - 12, flow.in, { anchor: 'end', size: 11 }) + T(ox + x1 + 22, oy + cy - 12, flow.out, { size: 11 }); + if (dimL) g += dim({ axis: 'h', a: ox + x0, b: ox + x1, at: oy + y0 - 34, from: oy + y0 - 4, label: dimL }); // overall length over the casing + if (dimH) g += dim({ axis: 'v', a: oy + y0, b: oy + y1, at: w - 80, from: ox + x1 + 20, label: dimH }); // overall height, right of the unit + const tb = rows.slice(0, 3), th = 28 * (tb.length + 1), bx = X1 - 330, by = Y1 - th; // title block, bottom right, fed by facts + g += `${T(bx + 8, by + 19, name, { size: 14 })}`; + tb.forEach(([k, v], i) => { g += T(bx + 8, by + 28 * (i + 1) + 18, k, { size: 9 }) + T(bx + 118, by + 28 * (i + 1) + 19, v, { size: 12 }) }); + return `${name}: ${PARTS.map(p => `${p.n} ${p.label}`).join(', ')}${g}`; +} + +// Each part is 3-6 primitives, and its silhouette must differ from every other part at 96px. +const wheel = (cx, cy, s) => { const R = Math.min(s * .46, 56), c = f(cx), y = f(cy); + return `` }; +export const PARTS_DEMO = [ + { n: 1, label: 'Damper', draw: (cx, cy) => `` + Array.from({ length: 7 }, (_, i) => ``).join('') }, + { n: 2, label: 'Filter', draw: (cx, cy) => `' }, + { n: 3, label: 'Heat wheel', draw: (cx, cy, s) => wheel(cx, cy, s) }, + { n: 4, label: 'Coil', draw: (cx, cy) => `` + Array.from({ length: 9 }, (_, i) => ``).join('') }, + { n: 5, label: 'Fan', draw: (cx, cy, s) => `` }, + { n: 6, label: 'Silencer', draw: (cx, cy) => [-30, -10, 10, 30].map(o => ``).join('') }, +]; +export const SHEET_DEMO = { + name: 'Section A-A', rows: [['VERKEFNI', 'Loftræsing 3. hæð'], ['MKV', '1:20'], ['DAGS', '1963-2025']], flow: { in: 'ÚTILOFT', out: 'FRÁLOFT' }, dimL: 'L = 4 200', dimH: 'H = 1 300', + notes: [{ n: 7, label: 'Casing, 50 mm', at: [170, 234], to: [170, 324] }, { n: 8, label: 'Drain DN 25', at: [410, 234], to: [410, 324] }], +}; +``` + +How to use it: + +- **One bay per part.** `PARTS` comes from the dossier: each part is a real product category with a `draw(cx, cy, bayWidth)` that returns strokes. Compose `draw` from the helpers (`polar`, `spokes`, `ring`, `blades`, `zigzag`, `ticks`); a new part is 3-6 primitives, and its silhouette must differ from every other part at 96px (a heat wheel is not a fan). +- **Drafting conventions carry the credibility**: double-line casing with a hatched wall section, dash-dot centre-lines (`22 5 4 5`), airflow arrows on dashes, a scale bar, a title with the section name, numbers in circles with a leader. +- **`sheet(PARTS, opts)` makes it a drawing sheet**: a double border, zone markers (A-F across, 1-8 down), a faint grid, the unit with its overall `L`/`H` dimensions through `dim()`, flow labels at both ends of the dashed airflow, extra numbered leader callouts (`notes`: a dot on the part, a leader, a circled number and a label), and a title block (`rows`: key and value pairs such as the project, the scale `1:20`, the year) fed by dossier facts. Everything is strokes and text in `currentColor`. The heat wheel is a real honeycomb (`honeycomb()` keeps hexagon cells inside a ring) with 16 spokes in the hub, and the hatched casing wall is `` elements: `hatchIn()` returns path data, never markup, so always wrap it. `draft.test.js` fails if path data is printed as text. +- **Dimensions are data.** `dim({ axis, a, b, at, from, label })` draws extension lines from the object's edge, an arrow-headed span and a mono label. The label is copied from a spec row (a DN range, a voltage, a service interval such as "6 MAN."), never invented. +- **Other subjects**: a hood section with grease build-up drawn as `ticks()` along the walls; an exploded filter swap with numbered steps; a roof build-up with each layer a hatched band; a lock with its pins in bays. + +The `
    ` of parts is the text equivalent and the way into the shop: + +```html +
    1. 1. Sía
    2. 2. Hitahjól
    +``` + +Use `currentColor` (the page sets `color: var(--line)` on the plate) so the drawing is one token, strokes only, and no fill can break the blueprint grammar. + +### e. `annotated-photo`: leader callouts glued to a real photo + +Fits: products photographed whole (a rooftop unit, a machine, a building) where the parts named in the spec rows are visible. Callouts are an overlay in percent coordinates, so they stay on the part at any width. + +```html +
    + Swegon rooftop unit + + Heat recovery +
    +``` +```css +.stage { position: relative; aspect-ratio: 1600 / 1000 } +.stage img, .stage svg { position: absolute; inset: 0; width: 100%; height: 100% } +.lead { fill: none; stroke: var(--ink); stroke-width: 1.5; vector-effect: non-scaling-stroke } +.tag { position: absolute; translate: 0 -100%; font: 600 .75rem var(--mono) } +``` + +Map image pixels to percent in one helper (`px / imgW * 100`) so coordinates come from the photo, not by eye. Label text comes from the product's own spec labels. A white casing line under the ink line keeps it readable over any pixel. + +### f. `scroll-scene`: one variable drives a drawn explanation + +Fits: a process or journey the company explains (air through a building, a filter clogging over twelve months, a route). One `--p` (0-1) per pinned section, written by one listener; every part of the scene reads it. + +```js +// one rAF listener, passive; writes --p (0-1) on each .spread. Narrow screens and reduced motion get the FINISHED state, not a pinned scene. +const spreads = [...document.querySelectorAll('.spread')], flat = matchMedia('(max-width: 720px), (prefers-reduced-motion: reduce)'); +let ticking = false; +const update = () => { ticking = false; for (const s of spreads) { + if (flat.matches) { s.style.setProperty('--p', 1); continue } + const b = s.getBoundingClientRect(), p = Math.min(1, Math.max(0, -b.top / (b.height - innerHeight))); + s.style.setProperty('--p', p.toFixed(3)); } }; +addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(update) } }, { passive: true }); +addEventListener('resize', update); flat.addEventListener('change', update); +document.documentElement.classList.add('scenes'); update(); +``` +```css +@property --n { syntax: ''; inherits: true; initial-value: 1 } +/* the default IS the finished state: no JS, narrow, print and reduced motion all see it */ +.spread__stage { --p: 1; --n: calc(1 + var(--p) * 11) } +.ribbon { stroke-dasharray: 1; stroke-dashoffset: calc(1 - var(--p)) } /* */ +.clog { transform-origin: bottom; transform: scaleY(var(--p)) } +.needle { transform: rotate(calc(-84deg + var(--p) * 84deg)) } +.month::after { counter-reset: n var(--n); content: counter(n) } +/* motion is the enhancement: pin only on wide screens, with JS, when motion is allowed */ +@media (min-width: 721px) and (prefers-reduced-motion: no-preference) { + .scenes .spread { height: 300svh } + .scenes .spread__stage { position: sticky; top: 0; height: 100svh } +} +/* progressive enhancement: no JS, off-main-thread */ +@supports (animation-timeline: view()) { @media (prefers-reduced-motion: no-preference) { + .reveal { animation: rise linear both; animation-timeline: view(); animation-range: entry 0% cover 40% } } } +@keyframes rise { from { opacity: 0; transform: translateY(24px) } to { opacity: 1; transform: none } } +``` + +Animate `stroke-dashoffset`, `transform`, `opacity` and `@property` counters only. Reduced motion and narrow screens show the **finished** state (`--p: 1`, unpinned). Never hide content until it animates in. Motif: the scene is the explanation of what the company sells; each spread ends on the product that does that job. Forbidden as the hero in `operate` mode (section 3). + +Line-draw variant (drafting in): `` with `.plate:not(.is-drafted) .draft{stroke-dasharray:1 2;stroke-dashoffset:1}` and `.is-drafted .draft{transition:stroke-dashoffset 420ms linear var(--delay)}`; set `--d` from each stroke's distance to the title-block corner, add `is-drafted` once from an `IntersectionObserver`. + +### Scaling line weight with the plate (applies to f, c, d) + +When a drawing scales (a plate at 360px and at 1200px), set `--k` from a ResizeObserver (`plate.style.setProperty('--k', width/1000)`) and use `stroke-width: calc(1.5px / var(--k))` and label sizes in the same variable, so strokes and labels stay legible. + +### h. `material-texture`: the surface the artefact is made of + +Fits: any concept whose antecedent is a physical thing (paper menu, wooden table, brushed plate, blueprint, gouache). The texture is the material, never a generic noise overlay. + +```css +/* fine paper grain: data-URI feTurbulence on a pseudo-element */ +.paper::after { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .06; mix-blend-mode: multiply; + background: url("data:image/svg+xml;utf8,") } +/* wood grain: noise stretched along one axis */ +.wood { background: url("data:image/svg+xml;utf8,"), var(--wood-base) } +/* brushed metal, no image: noise + streaks */ +.brushed { background: repeating-linear-gradient(90deg, rgb(255 255 255 / .06) 0 1px, rgb(0 0 0 / .05) 1px 3px), var(--metal) } +/* blueprint grid: 16px minor, 80px major */ +.grid { background: + linear-gradient(var(--line-major) 1px, transparent 1px) 0 0 / 80px 80px, + linear-gradient(90deg, var(--line-major) 1px, transparent 1px) 0 0 / 80px 80px, + linear-gradient(var(--line-minor) 1px, transparent 1px) 0 0 / 16px 16px, + linear-gradient(90deg, var(--line-minor) 1px, transparent 1px) 0 0 / 16px 16px, var(--blue) } +/* perforated ticket edge: semicircle mask */ +.ticket { mask: radial-gradient(circle 6px at 0 50%, #0000 98%, #000) left / 51% 20px repeat-y, + radial-gradient(circle 6px at 100% 50%, #0000 98%, #000) right / 51% 20px repeat-y } +``` + +```html + + +``` + +Torn or deckled edges: `clip-path: var(--torn)` set from `torn(seed)` in §1, with a lighter back plate behind it and a `drop-shadow` filter on the wrapper for a lifted look. Pick the texture that matches the material (fine paper grain, coarse concrete, brushed aluminium). + +Perf: a live filter is per-pixel; use the data-URI on a pseudo-element or a pre-rendered 256-512px tile, restrict it to sections, and **never animate the filter**. Opacity: 2-4% subtle, 5-8% visible, 10-15% strong. Measure text contrast over the texture. + +### i. `print-process`: the constraint of the ink is the style + +Fits: litho posters, letterpress handbills, risograph, one-ink halftones. The restriction is the identity: a fixed ink set, no gradients, no opacity, shadows as offset flat ink. + +```js +// art/print.js: a print-process world = a fixed ink set. Every swatch of the base palette snaps to its nearest ink. +import { STYLES } from './core.js'; +export const INKS = ['#5fb0cf', '#ffc417', '#e0643f', '#1d2a4a', '#f4ead2', '#4a8a5a']; // sky, yellow, coral, navy, paper, green: "six inks, nothing else" +const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); +const dist = (a, b) => Math.hypot(...rgb(a).map((v, i) => v - rgb(b)[i])); +export const nearest = (hex, inks = INKS) => inks.reduce((m, c) => dist(c, hex) < dist(m, hex) ? c : m); +// One drawing, a third world: the line palette snapped to the inks, flat fills, offset shadows, no outline. +export const press = { ...STYLES.litho, name: 'press', palette: Object.fromEntries(Object.entries(STYLES.ink.palette).map(([k, v]) => [k, nearest(v)])) }; +``` +```css +/* halftone from one gradient */ +.halftone { background: radial-gradient(circle, var(--ink) 30%, transparent 31%) 0 0 / 8px 8px } +/* ink-by-ink load. Drawings carry literal fills AND data-sw names, so either selector works (never var() fills: they break both) */ +.poster [data-sw="sky"] { animation: ink .5s both } +.poster [data-sw="cheese"] { animation: ink .5s 200ms both } +.poster [fill="#e0643f"] { animation: ink .5s 400ms both } /* by ink hex: the same press, third plate */ +@keyframes ink { from { opacity: 0 } to { opacity: 1 } } +@media (prefers-reduced-motion: reduce) { .poster [data-sw], .poster [fill] { animation: none } } +``` + +The misregistration shadow is already in `shape()` (`shadow: 'shadow'` offsets the same path by `S.misregister`, flat worlds only), so a litho drawing needs no extra code. Add a third world by mapping the same swatch names to the six inks with `nearest()`, as `press` does above: the poster, the letterpress handbill and the match-night flyer can then be one set of drawings. + +**Enforce the constraint with a test** (this is the style-rule test the art brief names): render every subject at every size and assert no opacity, no gradient, no filter and every colour in the ink set; also assert determinism and that swatch names and hexes agree. A test that passes on a drawing with a stray `opacity` is a test that does not bite: check it fails when you add one. The same goes for the markup itself: `wellFormed()` (in `art/wellformed.js`) rejects unbalanced tags and any text node outside ``, which is how a path-data string pasted into an SVG (the drafting-kit hatch bug) is caught; the test file proves it bites by feeding it that exact mistake. Run it over every subject at every size. + +```js +// art/wellformed.js: a small well-formedness check for the SVG strings the engine returns (no XML library needed). +// Balanced tags, quoted attributes, and NO bare text outside /: a path-data string pasted into the markup is exactly such a text +// node, and a renderer silently draws nothing for it. +import assert from 'node:assert/strict'; +export function wellFormed(svg) { + const stack = [], re = /<(\/?)([a-zA-Z][\w-]*)((?:\s+[\w:-]+="[^"]*")*)\s*(\/?)>|([^<]+)/gy; let m, pos = 0; + while (pos < svg.length) { re.lastIndex = pos; m = re.exec(svg); if (!m) throw new Error('malformed markup near: ' + svg.slice(pos, pos + 60)); pos = re.lastIndex; + if (m[5] !== undefined) { if (m[5].trim() && !['text', 'title'].includes(stack.at(-1))) throw new Error('stray text node: ' + m[5].slice(0, 60)); continue } + if (m[4]) continue; + if (m[1]) { if (stack.pop() !== m[2]) throw new Error('unbalanced </' + m[2] + '>') } else stack.push(m[2]) } + assert.deepEqual(stack, [], 'unclosed tags'); + return true; +} +``` + +```js +// art/art.test.js: the constraint is the style, so enforce it. Run: bun test (or: node --test) +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { STYLES, inkSet } from './core.js'; +import { pizza } from './pizza.js'; +import { INKS, press } from './print.js'; +import { carton, storefront, block, blockScene, ribbons } from './scene.js'; +import { wellFormed } from './wellformed.js'; + +const item = { id: 'diavola', toppings: [{ k: 'pepperoni', n: 9 }, { k: 'olive', n: 8 }, { k: 'basil', n: 4 }] }; +const doc = s => s.startsWith('<svg') ? s : `<svg>${s}</svg>`; // block() returns bare markup +const SUBJECTS = { // every subject the art brief lists, at every size it ships + 'pizza hero': S => pizza(item, S, 'hero'), + 'pizza lifted slice': S => pizza({ ...item, lift: true }, S, 'hero'), + 'pizza thumb': S => pizza({ ...item, toppings: item.toppings.slice(0, 2) }, S, 'thumb'), + carton: S => carton({ label: 'ARA' }, S), + storefront: S => storefront('ara', {}, S), + 'storefront night': S => storefront('ara', { night: true }, S), + block: S => block('b', { lit: new Set(['0,1']) }, S).body + ribbons([{ path: [[0, 0], [50, 0], [50, 90], [100, 90]], sw: 'air', width: 12 }], S), + 'block scene': S => blockScene('b', { lit: new Set(['0,1', '2,2']) }, S), +}; + +test('wellFormed bites: it rejects stray path data and unclosed tags', () => { + assert.throws(() => wellFormed('<svg><g>M10 10L20 20</g></svg>'), /stray text/); + assert.throws(() => wellFormed('<svg><g></svg>'), /unbalanced/); + assert.ok(wellFormed('<svg><text x="1">M ok</text><path d="M0 0"/></svg>')); +}); + +for (const [name, draw] of Object.entries(SUBJECTS)) { + for (const S of [STYLES.ink, STYLES.litho]) test(`${S.name} ${name}: well-formed, no stray text`, () => wellFormed(doc(draw(S)))); + test(`litho ${name}: only the ink set, no opacity, no gradients, no filters`, () => { + const out = draw(STYLES.litho), inks = inkSet(STYLES.litho); + assert.doesNotMatch(out, /opacity|gradient|filter|<image/i); + for (const [, hex] of out.matchAll(/(?:fill|stroke)="(#[0-9a-fA-F]{6})"/g)) assert.ok(inks.has(hex), `${hex} is not an ink`); + }); + test(`${name}: same key, same drawing`, () => assert.equal(draw(STYLES.ink), draw(STYLES.ink))); + test(`${name}: every drawn colour carries its swatch name`, () => { + const out = draw(STYLES.ink); + for (const [, hex, sw] of out.matchAll(/fill="(#[0-9a-fA-F]{6})"[^>]*?data-sw="(\w+)"/g)) assert.equal(STYLES.ink.palette[sw], hex); + }); +} +test('press: every swatch snaps to one of the six inks', () => { + for (const hex of Object.values(press.palette)) assert.ok(INKS.includes(hex), hex); + assert.doesNotMatch(carton({}, press), /opacity|gradient/); +}); +test('draw once, style many: at equal wobble the litho shapes are a subset of the ink shapes', () => { + const d = S => new Set([...pizza({ id: 'x', toppings: [{ k: 'olive', n: 4 }] }, S).matchAll(/ d="([^"]+)"/g)].map(m => m[1])); + const ink = d(STYLES.ink); for (const g of d({ ...STYLES.litho, wobble: STYLES.ink.wobble })) assert.ok(ink.has(g)); +}); + +// ---- the drawings are what they claim to be (each assertion fails on the generic version) ---- +const count = (s, re) => (s.match(re) ?? []).length; +test('pizza: melted cheese is many overlapping blobs with char on the crust; crust and cheese are different inks in litho', () => { + const p = pizza(item, STYLES.ink); + assert.ok(count(p, /data-sw="cheese"/g) >= 12); + assert.ok(count(p, /data-sw="crustDark"/g) + count(p, /data-sw="fleck"/g) >= 20, 'char spots'); + assert.notEqual(STYLES.litho.palette.crust, STYLES.litho.palette.cheese); +}); +test('pizza: a lifted slice adds a second group and cheese strands; the thumb never lifts', () => { + assert.match(pizza({ ...item, lift: true }, STYLES.ink), /<g transform="translate\([-\d.]+ [-\d.]+\) rotate\(9\)">/); + assert.doesNotMatch(pizza(item, STYLES.ink), /rotate\(9\)/); + assert.doesNotMatch(pizza({ ...item, lift: true }, STYLES.ink, 'thumb'), /rotate\(9\)/); +}); +test('storefront: first path is the sky; sign, sub-ribbon, phone sign, cladding ribs, lamp and snow are all drawn', () => { + const s = storefront('ara', { sign: 'ARA', phone: '554 3050' }, STYLES.ink); + assert.ok(s.includes('<path d="M-10 -10') && s.indexOf('<path d="M-10 -10') < s.indexOf('data-sw="ridge"')); + for (const t of ['>ARA<', '>RESTAURANT<', '>554 3050<']) assert.ok(s.includes(t), t); + assert.match(s, /textLength="318"/); + assert.ok(count(s, /data-sw="rib"/g) >= 1 && count(s, /data-sw="snow"/g) >= 3 && count(s, /data-sw="sun"/g) >= 5); +}); +test('ribbons: the ribbon ink differs from the wall ink, and a back ribbon is drawn before the facade, a front one after', () => { + for (const S of [STYLES.ink, STYLES.litho]) { assert.notEqual(S.palette.air, S.palette.wall); assert.notEqual(S.palette.air, S.palette.skyDark) } + const s = blockScene('b', {}, STYLES.ink), air = [...s.matchAll(/data-sw="air"/g)].map(m => m.index), wall = s.indexOf('data-sw="wall"'); + assert.ok(air.some(i => i < wall) && air.some(i => i > wall), 'air ribbon both behind and in front of the facade'); + assert.match(s, /data-sw="stale"/); +}); +``` + +```js +// art/draft.test.js: the drafting kit draws what it claims. Run: node --test +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unit, sheet, honeycomb, dim, PARTS_DEMO, SHEET_DEMO } from './draft.js'; +import { wellFormed } from './wellformed.js'; + +test('unit: the hatched casing is real <path> elements, not path data printed as text', () => { + const s = unit(PARTS_DEMO); + assert.ok(wellFormed(s)); + assert.doesNotMatch(s, />\s*M[\d.-]+[\s,][^<]*</, 'a text node that starts with M is stray path data'); + assert.ok((s.match(/<path d="M[\d.]+ [\d.]+L[\d.]+ [\d.]+M/g) ?? []).length >= 2, 'hatch paths exist (top wall and bottom wall)'); +}); +test('unit: the honeycomb heat wheel has dozens of hexagonal cells, all inside the wheel', () => { + const d = honeycomb(0, 0, 10, 50, 7); + assert.ok((d.match(/Z/g) ?? []).length >= 30); + for (const [, x, y] of d.matchAll(/M(-?[\d.]+) (-?[\d.]+)/g)) assert.ok(Math.hypot(+x, +y) < 52, 'cell outside the wheel'); +}); +test('sheet: border, zone markers A-F and 1-8, title block from facts, leader callouts, dimensions and flow labels', () => { + const s = sheet(PARTS_DEMO, SHEET_DEMO); + assert.ok(wellFormed(s)); + for (const t of ['A', 'F', '1', '8', 'VERKEFNI', 'MKV', '1:20', 'L = 4 200', 'H = 1 300', 'ÚTILOFT', 'FRÁLOFT', 'Casing, 50 mm', 'Heat wheel']) assert.ok(s.includes(`>${t}<`), t); + assert.equal((s.match(/>[A-F]</g) ?? []).length, 12, 'six letters on top and bottom'); + assert.equal((s.match(/>[1-8]</g) ?? []).length >= 16, true, 'eight numbers on left and right'); + assert.doesNotMatch(s, />\s*M[\d.-]+[\s,][^<]*</); +}); +test('sheet: only the title block fed by facts changes with the facts', () => { + const a = sheet(PARTS_DEMO, SHEET_DEMO), b = sheet(PARTS_DEMO, { ...SHEET_DEMO, rows: [['VERKEFNI', 'Eldhúsháfur'], ['MKV', '1:10'], ['DAGS', '2025']] }); + assert.notEqual(a, b); assert.ok(b.includes('>Eldhúsháfur<') && !a.includes('Eldhúsháfur')); +}); +test('dim: extension lines, two arrowheads and a label from the spec row', () => { + const s = dim({ axis: 'h', a: 0, b: 100, at: 40, from: 10, label: 'DN 15-25' }); + assert.ok(s.includes('>DN 15-25<')); assert.equal((s.match(/L/g) ?? []).length >= 4, true); +}); +``` + +Photos become one-ink halftones (see o). + +### j. `typographic`: the trade's own lettering as the picture + +Fits: subjects with strong lettering heritage and no imagery (a founding year, a route number, a ledger, signage, a stamp). Keep real text real. + +```html +<svg viewBox="0 0 600 200" role="img" aria-label="Founded 1963"> + <defs><path id="arc" d="M30 170 Q300 -40 570 170"/></defs> + <text font-family="var(--display)" font-size="40" letter-spacing=".08em" fill="var(--ink)"><textPath href="#arc" startOffset="50%" text-anchor="middle">HITATÆKNI · SÍÐAN</textPath></text> +</svg> +``` +```css +.year { font: 800 clamp(8rem, 28vw, 24rem)/.8 var(--display); color: transparent; -webkit-text-stroke: 2px var(--ink); font-variation-settings: "wght" 800 } +.fill-pattern { background: var(--pattern) 0 0/12px 12px; -webkit-background-clip: text; background-clip: text; color: transparent } +``` + +A giant numeral, a word repeated as texture, text on a path. Do not split into per-letter spans without `aria-label` on the parent. Verify the face's glyphs for the content language (§6). + +### k. `generative-pattern`: a mark or tile grid seeded by real values + +Fits: per-item, per-section or per-person art that is reproducible and unique (a mark per SKU, a divider per section, a fabric or tile pattern from names). Generate once at build time. + +```js +import { rng } from './core.js'; +export function sunburst(key, { wedges = 24, r = 600, core = 'var(--yellow)' } = {}) { + const rnd = rng('sun:' + key), start = rnd() * 15; // each section: its own tilt + const w = 360 / wedges; + const d = Array.from({ length: wedges / 2 }, (_, i) => { const a0 = (start + i * 2 * w) * Math.PI / 180, a1 = a0 + w * Math.PI / 180; + return `M0 0L${(r * Math.cos(a0)).toFixed(1)} ${(r * Math.sin(a0)).toFixed(1)}L${(r * Math.cos(a1)).toFixed(1)} ${(r * Math.sin(a1)).toFixed(1)}Z` }).join(''); + return `<svg viewBox="${-r} ${-r / 2} ${2 * r} ${r / 2}" aria-hidden="true" preserveAspectRatio="xMidYMax slice"><path d="${d}" fill="var(--ray)"/><circle r="60" fill="${core}"/></svg>`; +} +// perpendicular ticks along a path or wall: grease hatch, rulers, scale bars +export const ticks = (x0, y0, x1, y1, n, len = 6) => { + const dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy), nx = -dy / L * len, ny = dx / L * len; + return Array.from({ length: n }, (_, i) => { const t = (i + .5) / n, x = x0 + dx * t, y = y0 + dy * t; + return `M${x.toFixed(1)} ${y.toFixed(1)}l${nx.toFixed(1)} ${ny.toFixed(1)}` }).join('') }; +``` + +Seed with real facts (name, coordinates, founding year, SKU): ring counts map to years, angle to wind direction, tick count to the DN size. Never use `Math.random`; never encode information only in the pattern. + +### l. `data-diagram`: a bespoke chart form from real data + +Fits: when the subject has numbers or structure (hours, ranges, histories, comparisons). The chart IS the illustration, and a default bar chart is not. + +```js +// radial opening-hours clock from hours data: 0-24h ring, open spans as arcs, Fri/Sat run past midnight +const hours = [{ day: 'Fös', open: 11, close: 25 }]; // 25 = 01:00 next day, as in the dossier +const arc = (r, a0, a1) => { const p = a => [r * Math.cos((a - 90) * Math.PI / 180), r * Math.sin((a - 90) * Math.PI / 180)]; + const [x0, y0] = p(a0), [x1, y1] = p(a1); return `M${x0.toFixed(1)} ${y0.toFixed(1)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}` }; +export const clock = hours.map((h, i) => `<path d="${arc(40 + i * 8, h.open * 15, h.close * 15)}" stroke="var(--ink)" stroke-width="5" fill="none"/>`).join(''); +``` + +Also: a gauge from polar arcs and ticks; a departure-board timetable with today's row highlighted; a strata, tide or ticker-tape form. Always ship a text or table equivalent (`<table>` visually hidden, or the real hours list beside it). For colour, legend and axis rules defer to the dataviz guidance. + +### m. `shader`: only when the shader is the device + +Fits: a subject whose material is flowing or reactive (water, heat, aurora, ink, dither, terrain). Not a decoration for a business that does not move. + +```js +// shader.js: one full-screen triangle, a noise field. Every GL call that can fail is checked, so a bad driver degrades to the CSS fallback. +const c = document.querySelector('canvas.field'), gl = c.getContext('webgl', { antialias: false, powerPreference: 'low-power' }); +const still = matchMedia('(prefers-reduced-motion: reduce)'); +function program(vs, fs) { + const sh = (type, src) => { const o = gl.createShader(type); gl.shaderSource(o, src); gl.compileShader(o); + if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(o)); return o }; + const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); + gl.bindAttribLocation(p, 0, 'a'); // bind BEFORE linking: location 0 is then guaranteed, not driver luck + gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); return p; +} +try { + if (!gl) throw new Error('no webgl'); + const p = program('attribute vec2 a;void main(){gl_Position=vec4(a,0,1);}', + `precision mediump float;uniform vec2 r;uniform float t; + float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} + float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);} + void main(){vec2 u=gl_FragCoord.xy/r*vec2(r.x/r.y,1.)*3.;float v=n(u+t*.05)*.6+n(u*2.-t*.03)*.4; + gl_FragColor=vec4(mix(vec3(.04,.1,.14),vec3(.2,.55,.6),smoothstep(.35,.75,v)),1.);}`); + gl.useProgram(p); + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + const R = gl.getUniformLocation(p, 'r'), T = gl.getUniformLocation(p, 't'); + let on = true, raf = 0, t0 = 0; + const draw = t => { gl.uniform1f(T, t / 1000); gl.drawArrays(gl.TRIANGLES, 0, 3) }; + const frame = t => { raf = 0; if (!on || still.matches) return; draw(t); raf = requestAnimationFrame(frame) }; + const size = () => { const k = Math.min(devicePixelRatio, 2) * .75; c.width = c.clientWidth * k; c.height = c.clientHeight * k; + gl.viewport(0, 0, c.width, c.height); gl.uniform2f(R, c.width, c.height); + draw(t0); // resizing clears the canvas: ALWAYS redraw, also in reduced-motion mode + if (!still.matches && !raf) raf = requestAnimationFrame(frame) }; + new IntersectionObserver(([e]) => { on = e.isIntersecting; if (on && !still.matches && !raf) raf = requestAnimationFrame(frame) }).observe(c); + new ResizeObserver(size).observe(c); // also fires once on observe(): that is the first paint + still.addEventListener('change', size); // user flips reduced motion: stop, or restart, and repaint + c.addEventListener('webglcontextlost', e => { e.preventDefault(); cancelAnimationFrame(raf); raf = 0 }); + c.addEventListener('webglcontextrestored', () => location.reload()); // simplest correct recovery for a single-program page +} catch (err) { c.hidden = true; console.warn('shader off, CSS fallback shown:', err.message) } // .field-wrap already paints the static fallback +``` + +The checks matter: `getShaderParameter(COMPILE_STATUS)` and `LINK_STATUS` turn a silent black canvas into an error with the driver's log, and the catch shows the CSS fallback; `bindAttribLocation` before `linkProgram` guarantees attribute 0; a resize clears the canvas, so `size()` always redraws (otherwise a reduced-motion user, who gets one still frame and no animation loop, sees a blank canvas after rotating the phone); the reduced-motion media query is also listened to, so flipping the setting repaints or restarts correctly. + +Render at 0.5-1x resolution, cap DPR at 2, pause off-screen, `aria-hidden="true"`, no text inside the canvas, give the section a static CSS fallback (`.field-wrap { background: <the still colours> }`), and check text contrast against the **brightest and darkest frames**. Use a library (OGL, Three.js) only if the device needs more than a quad. **Forbidden at hero scale in operate and read modes** (see section 3). + +### n. Custom icon set (at micro scale) + +Every project ships its own icons, not a library, unless the repo has an icon system (extend mode) or an operate surface uses one library consistently. 24-unit grid, one stroke width, `currentColor`, one quirk drawn from the subject (squared ends for engineering, brushed ends for craft, cut corners for stone), as a `<symbol>` sprite. + +```html +<svg width="0" height="0" style="position:absolute" aria-hidden="true"> + <symbol id="i-valve" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="square"> + <path d="M3 17h18M12 17V8M8 8h8M6 12l12 5"/></symbol> +</svg> +<button><svg width="20" height="20" aria-hidden="true"><use href="#i-valve"/></svg>Panta varahlut</button> +``` + +Icon-only buttons need a text label (`aria-label` or visually hidden text). Compute drawn icons from primitives where you can (a football from a hexagon ring). + +### o. Photos transformed, not pasted + +Photos as supporting material in a scene or pattern treatment are transformed into the world's material. Under photography or product-ui, and for identity assets (headshots, partner logos, product screens), the image ships faithfully: art direction by crop, sequence, size and grade only. The snippets below apply to the transformed case. + +```css +.cutout { mix-blend-mode: multiply } /* white-ground cut-out onto painted paper */ +.mono-plate img { filter: grayscale(1) contrast(1.05); mix-blend-mode: multiply } /* one-signal-colour rule */ +.warm img { filter: sepia(.55) saturate(1.6) hue-rotate(-6deg) } /* match the illustrations */ +.pinned { padding: 8px 8px 22px; background: var(--paper); transform: rotate(var(--tilt, 1.2deg)) } /* pinned print */ +``` + +Crop or clip baked-in badges and watermarks (`object-position`, a `clip-path`, or paint them out before tracing). Under photography or product-ui, only crop and clip badges; do not transform. In a scene or pattern treatment: where there is no photo, draw it with a technique above; where a photo must stay, make it a one-ink halftone (`mix-blend-mode: multiply` over a `.halftone`), a greyscale plate or a traced line. + +## 3. Lead-device tables + +Subject type to lead device: + +| Subject type | Lead device | Support | +|---|---|---| +| Place / travel | Traced SVG silhouette or contour lines, seeded | Grain, scroll-drawn route | +| Craft / material | Grain, texture, CSS surface pattern | Typographic numerals, custom icons | +| Tech / product | Shader or generative field, diagram of the real mechanism | Icon set, mono type | +| History / institution | Typographic illustration, data timeline | Halftone, stamp CSS art | +| Data-heavy | Bespoke data diagram | Seeded marks | + +World to natural execution (guides execution only; the roll already picked the technique): + +| World | Natural technique | +|---|---| +| blueprint | traced-line + mechanism-diagram | +| childrens-book | seeded-illustration + scroll-scene | +| mid-century-travel-poster | print-process | +| industrial-datasheet | annotated-photo | +| letterpress-wood-type | typographic + print-process | +| cartographic | generative contour (generative-pattern) | +| japanese-consumer-electronics-80s | material-texture + annotated-photo | +| risograph-zine, punk-zine, newsprint-tabloid | print-process (halftone, misregistration) | +| nautical-chart, field-guide-naturalist | seeded-illustration + data-diagram | +| apothecary-label, illuminated-manuscript | typographic + material-texture | +| board-game-rulebook, video-game-menu | mechanism-diagram + generative-pattern | +| scientific-journal, pharmaceutical-clinical | data-diagram + mechanism-diagram | +| transit-signage, wayfinding-aicher | typographic + generative-pattern (pictograms on a grid) | +| terminal-hacker, aviation-hud, trading-floor | data-diagram (live readouts); shader only if it is the device | +| swiss-international, bauhaus, de-stijl, constructivist, op-art, art-deco-geometric | generative-pattern (grid, geometry as the mark) | +| mid-century-corporate, dieter-rams-braun, scandinavian-functionalism | mechanism-diagram (clean product line art) + material-texture | +| vintage-analog, grunge-carson, brutalist-web, neo-brutalism | material-texture + typographic | +| museum-gallery, editorial-newspaper, japanese-editorial, luxury-fashion-editorial | annotated-photo + typographic (restrained) | +| sports-broadcast, memphis, y2k-frutiger-aero, psychedelic | generative-pattern or shader (when the world is the energy) | + +For any world not listed, read its grammar and materials and apply the same rule: take the physical thing its antecedent is made of and draw it. + +### Hero-forbidden devices per mode + +The roll refuses these techniques as the **hero** device (they stay allowed at plate, divider or micro scale). It mirrors `HERO_FORBIDDEN` in `scripts/roll.mjs`: + +| Mode | Forbidden as hero | Why | +|---|---|---| +| `operate` | `shader`, `scroll-scene` | people are doing a task: no live canvas, no pinned scroll story between them and the form | +| `read` | `shader` | long reading: a moving field fights the text | +| `persuade`, `experience` | none | the hero may carry the full device | + +If every device a concept offers is forbidden for the mode, the roll keeps the concept and notes it: draw the device at plate and micro scale and (scene) open the page with a static drawing. When you hand-pick a device (a reroll or a veto), apply the same table. + +## 4. Banned forms and the owner test + +- No faces on food (a pepperoni that reads as a smiling face, breadsticks that look like fingers were both caught in critique), no kawaii, no mascots, no emoji-blobs. +- No stock icon libraries as the identity. +- No generic gradient blobs, meshes or noise-only heroes where a drawing of the subject belongs. +- No AI-image look. +- Staff or owner portraits only if the dossier allows them. + +Test: *"Would the owner print this on their menu, van or datasheet?"* If not, redraw. + +## 5. Budget and performance + +- One hero device, one texture, one motion moment, under about 100KB excluding photos. +- Never animate filters. Pre-render heavy textures as tiles. +- Generate SVG once at build or load; draw canvas once unless it animates; cap DPR at 2. +- Seeded everything: a render must be identical every load. + +## 6. Accessibility + +- Decorative SVG gets `aria-hidden="true"`. Meaningful SVG gets `role="img"` plus `<title>` (and `aria-labelledby`), tested in context. +- Diagrams get a text or table equivalent; never encode information only in a pattern. +- Reduced motion shows the final state; narrow screens too. Nothing is hidden until it animates. +- Contrast is measured over the texture and the worst frame. Decorative saturated fields stay on graphic plates; reading, prices and forms sit on a high-contrast field. +- **Verify glyphs** for the content language: render the hero copy with the assigned face and look for fallback boxes or misleading shapes (þ ð Á Í Ö). Swap or patch a face that fails, and pin which weights ship. + +## 7. Art brief, gallery and critique loop (scene treatment, before any UI page code) + +1. **Write `.vary/art-brief.md`** with these sections: + 1. Subjects: an exhaustive list from the dossier, each with its fact ids. + 2. Engine and file layout (`art/core.js`, `art/<subject>.js`, `art/styles.js`, `art/art.test.js`). + 3. Style object(s), one per world. + 4. Banned forms (§4 plus the world's refuse list). + 5. Sizes: hero, and thumb at 96px. + 6. The style-rule test to write (for litho: "no opacity, no gradients, only the six inks"). + 7. The 3 placements of the device: hero, divider, micro. + 8. **Objects table**: every drawn object (§1e) with its silhouette parts (a train: roof line, bogies, windows, door, couplers; a book: spine, boards, headband, page block), the three value steps it uses, and for each person the posture or gesture and the cast shadow. A box standing for a thing is not an entry. + 9. **Section skeleton table**: one row per section below the fold, assigning one of the six skeletons below to it and naming the plate's subject. No two consecutive rows may share a skeleton. + 10. **Shared plate frame**: the shared ground line (% of plate height), key-light direction, glow source and grain level, one value for all plates; and **per-plate rows**: subject, fact ids, skeleton, size at desktop and phone. The lead fills the illustrator template's placeholders ({GROUND_Y}, {LIGHT_DIR}, sizes) from these; illustrators never choose them. + 11. **Headline surface**: the drawn surface in the hero scene that carries the business name AND a one-line value proposition (sign, board, title block, ticket), with both strings, rendered as a real `<h1>` plus value line plus action over that surface (§1b pattern), and the **audience mood need** in one line (children: bright, safe, daytime; bereavement: calm, quiet, dignified; B2B lab: precise, trustworthy) that the concept and world must serve. +2. **Build the art module** (plates are drawn by parallel illustrator subagents per `art-crew.md`; the lead integrates) and `art-gallery.html` rendering every subject at both sizes. Write the style-rule test and run it. +3. **Screenshot the gallery** with Playwright, `npx agent-browser` or the browser MCP, and **Read the PNGs**. Look; do not assume. +4. **Write `.vary/critique-r1.json`** (the critique list also checks, per drawn object, the §1e rules: real silhouette not a box, at least 3 value steps, posture and cast shadow for people; and per section: no two consecutive sections share a skeleton, no plate is a labelled box, no fact is shown twice, the hero carries name and value proposition on a drawn surface) as `[{"subject","problem","fix"}]` plus a 1-10 score per group. Problems are concrete ("pepperoni reads as a smiling face", "the heat wheel is indistinguishable from the fan at 96px"), never "could be better". +5. **Fix, re-render, re-screenshot, write `critique-r2.json`.** Two rounds, then build. Items still open after round 2 go into NOTES.md as `art: open — <item>` and the page ships; never start a third round. +6. No UI page code until round 2 passes. + +**Art all the way down (scene rule).** Every section below the fold carries its own drawn plate at real size: at least 40% of that section's width (never under a third — the gate fails plates under 35%) and 280px tall at desktop, never thumbnail-only, fully rendered with the §1d finish pass and at least 3 value steps (not a thin pale line diagram in a tinted box), drawn from that section's own facts (place, product, process, people, history). A catalogue, menu or services list draws EACH item: the art engine is a function of the item record (§1), so every row or card gets its own drawing from its own fields. A section that is only cards, text or a form is a failure unless it is the contact block (or the footer). The art brief lists one plate per section. Identity assets (headshots, sponsor or partner logos, client photos of people, product screenshots) are shown unaltered and are exempt from "draw each item"; data sections (schedule, pricing table, comparison, code) are exempt from plates. + +**Section skeletons (assign one per section in the brief; no two consecutive sections share a skeleton).** Without this, every section falls back to copy left and a small boxed diagram right. + +1. **Full-bleed plate with caption**: the drawing spans the section's full width; a short caption sits on a drawn surface in it or in a band under it. +2. **Catalogue of drawn items**: a row or grid where each item (product, dish, service, room) is its own drawing from its own record, with name and price beneath. +3. **Process strip**: 3-5 drawn steps left to right (or down on a phone), each step a drawing of the work at that stage, joined by a drawn path or line. +4. **Map or plan**: the place drawn from above (street plan, floor plan, site plan), with the real address and route marked in it. +5. **Big quote over art**: one real quote, set large, on a drawn surface inside a full-width scene (a wall, a window, a page), with its source. +6. **Ticket, label or form drawn as an object**: the booking, price list or enquiry presented as a ticket, shop label, order slip or ledger page that is itself the container, in the world's material. + +(scene) Every section plate is a drawing of that section's subject, never a labelled box with words in it. Each fact (price list, hours, address) is placed once in the body; the footer may repeat contact details only. A primary action may repeat (amounts inside a `<form>`, anything marked `data-repeat-ok`); code blocks are exempt. Identity assets and data sections are exempt as above. + +**Collision rule (every critique round, both viewports).** Every label, callout and sign text must be fully visible: no overlap with a drawn object, another label or a UI element; no object cutting through lettering ('REPAI_RERS'); no callout hidden behind a prop; no grey smear at the scene edge. Check the 1440x900 and the 390x844 screenshots separately, zoomed on each text; list every collision as a "reads as the wrong thing" item and fix it before the round closes. + +**Time budget.** The art loop is capped: two critique rounds, then build. Order: hero first, plates second, micro icons last. With the art crew, the illustrators' render-and-fix rounds (2 each, 3 for the hero, run in parallel, capped at about 20 minutes wall-clock) count as the plate pass; the lead's gallery review is a single read followed by one fix. Critic rounds (Step 4b) sit outside the art budget, are capped at 2 and may be skipped when time is short. If the gallery has been rendered 6 times, stop critiquing and build the page with the art you have; a shipped page with good art beats a perfect gallery and no page. Never start a third round to polish a plate. + +**Art crew and critic.** `art-crew.md` holds the illustrator brief, the self-critique checklist, the integration rules (one shared ground line and light direction, plate sizes from the brief) and the pre-ship critic rubric; the §0b rows are its 9-10 anchor. + +## 8. Generated raster images + +Only for photographic material code cannot draw, and only when `impeccable/scripts/generate-image.mjs` has a key. Label it synthetic in the dossier and transform it per §2o. Code-drawn art is preferred; a generated image is never the signature device. + +## 9. Bar: photography treatment + +The hero and sections are the client's own photos (dossier assets), art-directed, never redrawn or filtered. + +- The signature is the direction: a crop rule, a sequence and a consistent grade, with the asset paths cited in `.vary/visual-brief.md`. +- No `filter`, blend mode, overlay or grain on work images; identifiable people are never redrawn. +- Natural resolution at least 1.5x the rendered size; `width` and `height` set. +- Text over a photo is measured on the darkest and lightest pixel under it; no glass card. +- Critique checklist: focal subject not cropped by the nav; grade consistent across frames; every cited asset is used where the brief says; no stretched or upscaled frame; h1, value line and action in the fold. + +## 10. Bar: product-ui treatment + +The hero is a real capture or live UI of the product, shown faithfully. + +- The signature is a named workflow from the dossier visible in the capture, with real data and states; callouts allowed, a framed device allowed, the h1 may sit beside the capture. +- Captures at DPR 2, no blur, no halftone, no invented screens or numbers. +- Skeletons available: capture with callouts, comparison or pricing table, code sample. +- Critique checklist: the workflow is nameable from the capture; text in it is legible at the rendered size; data matches the dossier; callout leaders end on their element; at least one more section shows a second state. + +## 11. Bar: typography treatment + +Type is the picture. + +- The signature is a typographic system or lettering idea that encodes one dossier fact (a founding year as the grid, a route code as the display). +- A specimen at hero size, rendered with the content language's glyphs; at most two faces from the roll; hierarchy by size, weight and position. +- Under a black and white directive: two inks, no tints, no accent; rules, scale and spacing carry the design. +- Critique checklist: the fact the lettering carries is readable; glyphs are present; scale contrast is at least 3:1 between display and body; whitespace is intentional, not leftover. + +## 12. Bar: utility treatment + +No hero: the tool or document is the first screen. + +- Distinctiveness lives in details: numerals (tabular, aligned), empty states, error states, icons from one consistent library, copy only this product could say. +- Conventional navigation, forms and error handling; the world is applied through tokens. +- A visible h1 or page title in the first viewport. +- Critique checklist: the primary task is reachable in one action; tables align on the numerals; every empty and error state is designed; focus and keyboard paths complete; no decorative plate. diff --git a/plugins/design/skills/vary/references/thin-domain.md b/plugins/design/skills/vary/references/thin-domain.md deleted file mode 100644 index 731bf82..0000000 --- a/plugins/design/skills/vary/references/thin-domain.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: thin-domain -description: For categories with little cultural material of their own (SRE tooling, invoicing, generic B2B, internal admin). How to derive grounded physical antecedents from the audience's world, write them as probability-tagged candidates, and hand them to roll.mjs --grounded so an external pick lands on the tail instead of the cliché. Load from Step 0 only when the category is thin. ---- - -# Thin domains: grounded antecedents - -Some subjects resist every catalog roll because the world dealt has nothing to hold onto in the product's own culture. The fix is a second source of material: **physical antecedents from the audience's lived world**, picked from the tail of Mr Claude's own list by the script, not by Mr Claude. - -## 1. The physical-object method - -Ask: *what would this be as a physical artifact, and what did that world look like before the web?* Then: - -1. **Name three real antecedents, not one.** The first is the cliché (bank → vault; fitness → gym poster; dashboard → glass cards). The good answer lives at #2 or #3. -2. **Extract a constraint, not a texture.** A ledger is not "beige and lined"; it forces every figure to reconcile in a paired column. A cockpit panel is flush, not floating. A rulebook numbers things because they are cross-referenced. The constraint becomes a layout rule. -3. **Include graphic traditions, not only objects**: the notation, publications, identity programmes, data graphics and interfaces this audience reads daily (a surveyor's field book, a racing programme, a broadcast lower-third, a pharmacy label, a shipping manifest). -4. Reject any antecedent that is the category's standard page or its predictable opposite; spend at most one candidate on the product's own name or metaphor. - -Worked examples (condensed): - -| Subject | Cliché to skip | Better antecedents | Constraint extracted | -|---|---|---|---| -| SaaS monitoring dashboard | glass KPI cards | control-room gauge panel; ship's bridge cluster; household electricity meter | gauges with tick-marked ranges, flush console grid, colour only for signal state | -| Invoicing tool | fintech blue + gradient button | double-entry ledger; carbon-copy invoice pad; rubber date stamp | hairline-ruled rows, right-aligned tabular amounts, a stub region for totals, stamped status | -| Dev-tool docs | three feature tiles over a sidebar tree | aircraft maintenance manual; board-game rulebook; lab notebook | numbered callouts cross-referenced from prose; flat numbered index instead of a nested tree | -| Fitness app | hero photo + "Start your journey" | boxing-gym chalk round timer; flip-digit scoreboard; coach's logbook | flip-digit counters, a dated ledger spine for history, no progress rings | - -## 2. Write the candidates file - -Use this wording to yourself, then write the answer as JSON: - -> List 5 to 7 real, concrete physical antecedents — objects, places, rituals, printed matter, or graphic traditions — that this surface's actual audience would recognise from their own lived culture, not from this product's category. For each, give a one-line grounding and a probability from 0 to 1 estimating how likely you would be to reach for it if left alone. Give each a genuinely different probability; list them in the order you thought of them, not sorted. - -```json -[ - {"name": "ship's bridge instrument cluster", "p": 0.45, "why": "operators watch it for hours; every reading has a range and an alarm state"}, - {"name": "household electricity meter with a rolling counter", "p": 0.15, "why": "one number, mechanically honest, read at a glance"}, - {"name": "1970s NASA mission-control console", "p": 0.30, "why": "dense, monochrome, status lamps; the audience grew up on the imagery"}, - {"name": "railway signal box lever frame", "p": 0.05, "why": "physical interlocks make invalid states impossible"}, - {"name": "hospital patient monitor", "p": 0.05, "why": "waveform + number + alarm colour, nothing decorative"} -] -``` - -Save it (for example `.vary/candidates.json`; roll.mjs gitignores `.vary/`) and roll: - -```bash -node <skill-dir>/scripts/roll.mjs --mode <mode> --platform <p> --grounded .vary/candidates.json --project . -``` - -The script ranks by `p`, drops the top third (the queue you would have built anyway), and picks from the tail. It prints the pick as **GROUNDED ANTECEDENT** alongside the dealt WORLD. - -## 3. Fuse, do not blend - -- The **WORLD** supplies the skeleton grammar, motion envelope, type classes and refuse list. -- The **GROUNDED ANTECEDENT** supplies materials, the thesis, the memorable element, and any structural constraint you extracted in §1. -- **Product facts win every conflict**; clarity beats both. -- If the dealt world genuinely cannot carry the antecedent's constraint (a poster world for a reconciling-ledger constraint), that is a named factual ground: `--from <key> --reroll 1` once, keeping the same candidates file. Do not swap the antecedent for your #1 candidate: that is the argmax coming back through the side door. -- Discard the rest of the candidates list from the contract entirely; carrying a losing branch forward invites re-litigating a closed decision. - -Then continue at SKILL.md Step 2. diff --git a/plugins/design/skills/vary/scripts/check.mjs b/plugins/design/skills/vary/scripts/check.mjs index f897dcf..fb028e1 100755 --- a/plugins/design/skills/vary/scripts/check.mjs +++ b/plugins/design/skills/vary/scripts/check.mjs @@ -8,26 +8,43 @@ * values, banned marketing phrases and em-dash density. What needs eyes (silhouette, memorable * thing, promise-vs-execution) is NOT here — see evaluator-prompt.md. * - * Usage: node check.mjs <file-or-dir> [more paths…] [--json] [--strict] + * Project-specificity checks (read the printed contract, then the code): concept-contract (CONCEPT / + * FACTS / SIGNATURE DEVICE / SIGNATURE GRAPHIC filled), facts-in-build (cited facts reach the source), + * bespoke-graphics (a drawing source exists: inline SVG, SVG-from-code, canvas/webgl, SVG filters, generated + * masks), seeded-art (art code must not use Math.random), svg-a11y (large SVGs hidden or labelled). + * + * HERO TREATMENT (contract line, first word; default scene for persuade/experience, utility otherwise) changes bespoke-graphics: + * scene/pattern/data-visual need a drawing source; photography/product-ui need an <img>/<picture>/<video> (hero visual source) + * and get a photo-fidelity WARN for filters/blend modes/grayscale/halftone; typography/utility report INFO. WORLD: system: and + * WORLD: canon are exempt from the subject-blind warning. Platform ios/android adds .swift .kt .dart and reports fold as N/A. + * + * fold: with --fold=<url-or-built-html> runs fold-check.mjs (Playwright) on the built page; otherwise UNKNOWN. + * + * Usage: node check.mjs <file-or-dir> [more paths…] [--json] [--strict] [--fold=<url-or-built-html>] * Exit: 0 = no FAIL · 1 = at least one FAIL (with --strict, WARN also fails) · 2 = usage */ import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, extname } from 'node:path'; +import { join, extname, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const EXT = new Set(['.html', '.htm', '.css', '.scss', '.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.astro', '.mdx']); const args = process.argv.slice(2); const json = args.includes('--json'); const strict = args.includes('--strict'); +const foldArg = (args.find(a => a.startsWith('--fold=')) || '').slice(7); const paths = args.filter(a => !a.startsWith('--')); -if (!paths.length) { console.error('usage: node check.mjs <file-or-dir> [...] [--json] [--strict]'); process.exit(2); } +if (!paths.length) { console.error('usage: node check.mjs <file-or-dir> [...] [--json] [--strict] [--fold=<url-or-built-html>]'); process.exit(2); } -function walk(p, out = []) { +const SKIP_DIRS = ['node_modules', '.git', 'dist', '.next', 'build']; +function walk(p, out = [], exts = EXT, skip = SKIP_DIRS) { const st = statSync(p); - if (st.isDirectory()) { for (const f of readdirSync(p)) { if (['node_modules', '.git', 'dist', '.next', 'build'].includes(f)) continue; walk(join(p, f), out); } } - else if (EXT.has(extname(p).toLowerCase())) out.push(p); + if (st.isDirectory()) { for (const f of readdirSync(p)) { if (skip.includes(f)) continue; walk(join(p, f), out, exts, skip); } } + else if (exts.has(extname(p).toLowerCase())) out.push(p); return out; } -const files = paths.flatMap(p => walk(p)).map(f => ({ file: f, text: readFileSync(f, 'utf8') })); -const all = files.map(f => f.text).join('\n'); +const load = exts => paths.flatMap(p => walk(p, [], exts)).map(f => ({ file: f, text: readFileSync(f, 'utf8') })); +let files = load(EXT); +let all = files.map(f => f.text).join('\n'); const checks = []; const ev = (file, idx, text) => ({ file, line: text.slice(0, idx).split('\n').length, snippet: text.slice(Math.max(0, idx - 40), idx + 80).replace(/\s+/g, ' ').trim() }); function find(re, fn) { for (const f of files) { let m; const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); while ((m = r.exec(f.text))) fn(f, m); } } @@ -146,6 +163,126 @@ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - checks.push({ id: 'template-tells', status: tells.length >= 3 ? 'FAIL' : tells.length ? 'WARN' : 'PASS', evidence: tells.map(t => ({ tell: t })), detail: tells.length ? tells.join('; ') : 'none of the common template tells detected' }); } +// ---- project-specificity checks: contract parsing ----------------------------------------------------------- +function contractOf(text) { + const i = text.indexOf('SEED KEY:'); if (i < 0) return null; + const s = Math.max(text.lastIndexOf('<!--', i), text.lastIndexOf('/*', i)); const ends = ['-->', '*/'].map(e => text.indexOf(e, i)).filter(x => x >= 0); + const e = ends.length ? Math.min(...ends) : -1; + return s >= 0 && e >= 0 && e - s < 8000 ? text.slice(s, e + 3) : text.slice(Math.max(0, i - 200), i + 3000); +} +const contractFile = files.find(f => f.text.includes('SEED KEY:')); +const contract = contractFile ? contractOf(contractFile.text) : null; +const modeM = contract && contract.match(/SEED KEY:[^\n]*?mode\s+(\w+)/); +const mode = modeM ? modeM[1].toLowerCase() : null; +const line = key => { const m = contract && contract.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`, 'm')); return m ? m[1].trim() : null; }; +const factLines = (() => { if (!contract) return []; const ls = contract.split('\n'); const i = ls.findIndex(l => /^\s*FACTS:/.test(l)); if (i < 0) return []; const out = []; for (const l of ls.slice(i + 1)) { if (/^\s*[-*]\s+/.test(l)) out.push(l.replace(/^\s*[-*]\s+/, '')); else if (l.trim()) break; } return out; })(); +const unfilled = v => v === null || v === '' || /<model\b|<what\b|<fill/i.test(v); +const CODE_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']); +const worldLine = line('WORLD'); const isCanon = !!worldLine && /^(canon\b|system:)/i.test(worldLine); +const platformM = contract && contract.match(/SEED KEY:[^\n]*?platform\s+(\w+)/); +const native = !!platformM && /^(ios|android)$/i.test(platformM[1]); +if (native) { files = load(new Set([...EXT, '.swift', '.kt', '.dart'])); all = files.map(f => f.text).join('\n'); } +// HERO TREATMENT: first word of the contract line; default scene for persuade/experience, utility otherwise (roll.mjs / SKILL Step 3a) +const TREATMENTS = ['scene', 'pattern', 'photography', 'product-ui', 'typography', 'data-visual', 'utility']; +const treatmentWord = (line('HERO TREATMENT') || '').split(/\s/)[0].toLowerCase(); +const treatment = TREATMENTS.includes(treatmentWord) ? treatmentWord : (mode === 'persuade' || mode === 'experience' || !mode ? 'scene' : 'utility'); + +// ---- 11. concept contract --------------------------------------------------------------------------------------- +{ + if (!contract) checks.push({ id: 'concept-contract', status: 'UNKNOWN', evidence: [], detail: 'no contract block to read (see seed-key-present)' }); + else { + const concept = line('CONCEPT'); + if (concept === null || /^none\b/i.test(concept)) { + checks.push({ id: 'concept-contract', status: isCanon ? 'PASS' : 'WARN', evidence: [], detail: isCanon ? 'CONCEPT: none under WORLD: canon (exempt from the subject-blind warning)' : 'subject-blind roll: CONCEPT is none; a new project needs a dossier and concepts before the roll' }); + } else { + const missing = []; + if (!factLines.length || factLines.some(l => /<model\b/i.test(l))) missing.push('FACTS (no cited fact lines)'); + if (unfilled(line('SIGNATURE DEVICE'))) missing.push('SIGNATURE DEVICE'); + if (unfilled(line('SIGNATURE GRAPHIC'))) missing.push('SIGNATURE GRAPHIC (still the <model: ...> placeholder or absent)'); + checks.push({ id: 'concept-contract', status: missing.length ? 'FAIL' : 'PASS', evidence: missing.map(m => ({ snippet: m })), detail: missing.length ? `CONCEPT ${concept.slice(0, 40)} is set but unfilled: ${missing.join('; ')}` : `CONCEPT, ${factLines.length} FACTS, SIGNATURE DEVICE and SIGNATURE GRAPHIC are filled` }); + } + } +} +// ---- 12. cited facts reach the build ------------------------------------------------------------------------------ +{ + const norm = s => s.toLowerCase().replace(/\s+/g, ' '); + const dataFiles = paths.flatMap(p => walk(p, [], new Set([...EXT, '.json']), [...SKIP_DIRS, '.vary'])) + .filter(f => !/(^|\/)(dossier|concepts|package-lock|package)\.json$/.test(f)).map(f => ({ file: f, text: readFileSync(f, 'utf8') })); + const corpus = norm(dataFiles.map(f => f.text.replace(contractOf(f.text) || '\u0000', ' ')).join('\n')); + const tests = []; + for (const l of factLines) { + const q = l.match(/"([^"]+)"/); const text = (q ? q[1] : l).trim(); if (!text) continue; + const segs = text.split(/\s[—–·|-]\s|[—–·|;()[\]…"“”]|[.,:](?=\s|$)/).map(s => s.trim()).filter(s => s.split(/\s+/).filter(w => /[\p{L}]/u.test(w)).length >= 3); + const seg = segs.sort((a, b) => b.split(/\s+/).length - a.split(/\s+/).length)[0]; + const nums = (text.match(/\d[\d.,:]*\d/g) || []).filter(n => n.length >= 3); + const hitSeg = seg ? (() => { const w = norm(seg).split(' '); const n = Math.min(4, w.length); for (let i = 0; i + n <= w.length; i++) if (corpus.includes(w.slice(i, i + n).join(' '))) return true; return false; })() : false; + const hitNum = nums.some(n => corpus.includes(n)); + if (!seg && !nums.length) continue; + tests.push({ fact: text.slice(0, 70), found: hitSeg || hitNum }); + } + const found = tests.filter(t => t.found).length; const need = Math.min(2, tests.length); + checks.push({ id: 'facts-in-build', status: !contract || !tests.length ? 'UNKNOWN' : found >= need ? 'PASS' : 'FAIL', evidence: tests.filter(t => !t.found).slice(0, 6).map(t => ({ snippet: `not found in source: ${t.fact}` })), detail: !tests.length ? 'no testable FACTS lines in the contract' : found >= need ? `${found}/${tests.length} cited facts found in source or data files (contract comment excluded)` : `${found}/${tests.length} cited facts found: cited facts never reach the build (need ${need})` }); +} +// ---- 13. bespoke graphics ------------------------------------------------------------------------------------------ +const svgBlocks = []; // large inline SVGs, shared with svg-a11y +find(/<svg\b([^>]*)>([\s\S]*?)<\/svg>/i, (f, m) => { const vb = m[1].match(/viewBox\s*=\s*["']?\{?["']?\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)/); const big = !!vb && Math.max(+vb[3], +vb[4]) > 32; svgBlocks.push({ f, m, big, shapes: (m[2].match(/<(path|circle|rect|polygon|line|ellipse)\b/gi) || []).length }); }); +{ + const inline = svgBlocks.filter(b => b.big && b.shapes >= 8); + const fromCode = files.filter(f => CODE_EXT.has(extname(f.file).toLowerCase()) && (/`[^`]*<(svg|path)\b/.test(f.text) || /["']<(svg|path)\b/.test(f.text) || /\bd=\$\{|\bd=\{`/.test(f.text))); + const canvas = files.filter(f => /getContext\(\s*['"`](2d|webgl2?|experimental-webgl)['"`]/.test(f.text)); + const filters = files.filter(f => /feTurbulence|feDisplacementMap/.test(f.text)); + const masks = files.filter(f => /(mask-image|clip-path\s*:\s*polygon)\s*:?[^;}\n]*(\$\{|var\(|url\()|\.(maskImage|clipPath)\s*=/.test(f.text)); + const total = inline.length + fromCode.length + canvas.length + filters.length + masks.length; + const strict = mode === 'persuade' || mode === 'experience'; + const ev0 = [...inline.slice(0, 2).map(b => ev(b.f.file, b.m.index, b.f.text)), ...[...fromCode, ...canvas, ...filters, ...masks].slice(0, 3).map(f => ({ file: f.file, line: 1, snippet: 'drawing source' }))]; + const imgSrc = files.filter(f => /<(img|picture|video)\b/i.test(f.text) || /<(Image|NextImage|Picture)\b|\bAsyncImage\(|\bImage\(/.test(f.text)); + if (treatment === 'typography' || treatment === 'utility') { + checks.push({ id: 'bespoke-graphics', status: 'INFO', evidence: ev0, detail: `treatment ${treatment}: no drawn hero is required (${total} drawing source(s) found); judged by the evaluator's signature-element row` }); + } else if (treatment === 'photography' || treatment === 'product-ui') { + checks.push({ id: 'bespoke-graphics', status: imgSrc.length ? 'PASS' : 'FAIL', evidence: imgSrc.slice(0, 3).map(f => ({ file: f.file, line: 1, snippet: 'image source' })), detail: `hero visual source (treatment ${treatment}): ${imgSrc.length} file(s) with <img>/<picture>/<video> or an image component${imgSrc.length ? '' : '; no hero image or video found'}` }); + } else { + checks.push({ id: 'bespoke-graphics', status: total ? 'PASS' : strict ? 'FAIL' : 'WARN', evidence: ev0, detail: `drawing sources: ${inline.length} inline SVG (>=8 shapes, viewBox>32), ${fromCode.length} file(s) returning SVG from code, ${canvas.length} canvas/webgl, ${filters.length} SVG-filter file(s), ${masks.length} generated mask file(s)${total ? '' : `; nothing drawn of the subject (mode ${mode || 'unknown'}${strict ? ', FAIL' : ', WARN'})`}` }); + } +} +// ---- 13b. photo fidelity (photography / product-ui): the client's images ship unaltered ---------------------------------- +if (treatment === 'photography' || treatment === 'product-ui') { + const hits = []; + const directive = /DIRECTIVES?:[^\n]*(grayscale|greyscale|black and white|halftone|duotone|filter)/i.test(contract || ''); + const bad = /(?<![-\w])filter\s*:\s*(?!none)(?!drop-shadow)[^;}]*|mix-blend-mode\s*:\s*(?!normal)[^;}]*|\bgrayscale\b|\bsepia\b|\bhalftone\b/gi; + find(bad, (f, m) => hits.push(ev(f.file, m.index, f.text))); + checks.push({ id: 'photo-fidelity', status: hits.length && !directive ? 'WARN' : 'PASS', evidence: hits.slice(0, 4), detail: hits.length ? (directive ? `${hits.length} filter/blend/grayscale/halftone use(s), allowed by a DIRECTIVES line` : `${hits.length} filter/blend/grayscale/sepia/halftone use(s): confirm none touches the hero or a gallery image (treatment ${treatment} ships client images unaltered)`) : 'no filter, blend mode, grayscale, sepia or halftone found' }); +} +// ---- 14. seeded art ------------------------------------------------------------------------------------------------ +{ + const bad = files.filter(f => /Math\.random\s*\(/.test(f.text) && (/getContext\(\s*['"`](2d|webgl)/.test(f.text) || (CODE_EXT.has(extname(f.file).toLowerCase()) && (/`[^`]*<(svg|path)\b/.test(f.text) || /["']<(svg|path)\b/.test(f.text) || /\bd=\$\{|\bd=\{`/.test(f.text))))); + checks.push({ id: 'seeded-art', status: bad.length ? 'WARN' : 'PASS', evidence: bad.slice(0, 4).map(f => ({ file: f.file, line: f.text.slice(0, f.text.search(/Math\.random\s*\(/)).split('\n').length, snippet: 'Math.random in a file that emits SVG or canvas' })), detail: bad.length ? `${bad.length} art file(s) use Math.random: unseeded art re-renders differently each load; use a seeded PRNG keyed per item` : 'no art file uses Math.random' }); +} +// ---- 15. svg a11y -------------------------------------------------------------------------------------------------- +{ + const big = svgBlocks.filter(b => b.big); const bad = []; + for (const b of big) { + const a = b.m[1]; const hidden = /aria-hidden(?!\s*=\s*["'{]\s*false)/.test(a); + const labelled = /role\s*=\s*["']img["']/.test(a) && (/aria-label(ledby)?\s*=/.test(a) || /<title\b/i.test(b.m[2])); + if (!hidden && !labelled) bad.push(ev(b.f.file, b.m.index, b.f.text)); + } + checks.push({ id: 'svg-a11y', status: !big.length ? 'UNKNOWN' : bad.length ? 'WARN' : 'PASS', evidence: bad.slice(0, 5), detail: !big.length ? 'no inline SVG with viewBox > 32' : `${big.length} large inline SVG(s), ${bad.length} neither aria-hidden="true" nor role="img" with <title>/aria-label` }); +} + +// ---- fold: first-viewport scene measurement (browser; delegated to fold-check.mjs) --------------------- +{ + const how = 'run: node fold-check.mjs <url-or-built-html> [--mode persuade|experience|operate|read], or pass --fold=<url-or-built-html> here'; + if (native) checks.push({ id: 'fold', status: 'N/A', evidence: [], detail: 'N/A (native platform: use device screenshots)' }); + else if (!foldArg) checks.push({ id: 'fold', status: 'UNKNOWN', evidence: [], detail: `no built page given; ${how}` }); + else { + const r = spawnSync('node', [join(dirname(fileURLToPath(import.meta.url)), 'fold-check.mjs'), foldArg, ...(mode ? ['--mode', mode] : []), '--treatment', treatment], { encoding: 'utf8' }); + const out = (r.stdout || '').trim(); + const lines = out.split('\n').filter(l => /\[(PASS|FAIL|INFO)\]/.test(l)).map(l => ({ snippet: l.trim() })); + if (/fold-check SKIP/.test(out)) checks.push({ id: 'fold', status: 'UNKNOWN', evidence: [], detail: out }); + else if (r.status === 0 || r.status === 1) checks.push({ id: 'fold', status: r.status ? 'FAIL' : 'PASS', evidence: lines.filter(l => /\[FAIL\]/.test(l.snippet)), detail: `first viewport ${r.status ? 'fails' : 'meets'} the ${treatment} gates (${lines.length} metrics, mode ${mode || 'persuade'})` }); + else checks.push({ id: 'fold', status: 'UNKNOWN', evidence: [], detail: `fold-check could not run: ${(r.stderr || out).trim().split('\n')[0]}; ${how}` }); + } +} + const worst = checks.some(c => c.status === 'FAIL') ? 'FAIL' : checks.some(c => c.status === 'WARN') ? 'WARN' : 'PASS'; const report = { files: files.length, verdict: worst, checks }; if (json) console.log(JSON.stringify(report, null, 1)); diff --git a/plugins/design/skills/vary/scripts/check.test.mjs b/plugins/design/skills/vary/scripts/check.test.mjs new file mode 100644 index 0000000..4ebfa98 --- /dev/null +++ b/plugins/design/skills/vary/scripts/check.test.mjs @@ -0,0 +1,36 @@ +// node --test check.test.mjs — one passing and one failing fixture per project-specificity check. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const run = dir => { + const r = spawnSync('node', [join(here, 'check.mjs'), join(here, 'fixtures/check', dir), '--json'], { encoding: 'utf8' }); + return Object.fromEntries(JSON.parse(r.stdout).checks.map(c => [c.id, c.status])); +}; + +test('pass fixture passes every new check', () => { + const s = run('pass'); + for (const id of ['concept-contract', 'facts-in-build', 'bespoke-graphics', 'seeded-art', 'svg-a11y']) assert.equal(s[id], 'PASS', id); +}); +test('unfilled SIGNATURE GRAPHIC fails concept-contract', () => assert.equal(run('fail-concept')['concept-contract'], 'FAIL')); +test('cited facts absent from source fail facts-in-build', () => assert.equal(run('fail-facts')['facts-in-build'], 'FAIL')); +test('persuade build with no drawing source fails bespoke-graphics', () => assert.equal(run('fail-graphics')['bespoke-graphics'], 'FAIL')); +test('Math.random in SVG-emitting code warns seeded-art', () => assert.equal(run('fail-seeded')['seeded-art'], 'WARN')); +test('unlabelled large SVG warns svg-a11y', () => assert.equal(run('fail-a11y')['svg-a11y'], 'WARN')); +test('photography treatment with an img hero and no svg passes bespoke-graphics and photo-fidelity', () => { + const s = run('pass-photography'); + assert.equal(s['bespoke-graphics'], 'PASS'); + assert.equal(s['photo-fidelity'], 'PASS'); + assert.equal(s['concept-contract'], 'PASS'); +}); +test('typography treatment gives bespoke-graphics INFO, never FAIL', () => assert.equal(run('typography')['bespoke-graphics'], 'INFO')); +test('WORLD: system: counts as an exempt world and passes concept-contract', () => assert.equal(run('system')['concept-contract'], 'PASS')); +test('backdrop-filter and drop-shadow on a photography page do not trip photo-fidelity', () => assert.equal(run('photo-glass')['photo-fidelity'], 'PASS')); +test('native platform reads .swift sources and reports fold N/A', () => { + const s = run('native'); + assert.equal(s.fold, 'N/A'); + assert.equal(s['concept-contract'], 'PASS'); +}); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/art.js b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/art.js new file mode 100644 index 0000000..751f498 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/art.js @@ -0,0 +1,14 @@ +import menu from './menu.json'; + +// FNV-1a + mulberry32: seeded per item so every load draws the same pizza. +function rng(key) { + let h = 2166136261; + for (const c of key) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } + return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +export function pizza(item) { + const r = rng('pizza:' + item.id); + const dots = item.toppings.map(() => `<circle cx="${20 + r() * 60}" cy="${20 + r() * 60}" r="6"/>`).join(''); + return `<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="46"/>${dots}</svg>`; +} +document.getElementById('art').innerHTML = menu.map(pizza).join(''); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/index.html b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/index.html new file mode 100644 index 0000000..a96c294 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/index.html @@ -0,0 +1,28 @@ +<!doctype html> +<html lang="is"><head><meta charset="utf-8"><title>ARA + + +
    +

    Formaggi fjórir ostar

    +

    Opið alla daga frá 11:30 til 22:00. Sími 554 3050.

    + + + + + +
    + + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/menu.json new file mode 100644 index 0000000..f0102b0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-a11y/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"2.490 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/art.js b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/art.js new file mode 100644 index 0000000..c453c7d --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/art.js @@ -0,0 +1,14 @@ +import menu from './menu.json'; + +// FNV-1a + mulberry32: seeded per item so every load draws the same pizza. +function rng(key) { + let h = 2166136261; + for (const c of key) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } + return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +export function pizza(item) { + const r = rng('pizza:' + item.id); + const dots = item.toppings.map(() => ``).join(''); + return ``; +} +document.getElementById('art').innerHTML = menu.map(pizza).join(''); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/index.html b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/index.html new file mode 100644 index 0000000..42c3031 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/index.html @@ -0,0 +1,28 @@ + +ARA + + +
    +

    Formaggi fjórir ostar

    +

    Opið alla daga frá 11:30 til 22:00. Sími 554 3050.

    + +
    + + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/menu.json new file mode 100644 index 0000000..f0102b0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-concept/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"2.490 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/art.js b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/art.js new file mode 100644 index 0000000..c453c7d --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/art.js @@ -0,0 +1,14 @@ +import menu from './menu.json'; + +// FNV-1a + mulberry32: seeded per item so every load draws the same pizza. +function rng(key) { + let h = 2166136261; + for (const c of key) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } + return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +export function pizza(item) { + const r = rng('pizza:' + item.id); + const dots = item.toppings.map(() => ``).join(''); + return ``; +} +document.getElementById('art').innerHTML = menu.map(pizza).join(''); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/index.html b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/index.html new file mode 100644 index 0000000..0f3dfb6 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/index.html @@ -0,0 +1,28 @@ + +ARA + + +
    +

    Formaggi sex ostar

    +

    Opið alla daga frá 09:00 til 17:00. Sími 511 0000.

    + +
    + + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/menu.json new file mode 100644 index 0000000..45b6272 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-facts/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"3.990 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/index.html b/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/index.html new file mode 100644 index 0000000..adde826 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/index.html @@ -0,0 +1,22 @@ + +ARA + + +
    +

    Formaggi fjórir ostar

    +

    Opið alla daga frá 11:30 til 22:00. Sími 554 3050.

    +
    + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/menu.json new file mode 100644 index 0000000..f0102b0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-graphics/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"2.490 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/art.js b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/art.js new file mode 100644 index 0000000..9460913 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/art.js @@ -0,0 +1,14 @@ +import menu from './menu.json'; + +// FNV-1a + mulberry32: seeded per item so every load draws the same pizza. +function rng(key) { + let h = 2166136261; + for (const c of key) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } + return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +export function pizza(item) { + const r = rng('pizza:' + item.id); + const dots = item.toppings.map(() => ``).join(''); + return ``; +} +document.getElementById('art').innerHTML = menu.map(pizza).join(''); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/index.html b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/index.html new file mode 100644 index 0000000..830a8b2 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/index.html @@ -0,0 +1,28 @@ + +ARA + + +
    +

    Formaggi fjórir ostar

    +

    Opið alla daga frá 11:30 til 22:00. Sími 554 3050.

    + +
    + + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/menu.json new file mode 100644 index 0000000..f0102b0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/fail-seeded/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"2.490 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/native/Hero.swift b/plugins/design/skills/vary/scripts/fixtures/check/native/Hero.swift new file mode 100644 index 0000000..6ffa9c0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/native/Hero.swift @@ -0,0 +1,2 @@ +import SwiftUI +struct Hero: View { var body: some View { Text("Autumn campaign shot on the Faroe coast") } } diff --git a/plugins/design/skills/vary/scripts/fixtures/check/native/index.html b/plugins/design/skills/vary/scripts/fixtures/check/native/index.html new file mode 100644 index 0000000..7ef46e2 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/native/index.html @@ -0,0 +1,23 @@ + +Maison Orel + + +
    +

    Autumn campaign shot on the Faroe coast

    +

    Atelier opens Tuesday to Saturday from 10:00 to 18:00. Every coat is cut in one workroom in Lyon.

    + Model in a camel coat on a black-sand beach + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/pass-photography/index.html b/plugins/design/skills/vary/scripts/fixtures/check/pass-photography/index.html new file mode 100644 index 0000000..4e0f822 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/pass-photography/index.html @@ -0,0 +1,23 @@ + +Maison Orel + + +
    +

    Autumn campaign shot on the Faroe coast

    +

    Atelier opens Tuesday to Saturday from 10:00 to 18:00. Every coat is cut in one workroom in Lyon.

    + Model in a camel coat on a black-sand beach + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/pass/art.js b/plugins/design/skills/vary/scripts/fixtures/check/pass/art.js new file mode 100644 index 0000000..c453c7d --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/pass/art.js @@ -0,0 +1,14 @@ +import menu from './menu.json'; + +// FNV-1a + mulberry32: seeded per item so every load draws the same pizza. +function rng(key) { + let h = 2166136261; + for (const c of key) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } + return () => { h |= 0; h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +export function pizza(item) { + const r = rng('pizza:' + item.id); + const dots = item.toppings.map(() => ``).join(''); + return ``; +} +document.getElementById('art').innerHTML = menu.map(pizza).join(''); diff --git a/plugins/design/skills/vary/scripts/fixtures/check/pass/index.html b/plugins/design/skills/vary/scripts/fixtures/check/pass/index.html new file mode 100644 index 0000000..830a8b2 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/pass/index.html @@ -0,0 +1,28 @@ + +ARA + + +
    +

    Formaggi fjórir ostar

    +

    Opið alla daga frá 11:30 til 22:00. Sími 554 3050.

    + +
    + + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/pass/menu.json b/plugins/design/skills/vary/scripts/fixtures/check/pass/menu.json new file mode 100644 index 0000000..f0102b0 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/pass/menu.json @@ -0,0 +1 @@ +[{"id":"formaggi","name":"Formaggi","price":"2.490 kr","toppings":["mozzarella","gorgonzola","parmesan","brie"]}] diff --git a/plugins/design/skills/vary/scripts/fixtures/check/photo-glass/index.html b/plugins/design/skills/vary/scripts/fixtures/check/photo-glass/index.html new file mode 100644 index 0000000..5247b22 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/photo-glass/index.html @@ -0,0 +1,23 @@ + +Maison Orel + + +
    +

    Autumn campaign shot on the Faroe coast

    +

    Atelier opens Tuesday to Saturday from 10:00 to 18:00. Every coat is cut in one workroom in Lyon.

    + Model in a camel coat on a black-sand beach + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/system/index.html b/plugins/design/skills/vary/scripts/fixtures/check/system/index.html new file mode 100644 index 0000000..1c387ec --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/system/index.html @@ -0,0 +1,22 @@ + +Acme + + +
    +

    Acme Cloud starts at 12 euro per seat

    +

    Annual billing saves two months of subscription cost. All plans include single sign-on and audit logs.

    + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/check/typography/index.html b/plugins/design/skills/vary/scripts/fixtures/check/typography/index.html new file mode 100644 index 0000000..69cec0c --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/check/typography/index.html @@ -0,0 +1,23 @@ + +Studio Nord + + +
    +

    Studio Nord, since 1998

    +

    Eleven designers, one long table, no account managers. Booking a first call takes ten minutes.

    + +
    + diff --git a/plugins/design/skills/vary/scripts/fixtures/concepts.json b/plugins/design/skills/vary/scripts/fixtures/concepts.json new file mode 100644 index 0000000..5ce83ac --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/concepts.json @@ -0,0 +1,68 @@ +[ + { + "id": "kvold", + "name": "The room on a dark evening", + "angle": "the room on a dark evening", + "temperament": "nocturnal-social", + "treatment": "scene", + "antecedent": "paper menu on a wooden table; waiter's guest check", + "facts": ["F1", "F8", "F10"], + "functional_reskin": "cart = guest-check pad; confirmation = kitchen ticket on a rail", + "devices": [ + { "technique": "procedural-subject", "what": "every pizza drawn from its menu topping list" }, + { "technique": "material-texture", "what": "deckled paper, wood grain, ruled check" } + ], + "world_hints": ["letterpress-wood-type", "vintage-analog"], + "memorable": "the menu lying on the table", + "p": 0.35 + }, + { + "id": "taka", + "name": "Friday take-away", + "angle": "the family collecting dinner on Friday", + "temperament": "warm-domestic", + "treatment": "typography", + "antecedent": "pizza box with a printed lid; paper bag with a rubber stamp", + "facts": ["F3", "F6", "F7"], + "functional_reskin": "cart = the box lid tally; confirmation = a stamped pickup slip", + "devices": [ + { "technique": "print-process", "what": "two-ink stamp and box-lid print with misregistration" }, + { "technique": "typographic", "what": "oversized 48-hour dough numeral" } + ], + "world_hints": ["risograph-zine", "childrens-book"], + "memorable": "the stamped pickup slip", + "p": 0.25 + }, + { + "id": "matchday", + "name": "Match night", + "angle": "the match-night crowd", + "temperament": "nocturnal-social", + "antecedent": "league fixture list; broadcast lower-third", + "facts": ["F12", "F13", "F14"], + "functional_reskin": "menu = fixture list; confirmation = a booked-table scoreboard card", + "devices": [ + { "technique": "data-diagram", "what": "fixture table with kick-off times beside platter stock" }, + { "technique": "scroll-scene", "what": "the wall of eight screens lighting up in turn" } + ], + "world_hints": ["sports-broadcast", "newsprint-tabloid"], + "memorable": "eight screens on the wall", + "p": 0.2 + }, + { + "id": "street", + "name": "The corner under the sign", + "angle": "the street-corner landmark", + "temperament": "civic-calm", + "antecedent": "mid-century travel poster of the corner; shopfront sign", + "facts": ["F4", "F5", "F7"], + "functional_reskin": "nav = signboard panels; opening hours = a door-hanger card", + "devices": [ + { "technique": "seeded-illustration", "what": "the real storefront drawn flat with its gold serif sign" }, + { "technique": "generative-pattern", "what": "cladding panel pattern as section divider" } + ], + "world_hints": ["mid-century-travel-poster", "cartographic"], + "memorable": "the lit shopfront at dusk", + "p": 0.2 + } +] diff --git a/plugins/design/skills/vary/scripts/fixtures/dossier.json b/plugins/design/skills/vary/scripts/fixtures/dossier.json new file mode 100644 index 0000000..a57c54e --- /dev/null +++ b/plugins/design/skills/vary/scripts/fixtures/dossier.json @@ -0,0 +1,27 @@ +{ + "project": "FIXTURE-PIZZERIA", + "subject": "neighbourhood pizzeria and sports bar (test fixture, not a real business)", + "sector": "food-drink", + "audience": ["families ordering take-away on Friday", "match-night regulars"], + "place": { "city": "Testville", "climate": "dark winters", "light": "Edison bulbs, oven glow", "landmarks": ["the ridge"] }, + "language": { "code": "en", "glyphs": "" }, + "facts": [ + { "id": "F1", "kind": "product", "text": "Formaggi: four cheeses, tomato base, basil", "source": "fixture/menu" }, + { "id": "F2", "kind": "product", "text": "Diavola: spicy salami, chilli oil, red onion", "source": "fixture/menu" }, + { "id": "F3", "kind": "price", "text": "Margherita 2190 kr", "source": "fixture/menu", "demo": true }, + { "id": "F4", "kind": "hours", "text": "Open 11:30 to 22:00 daily, kitchen closes 21:30", "source": "fixture/hours" }, + { "id": "F5", "kind": "place", "text": "Corner unit under the navy cladding with a gold serif sign", "source": "fixture/photos" }, + { "id": "F6", "kind": "process", "text": "Dough rests 48 hours; baked 90 seconds in a wood-fired oven", "source": "fixture/about" }, + { "id": "F7", "kind": "history", "text": "Family run since 2009", "source": "fixture/about" }, + { "id": "F8", "kind": "artefact", "text": "Paper menu on a clipboard, one page, prices in the right margin", "source": "fixture/photos" }, + { "id": "F9", "kind": "artefact", "text": "Kitchen ticket rail above the pass", "source": "fixture/photos" }, + { "id": "F10", "kind": "material", "text": "Turquoise vinyl booths and dark wooden tables", "source": "fixture/photos" }, + { "id": "F11", "kind": "quote", "text": "Best take-away pizza in the district", "source": "fixture/review-1" }, + { "id": "F12", "kind": "product", "text": "Match-night platter: 12 wings, fries, two pitchers", "source": "fixture/menu" }, + { "id": "F13", "kind": "place", "text": "Eight screens, every league on the wall", "source": "fixture/photos" }, + { "id": "F14", "kind": "artefact", "text": "Match-night handbill pinned in the window", "source": "fixture/photos" } + ], + "assets": [], + "artefacts": ["paper menu", "guest-check pad", "kitchen ticket rail", "match-night handbill"], + "refuse": ["no staff portraits"] +} diff --git a/plugins/design/skills/vary/scripts/fold-check.mjs b/plugins/design/skills/vary/scripts/fold-check.mjs new file mode 100644 index 0000000..467e14e --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-check.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +/** + * fold-check.mjs — measures whether the first viewport is an illustrated scene (vary SKILL S1-S3). + * + * Usage: node fold-check.mjs [--mode persuade|experience|operate|read] [--treatment scene|pattern|photography|product-ui|typography|data-visual|utility] [--out ] + * Treatment (default scene for persuade/experience, utility for operate/read) decides which rows are gated; the rest print INFO + * ("informational for treatment "). The gate table lives in fold-gates.mjs, the row text in fold-rows.mjs. + * Loads the page at 1440x900 and 390x844, waits 1.5 s, and measures in the first viewport: + * coverage union area of visible svg/canvas/img/video/background-image(url) boxes / viewport area + * detail visible svg shape elements (path circle rect polygon ellipse line polyline); a canvas/img/video/svg-image/ + * css-mask image + * covering >= 0.15 of the viewport also satisfies it, since raster detail cannot be counted + * headline whether the h1 box overlaps an art box (headline set into the scene) + * plates distinct svg/canvas >= 400px wide down the full page (hero included) + * collisions (all modes, both viewports) visible text boxes (HTML and svg ) that overlap another text box, or are + * > 30% covered by an opaque later-painted element (document.elementsFromPoint grid sampling); prints both texts + * dead-zone (persuade/experience) largest near-uniform-colour rectangle in the desktop fold screenshot (24px grid); + * FAIL above 20% of the fold area; prints size and position + * card-over-art (persuade/experience, both viewports) translucent (alpha < 0.95) or backdrop-filter element holding text + * over a hero art box + * art-down-page (persuade/experience) sections (>= 300px tall children of main/body) below the fold holding a drawn plate + * >= 40% of the section width and >= 120px tall; FAIL when under 60% do (contact/footer excluded) + * card-over-art also fails on a SOLID text container covering > 6% of the hero art area (fold-depth.browser.js; a full-width nav bar is exempt) + * full-bleed (persuade/experience, both viewports) hero art union box (boxes >= 8% of the viewport) spans >= 95% of the width, margins <= 3% each + * rendering-depth (persuade/experience) desktop hero art area: luminance p95-p5 >= 90 and >= 5 of 16 bins holding >= 2% of pixels; + * flat-block texture (mean sd of the flattest 30% of 8x8 blocks) is printed, not gated (smooth gradient skies are flat) + * plates-size (persuade/experience) largest drawn element per below-fold section: >= 35% of the section width, >= 280px tall, >= 3 value + * steps; FAIL when fewer than 50% of the plates pass. Thresholds calibrated on ara-bar/1, hitataekni/1,/4 (all pass). + * duplicate-content (all modes, full page, footer excluded; fold-content.browser.js) text blocks >= 40 chars appearing twice, and price + * lists (>= 3 amounts in one block, HTML or svg text) sharing >= 3 amounts with another non-nested block; FAIL prints them + * hidden-content (all modes, both viewports, after a scroll through the page; fold-craft.browser.js) text whose effective opacity is 0 / visibility + * hidden, or a hero art box (>= 8% of the viewport) fully transparent; display:none, [hidden], menus, dialogs, sr-only are exempt + * stray-overlay (all modes, both viewports) a fixed/absolute element, ::before/::after, or last-painted svg shape covering >= 80% of the viewport + * with a semi-opaque (alpha 0.2-0.98; a lighter grain rect is allowed) dark (luminance < 0.35) fill + * clipped-text (all modes, both viewports) text with overflow hidden and scrollWidth > clientWidth (not ellipsis), or svg whose bbox crosses its svg viewBox + * page-overflow (all modes and treatments, both viewports; fold-page.browser.js) documentElement.scrollWidth > innerWidth + 1, or a visible text + * element, img, svg, video, canvas, iframe or table crossing the viewport edge (right > innerWidth + 1, left < -1) with no + * ancestor whose overflow-x is auto/scroll/hidden/clip; prints up to 5 offenders; visually hidden elements are ignored + * meta-copy (all modes and treatments, both viewports) visible text (not script/style/code/pre, not inside [data-meta-ok], not alt/aria-label) + * holding designer or scaffold language ("design note", "signature device", "placeholder", "lorem ipsum", "TODO", "REF XX-1" ...); prints each hit with context + * headline-on-fold (persuade/experience, desktop) a visible h1 (or role=heading level 1) in the first viewport plus another visible + * text line (>= 10 chars, outside nav/buttons) within 200px of it, plus an action (a link or button with visible text, + * outside nav) in the first viewport. An h1 laid over an art box is the intended pattern and passes; svg alone + * (no h1) fails. + * title-on-fold (operate/read) a visible h1 or [role=heading][aria-level=1] in the first desktop viewport + * BLOCKING final line listing the FAIL rows that must be fixed before shipping: collisions, clipped-text, hidden-content, + * stray-overlay, page-overflow, meta-copy, duplicate-content, headline-on-fold, title-on-fold, and headline-in-scene when the treatment gates it + * Scene thresholds: coverage >= 0.55 desktop / 0.45 phone, detail >= 50 desktop, plates >= 4, h1 overlaps art. + * Screenshots fold-desktop.png / fold-phone.png go to --out (default: next to a local file, else cwd). + * Playwright is resolved from cwd, /tmp, $FOLD_CHECK_PW (a dir holding node_modules), or this script's dir; absent -> prints SKIP and exits 0. + * Exit: 0 = all PASS (or SKIP) · 1 = any FAIL · 2 = usage + */ +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { GATES, MODES, TREATMENTS, defaultTreatment } from './fold-gates.mjs'; +import { buildRows, blockingRegex } from './fold-rows.mjs'; +// the text-collision measure runs in the page; it lives in its own file (see its header) +const FOLD_TEXT = fileURLToPath(new URL('./fold-text.browser.js', import.meta.url)); +// frame / solid-card / plate / rendering-depth measures (G1-G4 of round 5) live in their own browser file too +const FOLD_DEPTH = fileURLToPath(new URL('./fold-depth.browser.js', import.meta.url)); +// duplicate-content and headline-on-fold measures (F6, F7 of round 6) live in their own browser file too +const FOLD_CONTENT = fileURLToPath(new URL('./fold-content.browser.js', import.meta.url)); +// craft-bug measures (hidden content, stray overlay, clipped text; round 7) live in their own browser file too +const FOLD_CRAFT = fileURLToPath(new URL('./fold-craft.browser.js', import.meta.url)); +// page-overflow and meta-copy measures (B1, B2 of round 8) live in their own browser file too +const FOLD_PAGE = fileURLToPath(new URL('./fold-page.browser.js', import.meta.url)); + +const args = process.argv.slice(2); +const opt = n => { const i = args.indexOf(n); return i < 0 ? null : args[i + 1]; }; +const mode = opt('--mode') || 'persuade'; +const treatment = opt('--treatment') || defaultTreatment(mode); +const G = GATES[treatment]; +const target = args.find((a, i) => !a.startsWith('--') && !['--mode', '--out', '--treatment'].includes(args[i - 1])); +if (!target || !MODES.includes(mode) || !G) { + console.error(`usage: node fold-check.mjs [--mode ${MODES.join('|')}] [--treatment ${TREATMENTS.join('|')}] [--out ]`); process.exit(2); +} +const isUrl = /^https?:\/\/|^file:\/\//.test(target); +const url = isUrl ? target : pathToFileURL(resolve(target)).href; +const outDir = resolve(opt('--out') || (isUrl ? process.cwd() : dirname(resolve(target)))); + +let chromium; +for (const base of [process.cwd(), '/tmp', ...(process.env.FOLD_CHECK_PW ? [process.env.FOLD_CHECK_PW] : []), dirname(fileURLToPath(import.meta.url))]) { + for (const name of ['playwright', 'playwright-core']) { + try { chromium = createRequire(join(base, 'x.js'))(name).chromium; if (chromium) break; } catch { /* try next */ } + } + if (chromium) break; +} +if (!chromium) { console.log('fold-check SKIP: Playwright not found (looked in cwd, /tmp, $FOLD_CHECK_PW, script dir); install it to measure the fold'); process.exit(0); } + +const measure = () => { + const vw = innerWidth, vh = innerHeight, SHAPES = 'path,circle,rect,polygon,ellipse,line,polyline'; + const visible = el => { + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; + } + return true; + }; + const inert = el => !!el.closest('defs,symbol,clipPath,mask,pattern,marker,template'); + const box = el => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }; + const clip = b => { const x1 = Math.max(0, b.x), y1 = Math.max(0, b.y), x2 = Math.min(vw, b.x + b.w), y2 = Math.min(vh, b.y + b.h); return x2 > x1 && y2 > y1 ? { x1, y1, x2, y2 } : null; }; + const art = [], raster = []; + for (const el of document.querySelectorAll('svg,canvas,img,video,*')) { + const cs = getComputedStyle(el), masked = /url\(/.test(cs.maskImage || cs.webkitMaskImage || ''); + const isArt = /^(svg|canvas|img|video)$/i.test(el.tagName) || /url\(/.test(cs.backgroundImage) || masked; + if (!isArt || (el.parentElement && el.parentElement.closest('svg'))) continue; + if (!visible(el)) continue; + const c = clip(box(el)); if (c) { art.push(c); if (masked || /^(canvas|img|video)$/i.test(el.tagName)) raster.push(c); } + } + let detail = 0; + for (const el of document.querySelectorAll(SHAPES)) { + if (inert(el) || !visible(el)) continue; + const r = el.getBoundingClientRect(); + if (clip({ x: r.left, y: r.top, w: r.width, h: r.height })) detail++; + } + for (const el of document.querySelectorAll('svg image')) { + if (inert(el) || !visible(el)) continue; + const c = clip(box(el)); if (c) raster.push(c); + } + // the first h1 that is actually seen: a visually hidden duplicate must not hide the real one set over the art + const h1s = [...document.querySelectorAll('h1,[role="heading"][aria-level="1"]')]; + const h1 = h1s.find(e => visible(e) && clip(box(e))) || h1s[0]; const hb = h1 ? clip(box(h1)) : null; + const overlap = !!hb && art.some(a => Math.min(a.x2, hb.x2) > Math.max(a.x1, hb.x1) && Math.min(a.y2, hb.y2) > Math.max(a.y1, hb.y1)) + || !!(h1 && h1.closest('svg')); + let plates = 0; + for (const el of document.querySelectorAll('svg,canvas')) { + if (el.parentElement && el.parentElement.closest('svg')) continue; + if (visible(el) && el.getBoundingClientRect().width >= 400) plates++; + } + return { art, raster, detail, hasH1: !!h1, overlap, plates }; +}; + +// G1: text collisions in the first viewport. Returns { texts: n, hits: [{a, b, why}] }. +// G3: translucent/blurred element with text over a hero art box. +const measureCards = () => { + const vw = innerWidth, vh = innerHeight; + const alphaOf = c => { + if (!c || c === 'transparent') return 0; + const m = c.match(/(?:,|\/)\s*([\d.]+)(%?)\s*\)$/); + return m ? (m[2] ? +m[1] / 100 : +m[1]) : 1; + }; + const artEls = []; + for (const el of document.querySelectorAll('svg,canvas,img,video,*')) { + const cs = getComputedStyle(el); + if (/^(svg|canvas|img|video)$/i.test(el.tagName) || /url\(/.test(cs.backgroundImage)) { + if (el === document.body || el === document.documentElement) continue; + if (el.parentElement && el.parentElement.closest('svg')) continue; + if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) continue; + artEls.push(el); + } + } + const hits = []; + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg,defs,template') || /^(script|style|noscript)$/i.test(el.tagName)) continue; + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) continue; + const blur = (cs.backdropFilter && cs.backdropFilter !== 'none') || (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none'); + const a = alphaOf(cs.backgroundColor); + if (!blur && !(a > 0 && a < 0.95)) continue; + const text = (el.textContent || '').replace(/\s+/g, ' ').trim(); + if (!text) continue; + const r = el.getBoundingClientRect(); + const c = { x1: Math.max(0, r.left), y1: Math.max(0, r.top), x2: Math.min(vw, r.right), y2: Math.min(vh, r.bottom) }; + if (c.x2 - c.x1 < 20 || c.y2 - c.y1 < 12) continue; + const over = artEls.find(e => { + if (e === el || el.contains(e)) return false; + const b = e.getBoundingClientRect(); + return Math.min(b.right, c.x2) > Math.max(b.left, c.x1) && Math.min(b.bottom, c.y2) > Math.max(b.top, c.y1); + }); + if (over) hits.push({ text: text.slice(0, 40), why: blur ? 'backdrop-filter' : `background alpha ${a.toFixed(2)}`, art: `<${over.tagName.toLowerCase()}>` }); + } + return hits; +}; + +// G4: drawn plate >= 40% of the section width, per section (children of main/body >= 300px tall). +const measureSections = () => { + const vh = innerHeight; + const ok = el => { const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && +s.opacity > 0; }; + const big = c => [...c.children].filter(e => ok(e) && e.getBoundingClientRect().height >= 300); + let container = document.querySelector('main') || document.body; + for (let i = 0; i < 3; i++) { + const b = big(container); + if (b.length === 1 && b[0].children.length > 1 && b[0].getBoundingClientRect().height > 0.6 * container.getBoundingClientRect().height) container = b[0]; else break; + } + const out = []; + for (const sec of big(container)) { + const r = sec.getBoundingClientRect(), top = r.top + scrollY; + const name = (sec.id || sec.getAttribute('aria-label') || sec.className || sec.tagName).toString().slice(0, 30); + const tag = (sec.tagName + ' ' + sec.id + ' ' + sec.className + ' ' + (sec.getAttribute('aria-label') || '')).toLowerCase(); + const excluded = /^footer\b/.test(tag) || /contact|footer|kontakt|samband/.test(tag); + let plate = false; + for (const el of sec.querySelectorAll('svg,canvas,img,video,*')) { + const cs = getComputedStyle(el); + if (!(/^(svg|canvas|img|video)$/i.test(el.tagName) || /url\(/.test(cs.backgroundImage))) continue; + if (el.parentElement && el.parentElement.closest('svg')) continue; + if (!ok(el)) continue; + const b = el.getBoundingClientRect(); + if (b.width >= 0.4 * r.width && b.height >= 120) { plate = true; break; } + } + const below = top >= vh * 0.5; + if (below && !excluded) { sec.setAttribute('data-fold-sec', String(out.length)); sec.setAttribute('data-fold-name', name); } + out.push({ name, top: Math.round(top), h: Math.round(r.height), below, excluded, plate }); + } + return out; +}; + +// G2 helper: 24px cell stats of a screenshot, computed in the browser (no PNG decoder needed in node). +const cellStats = async b64 => { + const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode(); + const cv = document.createElement('canvas'); cv.width = img.width; cv.height = img.height; + const ctx = cv.getContext('2d'); ctx.drawImage(img, 0, 0); + const C = 24, cols = Math.floor(img.width / C), rows = Math.floor(img.height / C), cells = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const d = ctx.getImageData(c * C, r * C, C, C).data, n = C * C, mean = [0, 0, 0], sq = [0, 0, 0]; + for (let i = 0; i < d.length; i += 4) for (let k = 0; k < 3; k++) { mean[k] += d[i + k]; sq[k] += d[i + k] * d[i + k]; } + const sd = Math.max(...[0, 1, 2].map(k => Math.sqrt(Math.max(0, sq[k] / n - (mean[k] / n) ** 2)))); + cells.push({ m: mean.map(v => v / n), sd }); + } + } + return { cols, rows, cells, w: img.width, h: img.height }; +}; + +const deadZone = ({ cols, rows, cells, w, h }) => { + const C = 24, SD = 8, TOL = 36, clusters = []; + const label = new Array(cells.length).fill(-1); + cells.forEach((c, i) => { + if (c.sd >= SD) return; + let k = clusters.findIndex(s => Math.max(...s.map((v, j) => Math.abs(v - c.m[j]))) <= TOL); + if (k < 0) { clusters.push(c.m); k = clusters.length - 1; } + label[i] = k; + }); + let best = { cells: 0 }; + clusters.forEach((_, k) => { + const height = new Array(cols).fill(0); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) height[c] = label[r * cols + c] === k ? height[c] + 1 : 0; + const st = []; + for (let c = 0; c <= cols; c++) { + const cur = c === cols ? 0 : height[c]; + while (st.length && height[st[st.length - 1]] >= cur) { + const hh = height[st.pop()], left = st.length ? st[st.length - 1] + 1 : 0, wd = c - left; + if (hh * wd > best.cells) best = { cells: hh * wd, x: left * C, y: (r - hh + 1) * C, w: wd * C, h: hh * C }; + } + st.push(c); + } + } + }); + return best.cells ? { ...best, frac: (best.w * best.h) / (w * h) } : { x: 0, y: 0, w: 0, h: 0, frac: 0 }; +}; + +const union = rects => { + const xs = [...new Set(rects.flatMap(r => [r.x1, r.x2]))].sort((a, b) => a - b); + let area = 0; + for (let i = 0; i < xs.length - 1; i++) { + const iv = rects.filter(r => r.x1 <= xs[i] && r.x2 >= xs[i + 1]).map(r => [r.y1, r.y2]).sort((a, b) => a[0] - b[0]); + let end = -Infinity, len = 0; + for (const [a, b] of iv) { if (b > end) { len += b - Math.max(a, end); end = b; } } + area += len * (xs[i + 1] - xs[i]); + } + return area; +}; + +// Rendering depth of the hero art box (desktop screenshot) and of every below-fold plate; solid cards and frame on both viewports. +const measureDepth = async (page, shot, name) => { + const frame = await page.evaluate(() => window.__foldDepth.frame()); + const solid = await page.evaluate(() => window.__foldDepth.cards()); + if (name !== 'desktop') return { frame, solid }; + const hero = frame.none ? { range: 0, steps: 0, texture: 0 } : await page.evaluate(([b, r]) => window.__foldDepth.stats(b, r), [shot.toString('base64'), frame.box]); + const plates = await page.evaluate(() => window.__foldDepth.plates()); + for (const pl of plates) { + const png = await page.locator(`[data-fold-plate="${pl.id}"]`).first().screenshot().catch(() => null); + pl.stats = png ? await page.evaluate(([b]) => window.__foldDepth.stats(b, null), [png.toString('base64')]) : { range: 0, steps: 0, texture: 0 }; + } + return { frame, solid, hero, plates }; +}; + +mkdirSync(outDir, { recursive: true }); +const browser = await chromium.launch(); +const res = {}; +for (const [name, w, h] of [['desktop', 1440, 900], ['phone', 390, 844]]) { + const page = await browser.newPage({ viewport: { width: w, height: h } }); + await page.goto(url, { waitUntil: 'load' }); + await page.waitForTimeout(1500); + const m = await page.evaluate(measure); + await page.addScriptTag({ path: FOLD_TEXT }); + const text = await page.evaluate(() => window.__foldText()); + const cards = await page.evaluate(measureCards); + const sections = name === 'desktop' ? await page.evaluate(measureSections) : []; + const shot = await page.screenshot({ path: join(outDir, `fold-${name}.png`) }); + await page.addScriptTag({ path: FOLD_DEPTH }); + const depth = await measureDepth(page, shot, name); + await page.addScriptTag({ path: FOLD_CONTENT }); + const content = name === 'desktop' ? { head: await page.evaluate(() => window.__foldContent.headline()), dup: await page.evaluate(() => window.__foldContent.duplicates()) } : null; + await page.addScriptTag({ path: FOLD_CRAFT }); + const craft = { hidden: await page.evaluate(() => window.__foldCraft.hidden()), overlay: await page.evaluate(() => window.__foldCraft.overlay()), clipped: await page.evaluate(() => window.__foldCraft.clipped()) }; + await page.addScriptTag({ path: FOLD_PAGE }); + const pageRows = { overflow: await page.evaluate(() => window.__foldPage.overflow()), meta: await page.evaluate(() => window.__foldPage.meta()) }; + let dead = null; + if (name === 'desktop') dead = deadZone(await page.evaluate(cellStats, shot.toString('base64'))); + res[name] = { ...m, craft, pageRows, text, cards, sections, dead, depth, content, coverage: union(m.art) / (w * h), rasterCoverage: union(m.raster) / (w * h) }; + await page.close(); +} +await browser.close(); + +const lines = buildRows(res, mode, treatment, G); +console.log(`fold-check ${url} mode=${mode} treatment=${treatment}`); +for (const l of lines) console.log(' ' + l.text); +console.log(` screenshots: ${join(outDir, 'fold-desktop.png')}, ${join(outDir, 'fold-phone.png')}`); +const fail = lines.some(l => l.ok === false); +const BLOCKING = blockingRegex(G); +const blocking = lines.filter(l => l.ok === false && BLOCKING.test(l.text.slice(7))); +console.log(`BLOCKING: ${blocking.length ? blocking.map(l => l.text.slice(7).split(':')[0]).join(', ') : 'none'}`); +console.log(`fold-check ${fail ? 'FAIL' : 'PASS'}`); +process.exit(fail ? 1 : 0); diff --git a/plugins/design/skills/vary/scripts/fold-check.test.mjs b/plugins/design/skills/vary/scripts/fold-check.test.mjs new file mode 100644 index 0000000..19a5601 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-check.test.mjs @@ -0,0 +1,238 @@ +// node --test fold-check.test.mjs — tiny fixture pages; skipped when Playwright is absent. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dir = mkdtempSync(join(tmpdir(), 'fold-')); +const fold = (name, html, extra = []) => { + const f = join(dir, name + '.html'); writeFileSync(f, html); + return spawnSync('node', [join(here, 'fold-check.mjs'), f, '--out', join(dir, name), ...extra], { encoding: 'utf8', cwd: tmpdir() }); +}; +const noPw = /fold-check SKIP/.test(fold('probe', '

    x

    ').stdout); +const sky = ''; +const shapes = n => sky + Array.from({ length: n }, (_, i) => ``).join(''); +const plate = n => ``; +const scene = ` +

    Fjalar Bar

    Cocktails and small plates, open daily.

    ${plate(10)}${plate(10)}${plate(10)}
    `; +const withAction = scene.replace('
    ', 'Book a table
    '); +const textOnly = '

    Fjalar Bar

    Open daily.

    '; + +test('text-only hero fails every scene metric', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('text', textOnly); + assert.equal(r.status, 1, r.stdout); + for (const id of ['coverage-desktop', 'detail', 'headline-in-scene', 'plates']) assert.match(r.stdout, new RegExp(`\\[FAIL\\] ${id}`)); +}); +test('full-bleed layered svg scene passes', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('scene', withAction); + assert.equal(r.status, 0, r.stdout); + assert.match(r.stdout, /fold-check PASS/); +}); +test('operate mode reports coverage as informational', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('op', scene, ['--mode', 'operate']); + assert.match(r.stdout, /\[INFO\] coverage-desktop/); +}); +test('missing target is a usage error', () => assert.equal(spawnSync('node', [join(here, 'fold-check.mjs')]).status, 2)); + +const tail = `
    ${plate(10)}${plate(10)}${plate(10)}
    `; +const sceneWith = extra => `${extra}${tail}`; + +test('two overlapping text elements are a collision and both texts are printed', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('collide', sceneWith('

    Fjalar Bar

    Open daily

    ')); + assert.equal(r.status, 1, r.stdout); + assert.match(r.stdout, /\[FAIL\] collisions-desktop: .*"Fjalar Bar" vs "Open daily"/); +}); +test('text under a later-painted opaque box is a collision', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('covered', sceneWith('

    Fjalar Bar

    ')); + assert.match(r.stdout, /\[FAIL\] collisions-desktop: .*"Fjalar Bar".*covered by an opaque/); +}); +test('a flat empty fold fails the dead-zone check and reports its size', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('dead', '

    Fjalar Bar

    '); + assert.match(r.stdout, /\[FAIL\] dead-zone: largest near-uniform rectangle \d+x\d+px at \(\d+,\d+\)/); +}); +test('a translucent text card over hero art fails; a solid one passes', { skip: noPw && 'Playwright absent' }, () => { + const card = (bg, extra = '') => `

    Fjalar Bar

    Open daily 17-23
    `; + const glass = fold('glass', sceneWith(card('rgba(0,0,0,.6)'))); + assert.match(glass.stdout, /\[FAIL\] card-over-art-desktop: "Open daily 17-23" \(background alpha 0\.60\)/); + const blur = fold('blur', sceneWith(card('#123', 'backdrop-filter:blur(8px)'))); + assert.match(blur.stdout, /\[FAIL\] card-over-art-desktop: .*\(backdrop-filter\)/); + const solid = fold('solid', sceneWith(card('#123'))); + assert.match(solid.stdout, /\[PASS\] card-over-art-desktop/); +}); +test('sections below the fold without a wide plate fail art-down-page; contact is excluded', { skip: noPw && 'Playwright absent' }, () => { + const hero = `
    `; + const textSec = id => `

    Plain text section

    `; + const contact = '

    Call

    '; + const r = fold('sections', `
    ${hero}${textSec('menu')}${textSec('team')}${contact}
    `); + assert.match(r.stdout, /\[FAIL\] art-down-page: 0\/2 sections .*menu@\d+, team@\d+/); + const ok = fold('sections-ok', `
    ${hero}${contact}
    `); + assert.match(ok.stdout, /\[PASS\] art-down-page: 1\/1/); +}); + +const flatHero = `

    Fjalar Bar

    `; +test('a framed hero picture fails full-bleed and names its margins', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('framed', `

    Fjalar Bar

    `); + assert.match(r.stdout, /\[FAIL\] full-bleed-desktop: hero art box spans 63% .*margins left 270px right 270px/); + assert.match(fold('bleed', scene).stdout, /\[PASS\] full-bleed-desktop: hero art box spans 100%/); +}); +test('a flat single-fill hero fails rendering-depth and prints the three numbers; a gradient scene passes', { skip: noPw && 'Playwright absent' }, () => { + assert.match(fold('flat', flatHero).stdout, /\[FAIL\] rendering-depth: desktop art area: value range \d+ \(need >= 90\), \d+ value steps \(need >= 5\), flat-block texture \d/); + assert.match(fold('depth-ok', scene).stdout, /\[PASS\] rendering-depth/); +}); +test('a big solid headline card over the hero art fails card-over-art', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('bigcard', sceneWith('
    Open daily 17-23
    ')); + assert.match(r.stdout, /\[FAIL\] card-over-art-desktop: "Open daily 17-23" \(solid card covering \d+% of the hero art/); +}); +test('thin section plates fail plates-size; big rendered plates pass', { skip: noPw && 'Playwright absent' }, () => { + const hero = `
    `; + const sec = (id, w, h) => `
    `; + const thin = fold('thin', `
    ${hero}${sec('menu', '300px', '200px')}${sec('team', '300px', '200px')}
    `); + assert.match(thin.stdout, /\[FAIL\] plates-size: 0\/2 .*menu 300x200 of 1440px/); + const big = fold('big', `
    ${hero}${sec('menu', '900px', '320px')}
    `); + assert.match(big.stdout, /\[PASS\] plates-size: 1\/1/); +}); + +const prices = (id, rows) => `
      ${rows.map(r => `
    • ${r}
    • `).join('')}
    `; +const ladder = ['Rebacking £180', 'Full rebind £420', 'Clamshell box £150']; +test('a price list shown twice fails duplicate-content and names the amounts; once passes', { skip: noPw && 'Playwright absent' }, () => { + const twice = fold('dup-price', sceneWith(`
    ${prices('a', ladder)}
    ${prices('b', ladder)}
    `)); + assert.match(twice.stdout, /\[FAIL\] duplicate-content: .*price list 180\/420\/150 repeats in and /); + assert.match(fold('once-price', sceneWith(`
    ${prices('a', ladder)}
    `)).stdout, /\[PASS\] duplicate-content/); +}); +test('a repeated paragraph outside the footer fails; the same text in the footer passes', { skip: noPw && 'Playwright absent' }, () => { + const para = '

    We rebind and repair books by hand in the old print works.

    '; + assert.match(fold('dup-text', sceneWith(`
    ${para}
    ${para}
    `)).stdout, /\[FAIL\] duplicate-content: "we rebind and repair books by hand/); + assert.match(fold('foot-text', sceneWith(`
    ${para}
    ${para}
    `)).stdout, /\[PASS\] duplicate-content/); +}); +test('an h1 with no value-proposition line near it fails headline-on-fold; no h1 fails too', { skip: noPw && 'Playwright absent' }, () => { + assert.match(fold('lone-h1', sceneWith('

    Fjalar Bar

    ')).stdout, /\[FAIL\] headline-on-fold: h1 "Fjalar Bar" has no other text line/); + assert.match(fold('no-h1', '

    Fjalar Bar, open daily to all.

    ').stdout, /\[FAIL\] headline-on-fold: no visible h1/); + assert.match(fold('with-line', withAction).stdout, /\[PASS\] headline-on-fold: h1 "Fjalar Bar" with line "Cocktails.*action "Book a table"/); +}); +test('an h1 over the art with a line but no link or button fails headline-on-fold (no action)', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('no-action', scene); + assert.match(r.stdout, /\[FAIL\] headline-on-fold: h1 "Fjalar Bar" has no link or button/); + assert.match(r.stdout, /BLOCKING: headline-on-fold/); +}); +test('headline only as svg text (no h1) fails headline-on-fold and headline-in-scene', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('svg-only', sceneWith('Fjalar BarOpen daily to everyoneBook a table')); + assert.match(r.stdout, /\[FAIL\] headline-on-fold: no visible h1/); + assert.match(r.stdout, /\[FAIL\] headline-in-scene/); + assert.match(r.stdout, /BLOCKING: .*headline-on-fold.*headline-in-scene|BLOCKING: .*headline-in-scene.*headline-on-fold/); +}); +test('an h1 over the art passes even when a visually hidden h1 comes first', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('hidden-first', withAction.replace('

    Site

    { + const bad = fold('hid', sceneWith('

    Open daily 17-23

    ')); + assert.match(bad.stdout, /\[FAIL\] hidden-content-desktop: opacity 0 "Open daily 17-23"/); + const ok = fold('rev', sceneWith('

    Open daily 17-23

    ')); + assert.match(ok.stdout, /\[PASS\] hidden-content-desktop/); +}); +test('a dark semi-opaque full-viewport layer fails stray-overlay, in html and inside the svg', { skip: noPw && 'Playwright absent' }, () => { + const html = fold('ovl', sceneWith('
    ')); + assert.match(html.stdout, /\[FAIL\] stray-overlay-desktop: covers the viewport with a dark fill at alpha 0\.50/); + const inSvg = fold('ovl-svg', scene.replace('', '')); + assert.match(inSvg.stdout, /\[FAIL\] stray-overlay-desktop: in svg/); + assert.match(fold('ovl-ok', scene).stdout, /\[PASS\] stray-overlay-desktop/); +}); +test('text clipped by overflow hidden or by its svg viewport fails clipped-text', { skip: noPw && 'Playwright absent' }, () => { + const html = fold('clip', sceneWith('
    Open daily seventeen to twenty-three
    ')); + assert.match(html.stdout, /\[FAIL\] clipped-text-desktop: "Open daily seventeen/); + const svg = fold('clip-svg', sceneWith('UKAS ACCREDITED')); + assert.match(svg.stdout, /\[FAIL\] clipped-text-desktop: "UKAS ACCREDITED" \(svg text crosses/); + assert.match(fold('clip-ok', scene).stdout, /\[PASS\] clipped-text-desktop/); +}); + +// ---- treatment gates (generality spec B) ---- +test('typography treatment: a text-only page with a title, line and action passes', { skip: noPw && 'Playwright absent' }, () => { + const page = '

    Fjalar Bar

    Open daily.

    Book a table'; + const r = fold('typo', page, ['--treatment', 'typography']); + assert.equal(r.status, 0, r.stdout); + assert.match(r.stdout, /treatment=typography/); + assert.match(r.stdout, /\[INFO\] headline-in-scene: .*informational for treatment typography/); + assert.match(r.stdout, /BLOCKING: none/); +}); +test('operate mode with no svg reports plates and detail as INFO (B1 regression)', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('op-info', '

    Orders

    A-1
    ', ['--mode', 'operate']); + assert.match(r.stdout, /\[INFO\] plates:/); + assert.match(r.stdout, /\[INFO\] detail:/); + assert.match(r.stdout, /treatment=utility/); + assert.match(r.stdout, /\[PASS\] title-on-fold/); + assert.equal(r.status, 0, r.stdout); +}); +test('operate mode without any h1 fails title-on-fold and blocks', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('op-notitle', '

    Orders list

    ', ['--mode', 'operate']); + assert.match(r.stdout, /\[FAIL\] title-on-fold/); + assert.match(r.stdout, /BLOCKING: title-on-fold/); +}); +test('photography treatment: a framed img hero exits 0 and full-bleed is INFO', { skip: noPw && 'Playwright absent' }, () => { + const png = 'data:image/svg+xml;utf8,' + encodeURIComponent(''); + const page = `

    Maison Orel

    Campaign, autumn collection.

    See the collection`; + const r = fold('photo', page, ['--treatment', 'photography']); + assert.match(r.stdout, /\[INFO\] full-bleed-desktop: .*informational for treatment photography/); + assert.equal(r.status, 0, r.stdout); +}); +test('a donation form repeating the hero amounts passes duplicate-content; two plain lists still fail', { skip: noPw && 'Playwright absent' }, () => { + const tiers = ['Give £25', 'Give £50', 'Give £100']; + const ok = fold('dup-form', sceneWith(`
    ${prices('a', tiers)}
    ${prices('b', tiers)}
    `)); + assert.match(ok.stdout, /\[PASS\] duplicate-content: .*exemptions applied: .*
    /); + const bad = fold('dup-ul', sceneWith(`
    ${prices('a', tiers)}
    ${prices('b', tiers)}
    `)); + assert.match(bad.stdout, /\[FAIL\] duplicate-content: .*price list/); + const mark = fold('dup-ok', sceneWith(`
    ${prices('a', tiers)}
    ${prices('b', tiers)}
    `)); + assert.match(mark.stdout, /\[PASS\] duplicate-content: .*\[data-repeat-ok\]/); +}); +test('repeated code snippets do not trip duplicate-content', { skip: noPw && 'Playwright absent' }, () => { + const cmd = '
    npm install --save-dev some-long-package-name@latest
    '; + assert.match(fold('dup-code', sceneWith(`
    ${cmd}
    ${cmd}
    `)).stdout, /\[PASS\] duplicate-content: .*pre\/code\/kbd/); +}); +test('an unknown treatment is a usage error', () => assert.equal(spawnSync('node', [join(here, 'fold-check.mjs'), 'x.html', '--treatment', 'wobble']).status, 2)); + +// ---- page-overflow and meta-copy (round 8, B1/B2) ---- +const doc = body => `

    Orders

    ${body}`; +const op = ['--mode', 'operate']; +test('a 600px fixed-width div fails page-overflow at phone; inside an overflow-x:auto wrapper it passes', { skip: noPw && 'Playwright absent' }, () => { + const wide = '
    Wide table of orders
    '; + const bad = fold('ovf', doc(wide), op); + assert.match(bad.stdout, /\[FAIL\] page-overflow-phone: .*div 'Wide table of orders' right=600/); + assert.match(bad.stdout, /\[PASS\] page-overflow-desktop/); + assert.match(bad.stdout, /BLOCKING: .*page-overflow-phone/); + const ok = fold('ovf-ok', doc(`
    ${wide}
    `), op); + assert.match(ok.stdout, /\[PASS\] page-overflow-phone/); + assert.match(ok.stdout, /BLOCKING: none/); +}); +test('page-overflow is blocking in a scene run too', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('ovf-scene', sceneWith('
    Open daily 17-23
    ')); + assert.match(r.stdout, /BLOCKING: .*page-overflow-phone/); +}); +test('"Design note" in visible copy fails meta-copy; inside data-meta-ok it passes', { skip: noPw && 'Playwright absent' }, () => { + const bad = fold('meta', doc('

    Design note: hero is a lantern

    '), op); + assert.match(bad.stdout, /\[FAIL\] meta-copy-desktop: "Design note" in "\.\.\..*Design note.*"/); + assert.match(bad.stdout, /BLOCKING: .*meta-copy-desktop/); + const ok = fold('meta-ok', doc('

    Design note: hero is a lantern

    '), op); + assert.match(ok.stdout, /\[PASS\] meta-copy-desktop/); + assert.match(ok.stdout, /\[PASS\] meta-copy-phone/); +}); +test('placeholder only in an input attribute, alt or a code block passes meta-copy', { skip: noPw && 'Playwright absent' }, () => { + const r = fold('meta-attr', doc('mockupplaceholder'), op); + assert.match(r.stdout, /\[PASS\] meta-copy-desktop/); + assert.match(r.stdout, /\[PASS\] meta-copy-phone/); +}); +test('a closed drawer off either edge, a body that clips x, and the word "todo" in Spanish all pass', { skip: noPw && 'Playwright absent' }, () => { + const drawer = side => ``; + const r = fold('ovf-drawer', doc(drawer('right') + drawer('left')), op); + assert.match(r.stdout, /\[PASS\] page-overflow-desktop/); + assert.match(r.stdout, /\[PASS\] page-overflow-phone/); + const clip = fold('ovf-clip', '

    Orders

    ', op); + assert.match(clip.stdout, /\[PASS\] page-overflow-desktop/); + const es = fold('meta-es', doc('

    Abrimos todo el año. TODO: hero

    '), op); + assert.match(es.stdout, /\[FAIL\] meta-copy-desktop: "TODO"/); + assert.doesNotMatch(es.stdout, /"todo"/); +}); diff --git a/plugins/design/skills/vary/scripts/fold-content.browser.js b/plugins/design/skills/vary/scripts/fold-content.browser.js new file mode 100644 index 0000000..fc6f9b6 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-content.browser.js @@ -0,0 +1,100 @@ +// fold-content.browser.js — the content measures fold-check.mjs injects into the page (page.addScriptTag), then calls as +// window.__foldContent.headline() and window.__foldContent.duplicates(). Browser-side only: no imports. +// headline a visible level-1 heading in the first viewport (over art is fine) plus another visible text line within 200px of it +// and an action (link or button with text, outside nav) in the first viewport +// duplicates (full page, footer excluded) text blocks >= 40 chars that appear twice, and price lists (>= 3 amounts in one +// block, HTML or svg text) that share >= 3 amounts with another non-nested block. Exempt (and counted in +// `exempt`): pre/code/kbd text, anything inside [data-repeat-ok], and amount blocks inside a +(() => { + const visible = el => { + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; + } + return true; + }; + const norm = s => (s || '').replace(/\s+/g, ' ').trim(); + const inFooter = el => !!el.closest('footer,[role="contentinfo"],#footer,.footer,.site-footer'); + const ownText = el => norm([...el.childNodes].filter(n => n.nodeType === 3).map(n => n.nodeValue).join(' ')); + const INERT = 'script,style,noscript,template,defs,title,desc'; + + const headline = () => { + scrollTo({ top: 0, left: 0, behavior: 'instant' }); // earlier plate screenshots scroll the page; css smooth-scroll must not delay it + const vw = innerWidth, vh = innerHeight; + const inView = r => r.width >= 20 && r.height >= 10 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw; + const h1 = [...document.querySelectorAll('h1,[role="heading"][aria-level="1"]')].find(e => visible(e) && norm(e.textContent) && inView(e.getBoundingClientRect())); + if (!h1) return { h1: null, line: null, action: null }; + const hr = h1.getBoundingClientRect(); + let line = null; + for (const el of document.querySelectorAll('body *')) { + if (el === h1 || h1.contains(el) || el.closest(`nav,button,${INERT}`)) continue; + const t = ownText(el); + if (t.length < 10 || !/\s/.test(t) || !visible(el)) continue; + const r = el.getBoundingClientRect(); + if (!inView(r)) continue; + const gap = Math.max(0, r.top - hr.bottom, hr.top - r.bottom); + if (gap <= 200) { line = t.slice(0, 60); break; } + } + const act = [...document.querySelectorAll('a[href],button,[role="button"]')].find(e => !e.closest(`nav,${INERT}`) && visible(e) && norm(e.textContent || e.getAttribute('aria-label')) && inView(e.getBoundingClientRect())); + return { h1: norm(h1.textContent).slice(0, 40), line, action: act ? norm(act.textContent || act.getAttribute('aria-label')).slice(0, 30) : null }; + }; + + const AMOUNT = /(?:[£$€]\s?\d[\d.,]*|\d[\d.,]*\s?(?:kr\.?|isk|dkk|nok|sek|eur|usd|gbp|[£$€]))/gi; + const count = s => (s.match(AMOUNT) || []).length; + const amountsOf = s => [...new Set((s.match(AMOUNT) || []).map(a => a.replace(/\D/g, '')))]; + // A repeated card template (a sibling of the same tag and first class within 4 levels) legitimately repeats its text and + // price tiers; two occurrences with the same template signature are not duplicated content. + const cls = n => n.tagName.toLowerCase() + '.' + (n.classList[0] || ''); + const templated = el => { + for (let n = el, i = 0; n && n !== document.body && i < 4; n = n.parentElement, i++) { + if (n.classList.length && [...n.parentElement.children].some(c => c !== n && cls(c) === cls(n))) return true; + } + return false; + }; + const sig = el => { const out = []; for (let n = el, i = 0; n && i < 2; n = n.parentElement, i++) out.push(cls(n)); return out.join('>'); }; + const sameTemplate = (a, b) => templated(a) && templated(b) && sig(a) === sig(b); + const name = b => b.tagName.toLowerCase() + (b.id ? '#' + b.id : '') + (b.classList[0] ? '.' + b.classList[0] : ''); + + // exemptions the duplicate check applied (printed by fold-check so the evaluator can audit them) + const exempt = { code: 0, repeatOk: 0, form: 0 }; + + const repeatedText = () => { + const seen = new Map(); + for (const el of document.querySelectorAll('body *')) { + if (el.closest(`${INERT},svg`) || inFooter(el) || !visible(el)) continue; + const t = norm(el.textContent); + if (t.length < 40 || [...el.children].some(c => norm(c.textContent).length >= 40)) continue; + if (el.closest('pre,code,kbd')) { exempt.code++; continue; } + if (el.closest('[data-repeat-ok]')) { exempt.repeatOk++; continue; } + const k = t.toLowerCase(); seen.set(k, [...(seen.get(k) || []), el]); + } + return [...seen].filter(([, els]) => els.some((a, i) => els.slice(i + 1).some(b => !sameTemplate(a, b)))).map(([k, els]) => ({ text: k.slice(0, 60), n: els.length })); + }; + + const repeatedPrices = () => { + const blocks = new Set(); + for (const el of document.querySelectorAll('body *')) { + if (el.closest(INERT) || inFooter(el) || !visible(el)) continue; + const isText = el.tagName.toLowerCase() === 'text'; + if (!count(isText ? norm(el.textContent) : el.closest('svg') ? '' : ownText(el))) continue; + if (el.closest('[data-repeat-ok]')) { exempt.repeatOk++; continue; } + if (el.closest('form')) { exempt.form++; continue; } + let b = el; + while (b && b !== document.body && count(b.textContent || '') < 3) b = b.parentElement; + if (b && b !== document.body) blocks.add(b); + } + const list = [...blocks].map(b => ({ b, amounts: amountsOf(b.textContent || '') })); + const out = []; + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + const A = list[i], B = list[j]; + if (A.b.contains(B.b) || B.b.contains(A.b) || sameTemplate(A.b, B.b)) continue; + const shared = A.amounts.filter(a => B.amounts.includes(a)); + if (shared.length >= 3) out.push({ amounts: shared.slice(0, 6), a: name(A.b), b: name(B.b) }); + } + } + return out; + }; + + window.__foldContent = { headline, duplicates: () => ({ text: repeatedText(), prices: repeatedPrices(), exempt }) }; +})(); diff --git a/plugins/design/skills/vary/scripts/fold-craft.browser.js b/plugins/design/skills/vary/scripts/fold-craft.browser.js new file mode 100644 index 0000000..ff95b74 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-craft.browser.js @@ -0,0 +1,130 @@ +// fold-craft.browser.js — craft-bug measures fold-check.mjs injects into the page (page.addScriptTag), then calls as +// await window.__foldCraft.hidden() / .overlay() / .clipped(). Browser-side only: no imports. +// hidden (G1) after load + a scroll through the page: elements with text whose effective opacity is 0 or visibility hidden, +// or a hero art box (>= 8% of the viewport) that is fully transparent +// overlay (G2) a fixed/absolute element covering >= 80% of the viewport with a semi-opaque dark background over the hero +// clipped (G3) text whose scrollWidth > clientWidth with overflow hidden, or svg whose bbox crosses its svg viewport +(() => { + const norm = s => (s || '').replace(/\s+/g, ' ').trim(); + const ownText = el => norm([...el.childNodes].filter(n => n.nodeType === 3).map(n => n.nodeValue).join(' ')); + const label = el => `<${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/)[0] : ''}>`; + const wait = ms => new Promise(r => setTimeout(r, ms)); + // effective opacity along the ancestor chain; gone when display:none (deliberately not rendered) + const state = el => { + let op = 1, vis = true; + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === 'none') return { gone: true }; + op *= +s.opacity; + if (n === el && s.visibility === 'hidden') vis = false; + } + return { gone: false, op, vis }; + }; + // content that is meant to stay hidden until opened: menus, dialogs, tooltips, sr-only text, drawers + const PARKED = '[hidden],dialog,[role="dialog"],[role="menu"],[role="tooltip"],[class*="sr-only"],[class*="visually-hidden"],[class*="menu"],[class*="drawer"],[class*="modal"],[class*="tooltip"],[class*="skip"]'; + // Walk the page in steps; an element is hidden only if it sat in the viewport at some step and never showed at any step + // (scroll-linked and reveal-on-scroll content shows when it is reached; a reveal that never fires does not). + const hidden = async () => { + const seen = new Map(), heroMin = 0.08 * innerWidth * innerHeight, h = document.documentElement.scrollHeight; + const sample = () => { + for (const el of document.querySelectorAll('body *')) { + if (el.closest('defs,template,noscript,' + PARKED) || /^(script|style|title|desc)$/i.test(el.tagName)) continue; + if (el.closest('svg') && !/^(svg|text|tspan)$/i.test(el.tagName)) continue; + const r = el.getBoundingClientRect(); + if (!r.width || !r.height || r.right <= 0 || r.left >= innerWidth || r.bottom <= 0 || r.top >= innerHeight) continue; + const st = state(el); if (st.gone) continue; + const text = ownText(el), art = /^(svg|canvas|img|video)$/i.test(el.tagName) && !(el.parentElement && el.parentElement.closest('svg')) + && r.top + scrollY < innerHeight && r.width * r.height >= heroMin; + if (text.length < 2 && !art) continue; + const rec = seen.get(el) || { el, text: text.length >= 2 ? text : '', art: art && text.length < 2, shown: false, why: '' }; + if (st.op > 0 && st.vis) rec.shown = true; else rec.why = st.vis ? 'opacity 0' : 'visibility hidden'; + seen.set(el, rec); + } + }; + for (let y = 0; y < h; y += Math.max(200, innerHeight * 0.4)) { scrollTo({ top: y, left: 0, behavior: 'instant' }); await wait(150); sample(); } + scrollTo({ top: 0, left: 0, behavior: 'instant' }); + await wait(3000); // staggered reveals (transition-delay) finish; then re-read every element that was in view and still not shown + for (const r of seen.values()) { if (r.shown) continue; const st = state(r.el); if (!st.gone && st.op > 0 && st.vis) r.shown = true; } + return [...seen.values()].filter(r => !r.shown).map(r => ({ el: label(r.el), why: r.art ? 'hero art box fully transparent' : r.why, text: r.text.slice(0, 40) })); + }; + const rgba = c => { const m = (c || '').match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(/[,/ ]+/).filter(Boolean).map(parseFloat); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; }; + const darkAlpha = cs => { + const cols = [rgba(cs.backgroundColor), ...[...(cs.backgroundImage || '').matchAll(/rgba?\([^)]+\)/g)].map(m => rgba(m[0]))].filter(Boolean); + const bad = cols.filter(c => c.a >= 0.05 && c.a < 0.98 && (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255 < 0.35); + return bad.length ? Math.max(...bad.map(c => c.a)) : 0; + }; + const covers = r => { + const w = Math.min(r.right, innerWidth) - Math.max(r.left, 0), h = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0); + return w > 0 && h > 0 && w * h >= 0.8 * innerWidth * innerHeight; + }; + // a dark, semi-opaque svg shape covering the viewport, painted after every other shape of its svg (a "grain" rect whose + // filter composites over a default-black SourceGraphic is the classic case) + const svgOverlays = () => { + const out = [], SH = 'path,circle,rect,polygon,ellipse,line,polyline,image,use,text'; + for (const el of document.querySelectorAll('svg rect,svg path,svg polygon')) { + if (el.closest('defs,symbol,clipPath,mask,pattern,marker,filter,template')) continue; + const cs = getComputedStyle(el), st = state(el); + if (st.gone || !st.vis || !/^rgb/.test(cs.fill)) continue; + const fill = rgba(cs.fill); if (!fill) continue; + const a = st.op * (+cs.fillOpacity) * fill.a; + if (a < 0.2 || a >= 0.98 || (0.2126 * fill.r + 0.7152 * fill.g + 0.0722 * fill.b) / 255 >= 0.35) continue; + if (!covers(el.getBoundingClientRect())) continue; + const svg = el.ownerSVGElement; + const later = [...svg.querySelectorAll(SH)].filter(s => !s.closest('defs,symbol,clipPath,mask,pattern,marker,filter') && (el.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING) && !el.contains(s)); + if (!later.length) out.push({ el: label(el) + ' in svg', alpha: +a.toFixed(2) }); + } + return out; + }; + const overlay = () => { + scrollTo({ top: 0, left: 0, behavior: 'instant' }); + const hits = svgOverlays(); + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg,defs,template') || /^(script|style|noscript)$/i.test(el.tagName)) continue; + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) continue; + const rect = el.getBoundingClientRect(); + if (/^(fixed|absolute)$/.test(cs.position) && covers(rect)) { + const a = darkAlpha(cs); if (a) hits.push({ el: label(el), alpha: a }); + } + for (const ps of ['::before', '::after']) { + const pc = getComputedStyle(el, ps); + if (pc.content === 'none' || pc.display === 'none' || !/^(fixed|absolute)$/.test(pc.position)) continue; + const base = pc.position === 'fixed' || cs.position === 'static' ? { left: 0, top: 0, right: innerWidth, bottom: innerHeight } : rect; + const pw = parseFloat(pc.width), ph = parseFloat(pc.height); + const spans = Number.isFinite(pw) && Number.isFinite(ph) ? { left: base.left, top: base.top, right: base.left + pw, bottom: base.top + ph } : base; + const a = darkAlpha(pc); + if (a && covers(spans)) hits.push({ el: label(el) + ps, alpha: a }); + } + } + return hits; + }; + // svg bbox mapped into its owning svg's user space, compared with that svg's viewBox + const svgTextOut = el => { + const svg = el.ownerSVGElement; if (!svg || !svg.getScreenCTM || getComputedStyle(svg).overflow === 'visible') return false; + const vb = svg.viewBox.baseVal, hasVb = vb && vb.width > 0; + const x0 = hasVb ? vb.x : 0, y0 = hasVb ? vb.y : 0, w = hasVb ? vb.width : svg.clientWidth, h = hasVb ? vb.height : svg.clientHeight; + const b = el.getBBox(), tm = svg.getScreenCTM().inverse().multiply(el.getScreenCTM()); + const pts = [[b.x, b.y], [b.x + b.width, b.y], [b.x, b.y + b.height], [b.x + b.width, b.y + b.height]].map(([x, y]) => [tm.a * x + tm.c * y + tm.e, tm.b * x + tm.d * y + tm.f]); + const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]), EPS = 1; + return Math.min(...xs) < x0 - EPS || Math.max(...xs) > x0 + w + EPS || Math.min(...ys) < y0 - EPS || Math.max(...ys) > y0 + h + EPS; + }; + const clipped = () => { + const hits = []; + for (const el of document.querySelectorAll('body *')) { + if (el.closest('defs,template,noscript') || /^(script|style|noscript)$/i.test(el.tagName)) continue; + const st = state(el); if (st.gone || !st.vis || st.op === 0) continue; + const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue; + if (r.width <= 2 || r.height <= 2) continue; // a visually-hidden (sr-only) element is clipped on purpose + if (el.closest('svg')) { + if (/^text$/i.test(el.tagName) && norm(el.textContent) && svgTextOut(el)) hits.push({ text: norm(el.textContent).slice(0, 40), why: 'svg text crosses its viewport edge' }); + continue; + } + const cs = getComputedStyle(el); + if (ownText(el) && /hidden|clip/.test(cs.overflowX) && cs.textOverflow !== 'ellipsis' && el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1) { + hits.push({ text: ownText(el).slice(0, 40), why: `scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth} with overflow hidden` }); + } + } + return hits; + }; + window.__foldCraft = { hidden, overlay, clipped }; +})(); diff --git a/plugins/design/skills/vary/scripts/fold-depth.browser.js b/plugins/design/skills/vary/scripts/fold-depth.browser.js new file mode 100644 index 0000000..2ad511c --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-depth.browser.js @@ -0,0 +1,121 @@ +// fold-depth.browser.js — the frame / solid-card / plate / rendering-depth measures fold-check.mjs injects into the +// page (page.addScriptTag) and calls through window.__foldDepth. Browser-side only: no imports. +// frame() hero art union box (boxes >= 8% of the viewport) and its margins to the viewport edges (G1) +// cards() text containers with an opaque background covering > 6% of the hero art area (G3) +// plates() per below-fold section (tagged data-fold-sec): its largest drawn plate, tagged data-fold-plate (G4) +// stats(b64, rect) value range, value steps and flat-block texture of a screenshot region (G2) +(() => { + const ART = /^(svg|canvas|img|video)$/i; + const alphaOf = c => { + if (!c || c === 'transparent') return 0; + const m = c.match(/(?:,|\/)\s*([\d.]+)(%?)\s*\)$/); + return m ? (m[2] ? +m[1] / 100 : +m[1]) : 1; + }; + const shown = el => { + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; + } + return true; + }; + const isArt = el => { + const cs = getComputedStyle(el); + return ART.test(el.tagName) || /url\(/.test(cs.backgroundImage) || /url\(/.test(cs.maskImage || cs.webkitMaskImage || ''); + }; + const nested = el => !!(el.parentElement && el.parentElement.closest('svg')); + const artEls = root => [...root.querySelectorAll('svg,canvas,img,video,*')].filter(e => isArt(e) && !nested(e) && shown(e)); + const clipped = (r, vw, vh) => { + const c = { x1: Math.max(0, r.left), y1: Math.max(0, r.top), x2: Math.min(vw, r.right), y2: Math.min(vh, r.bottom) }; + return c.x2 > c.x1 && c.y2 > c.y1 ? c : null; + }; + const union = rects => { + const xs = [...new Set(rects.flatMap(r => [r.x1, r.x2]))].sort((a, b) => a - b); + let area = 0; + for (let i = 0; i < xs.length - 1; i++) { + const iv = rects.filter(r => r.x1 <= xs[i] && r.x2 >= xs[i + 1]).map(r => [r.y1, r.y2]).sort((a, b) => a[0] - b[0]); + let end = -Infinity; + for (const [a, b] of iv) { if (b > end) { area += (b - Math.max(a, end)) * (xs[i + 1] - xs[i]); end = b; } } + } + return area; + }; + const heroArt = () => { + const vw = innerWidth, vh = innerHeight, boxes = []; + for (const el of artEls(document)) { + const c = clipped(el.getBoundingClientRect(), vw, vh); + if (c && (c.x2 - c.x1) * (c.y2 - c.y1) >= 0.08 * vw * vh) boxes.push(c); + } + return boxes; + }; + const bounds = boxes => ({ + x1: Math.min(...boxes.map(b => b.x1)), y1: Math.min(...boxes.map(b => b.y1)), + x2: Math.max(...boxes.map(b => b.x2)), y2: Math.max(...boxes.map(b => b.y2)), + }); + + const frame = () => { + const boxes = heroArt(), vw = innerWidth; + if (!boxes.length) return { none: true, span: 0, left: 0, right: vw }; + const b = bounds(boxes); + return { none: false, span: (b.x2 - b.x1) / vw, left: Math.round(b.x1), right: Math.round(vw - b.x2), box: b }; + }; + + const cards = () => { + const vw = innerWidth, vh = innerHeight, boxes = heroArt(), arts = artEls(document); + const total = union(boxes), hits = []; + if (!total) return hits; + for (const el of document.querySelectorAll('body *')) { + if (el.closest('svg,defs,template') || /^(script|style|noscript)$/i.test(el.tagName)) continue; + if (alphaOf(getComputedStyle(el).backgroundColor) < 0.95 || !shown(el)) continue; + const text = (el.textContent || '').replace(/\s+/g, ' ').trim(); + if (!text || arts.some(e => e === el || el.contains(e) || e.contains(el))) continue; + const c = clipped(el.getBoundingClientRect(), vw, vh); + if (!c || (c.x2 - c.x1 >= 0.95 * vw && c.y2 - c.y1 <= 120)) continue; // a full-width nav bar is not a card + const part = boxes.map(b => ({ x1: Math.max(b.x1, c.x1), y1: Math.max(b.y1, c.y1), x2: Math.min(b.x2, c.x2), y2: Math.min(b.y2, c.y2) })).filter(r => r.x2 > r.x1 && r.y2 > r.y1); + const share = union(part) / total; + if (share > 0.06) hits.push({ text: text.slice(0, 40), share }); + } + return hits; + }; + + // largest drawn element (>= 150x80px) per tagged section + const plates = () => { + document.querySelectorAll('[data-fold-plate]').forEach(e => e.removeAttribute('data-fold-plate')); + const out = []; + document.querySelectorAll('[data-fold-sec]').forEach(sec => { + let best = null; + for (const el of artEls(sec)) { + const b = el.getBoundingClientRect(); + if (b.width < 150 || b.height < 80 || (best && b.width * b.height <= best.w * best.h)) continue; + best = { el, w: b.width, h: b.height }; + } + if (!best) return; + const id = sec.getAttribute('data-fold-sec'); + best.el.setAttribute('data-fold-plate', id); + out.push({ id, name: sec.getAttribute('data-fold-name'), w: Math.round(best.w), h: Math.round(best.h), secW: Math.round(sec.getBoundingClientRect().width) }); + }); + return out; + }; + + const stats = async (b64, rect) => { + const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode(); + const R = rect || { x1: 0, y1: 0, x2: img.width, y2: img.height }; + const x = Math.floor(R.x1), y = Math.floor(R.y1), w = Math.min(img.width - x, Math.floor(R.x2 - R.x1)), h = Math.min(img.height - y, Math.floor(R.y2 - R.y1)); + if (w < 8 || h < 8) return { range: 0, steps: 0, texture: 0 }; + const cv = document.createElement('canvas'); cv.width = w; cv.height = h; + const ctx = cv.getContext('2d'); ctx.drawImage(img, x, y, w, h, 0, 0, w, h); + const d = ctx.getImageData(0, 0, w, h).data, lum = new Float32Array(w * h), hist = new Array(256).fill(0); + for (let i = 0; i < w * h; i++) { const l = 0.2126 * d[i * 4] + 0.7152 * d[i * 4 + 1] + 0.0722 * d[i * 4 + 2]; lum[i] = l; hist[Math.round(l)]++; } + const pct = q => { let a = 0; for (let v = 0; v < 256; v++) { a += hist[v]; if (a >= q * w * h) return v; } return 255; }; + const bins = new Array(16).fill(0); + for (let v = 0; v < 256; v++) bins[v >> 4] += hist[v]; + const sds = []; + for (let by = 0; by + 8 <= h; by += 8) for (let bx = 0; bx + 8 <= w; bx += 8) { + let s = 0, q = 0; + for (let j = 0; j < 8; j++) for (let k = 0; k < 8; k++) { const l = lum[(by + j) * w + bx + k]; s += l; q += l * l; } + sds.push(Math.sqrt(Math.max(0, q / 64 - (s / 64) ** 2))); + } + sds.sort((a, b) => a - b); + const flat = sds.slice(0, Math.max(1, Math.floor(sds.length * 0.3))); + return { range: pct(0.95) - pct(0.05), steps: bins.filter(n => n >= 0.02 * w * h).length, texture: flat.reduce((a, b) => a + b, 0) / flat.length }; + }; + window.__foldDepth = { frame, cards, plates, stats }; +})(); diff --git a/plugins/design/skills/vary/scripts/fold-gates.mjs b/plugins/design/skills/vary/scripts/fold-gates.mjs new file mode 100644 index 0000000..f712f22 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-gates.mjs @@ -0,0 +1,15 @@ +// fold-gates.mjs — which first-viewport rows are gated for which hero treatment (vary SKILL Step 3a router). +// A null gate makes the row INFO ("informational for treatment "); the measures still print. +export const TREATMENTS = ['scene', 'pattern', 'photography', 'product-ui', 'typography', 'data-visual', 'utility']; +export const MODES = ['persuade', 'experience', 'operate', 'read']; +const OFF = { cov: null, detail: null, plates: null, inScene: false, card: null, bleed: false, depth: false, dead: null, artDown: null, platesSize: null }; +export const GATES = { + scene: { cov: [0.55, 0.45], detail: 50, plates: 4, inScene: true, card: 'all', bleed: true, depth: true, dead: 0.2, artDown: 0.6, platesSize: 0.5 }, + pattern: { ...OFF, cov: [0.55, 0.45], inScene: true, card: 'glass', bleed: true }, + photography: { ...OFF, cov: [0.4, 0.35] }, + 'product-ui': { ...OFF, cov: [0.25, 0.2] }, + 'data-visual': { ...OFF, cov: [0.3, 0.25], detail: 20 }, + typography: { ...OFF }, + utility: { ...OFF }, +}; +export const defaultTreatment = mode => (mode === 'persuade' || mode === 'experience' ? 'scene' : 'utility'); diff --git a/plugins/design/skills/vary/scripts/fold-page.browser.js b/plugins/design/skills/vary/scripts/fold-page.browser.js new file mode 100644 index 0000000..65883d3 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-page.browser.js @@ -0,0 +1,54 @@ +// fold-page.browser.js — page-level measures fold-check.mjs injects (page.addScriptTag), then calls as +// window.__foldPage.overflow() / .meta(). Browser-side only: no imports. +// overflow (B1) horizontal overflow: documentElement.scrollWidth > innerWidth + 1, or a visible text/img/svg/video/canvas/iframe/table +// element crossing the viewport edge (right > innerWidth + 1 or left < -1) with no ancestor that scrolls or clips on x +// meta (B2) designer/scaffold language in the visible text (script/style/code/pre and [data-meta-ok] subtrees excluded) +(() => { + const norm = s => (s || '').replace(/\s+/g, ' ').trim(); + const label = el => `${el.tagName.toLowerCase()}${typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : ''}`; + const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const srOnly = el => { + const s = getComputedStyle(el), r = el.getBoundingClientRect(); + return /^rect\(\s*0/.test(s.clip) || /inset\(\s*50%/.test(s.clipPath) || (r.width <= 1 && r.height <= 1); + }; + const contained = el => { + for (let n = el.parentElement; n; n = n.parentElement) if (/^(auto|scroll|hidden|clip)$/.test(getComputedStyle(n).overflowX)) return true; + return false; + }; + const overflow = () => { + const vw = innerWidth, out = []; + for (const el of document.querySelectorAll('body *')) { + const tag = el.tagName.toLowerCase(); + if (el.closest('svg') && tag !== 'svg') continue; + const own = norm([...el.childNodes].filter(n => n.nodeType === 3).map(n => n.nodeValue).join(' ')); + if (!own && !/^(img|svg|video|canvas|iframe|table)$/.test(tag)) continue; + const r = el.getBoundingClientRect(); + // wholly off either edge is a hide pattern (left:-9999px, a closed drawer translated out), not a layout bug + if (r.width === 0 || r.height === 0 || r.right <= 0 || r.left >= vw || !shown(el) || srOnly(el)) continue; + if ((r.right > vw + 1 || r.left < -1) && !contained(el)) out.push({ el: label(el), text: own.slice(0, 30), right: Math.round(r.right) }); + } + // html or body clipping x means the page cannot scroll sideways, whatever scrollWidth says + const clipsX = [document.documentElement, document.body].some(e => /^(hidden|clip)$/.test(getComputedStyle(e).overflowX)); + return { scrollWidth: clipsX ? vw : document.documentElement.scrollWidth, vw, offenders: out.slice(0, 5), total: out.length }; + }; + const PATTERNS = ['signature device', 'design notes?', 'art direction', 'hero treatment', 'text equivalent of', 'the drawing above', 'drawn, not generated', + 'placeholder', 'lorem ipsum', 'wireframe', 'will plot here', 'once telemetry', 'is wired', 'mock-?up', 'this section shows']; + const REGEXES = [...PATTERNS.map(p => new RegExp(`\\b${p}\\b`, 'gi')), /\b(TODO|TBD)\b/g, /\bconcept:/gi, /\bREF [A-Z]{2,}-[A-Z0-9-]+/g]; + const meta = () => { + const parts = [], walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const el = n.parentElement; + if (!el || !n.nodeValue.trim() || el.closest('script,style,noscript,template,code,pre,[data-meta-ok]') || !shown(el)) continue; + parts.push(n.nodeValue); + } + const text = norm(parts.join(' ')), hits = []; + for (const re of REGEXES) { + for (const m of text.matchAll(re)) { + const pad = Math.max(0, Math.floor((40 - m[0].length) / 2)), s = Math.max(0, m.index - pad); + hits.push({ term: m[0], context: text.slice(s, m.index + m[0].length + pad) }); + } + } + return hits; + }; + window.__foldPage = { overflow, meta }; +})(); diff --git a/plugins/design/skills/vary/scripts/fold-rows.mjs b/plugins/design/skills/vary/scripts/fold-rows.mjs new file mode 100644 index 0000000..9bb3299 --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-rows.mjs @@ -0,0 +1,102 @@ +// fold-rows.mjs — turns the measured first-viewport numbers (fold-check.mjs) into PASS / FAIL / INFO rows, gated by treatment. +// Thresholds calibrated on ara-bar/1, hitataekni/1,/4 (scene; must pass) and the nine trial-3 builds. +const TH = { span: 0.95, edge: 0.03, range: 90, steps: 5, plateW: 0.35, plateH: 280, plateSteps: 3, solid: 0.06 }; +const BLOCKING_BASE = ['collisions', 'clipped-text', 'hidden-content', 'stray-overlay', 'page-overflow', 'meta-copy', 'duplicate-content', 'headline-on-fold', 'title-on-fold']; + +export function blockingRegex(g) { + return new RegExp(`^(${[...BLOCKING_BASE, ...(g.inScene ? ['headline-in-scene'] : [])].join('|')})`); +} + +function coverageRows(c) { + const { row, d, p, g, inf } = c; + const cov = (v, min) => `${v.toFixed(2)} (need >= ${min})`; + const [md, mp] = g.cov || [0.55, 0.45]; + row(g.cov ? d.coverage >= md : null, 'coverage-desktop', cov(d.coverage, md) + inf(g.cov)); + row(g.cov ? p.coverage >= mp : null, 'coverage-phone', cov(p.coverage, mp) + inf(g.cov)); + const rasterOk = d.rasterCoverage >= 0.15, need = g.detail ?? 50; + row(g.detail != null ? d.detail >= need || rasterOk : null, 'detail', `${d.detail} shape elements in the desktop hero (need >= ${need}); phone ${p.detail}` + (d.detail < need && rasterOk ? `; passes on raster art covering ${d.rasterCoverage.toFixed(2)} of the viewport (canvas/img/video detail is not countable, judge it by eye)` : '') + inf(g.detail != null)); + row(g.inScene ? d.hasH1 && d.overlap : null, 'headline-in-scene', (!d.hasH1 ? 'no h1 found' : d.overlap ? 'h1 box overlaps an art box' : 'h1 sits clear of every art box (floated beside the picture)') + inf(g.inScene)); + row(g.plates != null ? d.plates >= g.plates : null, 'plates', `${d.plates} svg/canvas plates >= 400px wide down the page, hero included (need >= ${g.plates ?? 4})` + inf(g.plates != null)); +} + +function collisionRows(c) { + const { row, d, p, g, inf } = c; + for (const [name, v] of [['desktop', d], ['phone', p]]) { + const hits = v.text.hits; + row(hits.length === 0, `collisions-${name}`, hits.length ? hits.slice(0, 6).map(h => `"${h.a}" vs "${h.b}" (${h.why})`).join('; ') : `${v.text.texts} text boxes, none overlapping or covered`); + } + const dz = d.dead; + row(g.dead != null ? dz.frac <= g.dead : null, 'dead-zone', `largest near-uniform rectangle ${dz.w}x${dz.h}px at (${dz.x},${dz.y}) = ${(dz.frac * 100).toFixed(1)}% of the desktop fold (need <= ${(g.dead ?? 0.2) * 100}%)` + inf(g.dead != null)); +} + +function artRows(c) { + const { row, d, p, g, inf } = c; + for (const [name, v] of [['desktop', d], ['phone', p]]) { + const glass = v.cards.map(x => `"${x.text}" (${x.why}) over ${x.art}`); + const solid = v.depth.solid.map(x => `"${x.text}" (solid card covering ${(x.share * 100).toFixed(0)}% of the hero art, need <= ${TH.solid * 100}%)`); + const hits = g.card === 'all' ? [...glass, ...solid] : glass; + row(g.card ? hits.length === 0 : null, `card-over-art-${name}`, hits.length ? hits.slice(0, 4).join('; ') : 'no translucent, blurred or solid text card over hero art' + inf(g.card)); + } + for (const [name, v] of [['desktop', d], ['phone', p]]) { + const f = v.depth.frame, vw = name === 'desktop' ? 1440 : 390; + const ok = !f.none && f.span >= TH.span && f.left <= TH.edge * vw && f.right <= TH.edge * vw; + row(g.bleed ? ok : null, `full-bleed-${name}`, (f.none ? 'no hero art box' : `hero art box spans ${(f.span * 100).toFixed(0)}% of the viewport width, margins left ${f.left}px right ${f.right}px (need span >= ${TH.span * 100}% and margins <= ${TH.edge * 100}% of the width)${ok || !g.bleed ? '' : '; a framed picture, not a full-bleed scene'}`) + inf(g.bleed)); + } + const hs = d.depth.hero; + row(g.depth ? hs.range >= TH.range && hs.steps >= TH.steps : null, 'rendering-depth', `desktop art area: value range ${hs.range} (need >= ${TH.range}), ${hs.steps} value steps (need >= ${TH.steps}), flat-block texture ${hs.texture.toFixed(2)} (reported, not gated: a smooth gradient sky is legitimately flat)` + inf(g.depth)); +} + +function plateRows(c) { + const { row, d, g, inf } = c; + const share = g.platesSize ?? 0.5, pls = d.depth.plates; + const small = pls.filter(pl => pl.w < TH.plateW * pl.secW || pl.h < TH.plateH || pl.stats.steps < TH.plateSteps); + row(g.platesSize != null ? !pls.length || small.length <= pls.length * (1 - share) : null, 'plates-size', (pls.length + ? `${pls.length - small.length}/${pls.length} section plates >= ${TH.plateW * 100}% of the section width, >= ${TH.plateH}px tall, >= ${TH.plateSteps} value steps (need >= ${share * 100}% of them); failing: ${small.map(pl => `${pl.name} ${pl.w}x${pl.h} of ${pl.secW}px, ${pl.stats.steps} steps`).join(', ') || 'none'}` + : 'no section plates below the fold') + inf(g.platesSize != null)); + const below = d.sections.filter(s => s.below && !s.excluded), withPlate = below.filter(s => s.plate); + const frac = below.length ? withPlate.length / below.length : 1, need = g.artDown ?? 0.6; + row(g.artDown != null ? frac >= need : null, 'art-down-page', (below.length + ? `${withPlate.length}/${below.length} sections below the fold carry a plate >= 40% of their width (need >= ${need * 100}%); without: ${below.filter(s => !s.plate).map(s => `${s.name}@${s.top}`).join(', ') || 'none'}` + : 'no sections >= 300px below the fold') + inf(g.artDown != null)); +} + +function contentRows(c) { + const { row, d, mode, treatment } = c; + const dp = d.content.dup, ex = dp.exempt || {}; + const dups = [...dp.text.map(t => `"${t.text}" appears ${t.n}x`), ...dp.prices.map(t => `price list ${t.amounts.join('/')} repeats in <${t.a}> and <${t.b}>`)]; + const applied = Object.entries({ 'pre/code/kbd': ex.code, '[data-repeat-ok]': ex.repeatOk, 'amount blocks in ': ex.form }).filter(([, n]) => n).map(([k, n]) => `${n} in ${k}`); + row(dups.length === 0, 'duplicate-content', (dups.length ? dups.slice(0, 4).join('; ') : 'no text block >= 40 chars or price list repeated outside the footer') + (applied.length ? ` (exemptions applied: ${applied.join(', ')})` : '')); + const hd = d.content.head, lead = mode === 'persuade' || mode === 'experience'; + row(lead ? !!hd.h1 && !!hd.line && !!hd.action : null, 'headline-on-fold', !hd.h1 ? 'no visible h1 in the first desktop viewport (paint the lettering as a real HTML h1 positioned over the sign/board area; svg text alone does not count)' : !hd.line ? `h1 "${hd.h1}" has no other text line within 200px (no value proposition)` : !hd.action ? `h1 "${hd.h1}" has no link or button with visible text in the first desktop viewport (no primary action)` : `h1 "${hd.h1}" with line "${hd.line}" and action "${hd.action}"` + (lead ? '' : ` (informational in ${mode} mode)`)); + if (!lead) row(!!hd.h1, 'title-on-fold', hd.h1 ? `visible title "${hd.h1}" in the first desktop viewport (treatment ${treatment})` : 'no visible h1 or [role=heading][aria-level=1] in the first desktop viewport (operate/read pages need a page title)'); +} + +function craftRows(c) { + const { row, d, p } = c; + for (const [name, v] of [['desktop', d], ['phone', p]]) { + const h = v.craft.hidden, o = v.craft.overlay, k = v.craft.clipped; + row(h.length === 0, `hidden-content-${name}`, h.length ? h.slice(0, 4).map(x => `${x.el} ${x.why}${x.text ? ` "${x.text}"` : ''}`).join('; ') : 'no text or hero art left at opacity 0 / visibility hidden after scrolling'); + row(o.length === 0, `stray-overlay-${name}`, o.length ? o.slice(0, 3).map(x => `${x.el} covers the viewport with a dark fill at alpha ${x.alpha.toFixed ? x.alpha.toFixed(2) : x.alpha}`).join('; ') : 'no viewport-sized semi-opaque dark layer'); + row(k.length === 0, `clipped-text-${name}`, k.length ? k.slice(0, 4).map(x => `"${x.text}" (${x.why})`).join('; ') : 'no text clipped by its box or svg viewport'); + } +} + +function pageRows(c) { + const { row, d, p } = c; + for (const [name, v] of [['desktop', d], ['phone', p]]) { + const o = v.pageRows.overflow, m = v.pageRows.meta, sideways = o.scrollWidth > o.vw + 1; + row(!sideways && o.offenders.length === 0, `page-overflow-${name}`, sideways || o.offenders.length + ? `${sideways ? `page scrolls sideways (scrollWidth ${o.scrollWidth} > ${o.vw}); ` : ''}${o.offenders.map(x => `${x.el} '${x.text}' right=${x.right}`).join('; ') || 'no single element found (look for negative margins, transforms or an overflowing pseudo-element)'}` + : `no horizontal overflow (scrollWidth ${o.scrollWidth}, viewport ${o.vw}) and no element crossing the edge outside a scroller`); + row(m.length === 0, `meta-copy-${name}`, m.length ? m.slice(0, 5).map(x => `"${x.term}" in "...${x.context}..." (designer/scaffold language in visible copy; [data-meta-ok] exempts a subtree)`).join('; ') : 'no designer or scaffold language in the visible text'); + } +} + +export function buildRows(res, mode, treatment, g) { + const lines = []; + const row = (ok, id, text) => lines.push({ ok, text: `[${ok === null ? 'INFO' : ok ? 'PASS' : 'FAIL'}] ${id}: ${text}` }); + const inf = on => (on ? '' : ` (informational for treatment ${treatment})`); + const c = { row, d: res.desktop, p: res.phone, g, inf, mode, treatment }; + coverageRows(c); collisionRows(c); artRows(c); plateRows(c); contentRows(c); craftRows(c); pageRows(c); + return lines; +} diff --git a/plugins/design/skills/vary/scripts/fold-text.browser.js b/plugins/design/skills/vary/scripts/fold-text.browser.js new file mode 100644 index 0000000..38262ef --- /dev/null +++ b/plugins/design/skills/vary/scripts/fold-text.browser.js @@ -0,0 +1,191 @@ +// fold-text.browser.js — the text-collision measure fold-check.mjs injects into the page +// (page.addScriptTag), then calls as window.__foldText(). Browser-side only: no imports. +// Three passes over every visible text box in the first viewport: text over text, text under +// an opaque later-painted element, and svg shapes crossing (or half-swallowing) a text box. +(() => { + const vw = () => innerWidth, vh = () => innerHeight; + const label = el => (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40); + const effOpacity = el => { let o = 1; for (let n = el; n && n.nodeType === 1; n = n.parentElement) o *= +getComputedStyle(n).opacity; return o; }; + const visible = el => { + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; + } + return true; + }; + const alphaOf = c => { + if (!c || c === 'transparent') return 0; + const m = c.match(/(?:,|\/)\s*([\d.]+)(%?)\s*\)$/); + return m ? (m[2] ? +m[1] / 100 : +m[1]) : 1; + }; + const SHAPE = /^(path|rect|circle|ellipse|polygon|polyline)$/i; + const GEO = /^(path|rect|circle|ellipse|polygon|polyline|line)$/i; + const NOT_PAINTED = 'defs,symbol,clipPath,mask,pattern,marker,template'; + const area = r => (r.x2 - r.x1) * (r.y2 - r.y1); + const shrink = r => { const dy = (r.y2 - r.y1) * 0.15; return { x1: r.x1, x2: r.x2, y1: r.y1 + dy, y2: r.y2 - dy }; }; + const inter = (a, b) => Math.max(0, Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1)) * Math.max(0, Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1)); + const boxesMeet = (s, b) => Math.min(s.x2, b.x2) > Math.max(s.x1, b.x1) && Math.min(s.y2, b.y2) > Math.max(s.y1, b.y1); + const num = v => (v === 'auto' || v === '' ? 0 : +v || 0); + const effZ = el => { let z = 0; for (let n = el; n && n.nodeType === 1; n = n.parentElement) { const s = getComputedStyle(n); if (s.position !== 'static') z = Math.max(z, num(s.zIndex)); } return z; }; + const rgbOf = c => { const m = (c || '').match(/^rgba?\(\s*(\d+)[ ,]+(\d+)[ ,]+(\d+)/); return m ? [+m[1], +m[2], +m[3]] : null; }; + const lum = c => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); }; + const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; + + function opaque(e, t) { + if (e === document.documentElement || e === document.body || e.contains(t) || t.contains(e)) return false; + if (effOpacity(e) < 0.95) return false; + const cs = getComputedStyle(e), tag = e.tagName; + if (/^(img|video|canvas)$/i.test(tag)) return true; + if (SHAPE.test(tag)) return cs.fill !== 'none' && +cs.fillOpacity >= 0.95; + if (e instanceof SVGElement) return false; + return alphaOf(cs.backgroundColor) >= 0.95 || /url\(/.test(cs.backgroundImage); + } + + function textItems() { + const items = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + if (!n.nodeValue.trim()) continue; + const el = n.parentElement; + if (!el || el.closest('script,style,noscript,title,desc,' + NOT_PAINTED + ',head') || !visible(el)) continue; + const range = document.createRange(); range.selectNodeContents(n); + const rects = [...range.getClientRects()] + .map(r => ({ x1: Math.max(0, r.left), y1: Math.max(0, r.top), x2: Math.min(vw(), r.right), y2: Math.min(vh(), r.bottom) })) + .filter(r => r.x2 - r.x1 >= 4 && r.y2 - r.y1 >= 4); + if (rects.length) items.push({ el, text: n.nodeValue.replace(/\s+/g, ' ').trim().slice(0, 40), rects }); + } + return items; + } + + // the same words stacked at almost the same spot = layered lettering (emboss, drop shadow, outline): + // ara-bar's sign sets "ARA" and the phone that way; a real duplicate label sits further off + function layeredCopy(A, B) { + if (A.text !== B.text) return false; + if (A.el.closest('[aria-hidden="true"]') || B.el.closest('[aria-hidden="true"]')) return true; + const near = (a, b) => Math.abs(a.x1 - b.x1) + Math.abs(a.y1 - b.y1) <= 0.15 * Math.max(a.y2 - a.y1, b.y2 - b.y1) + 2; + return A.rects.length === B.rects.length && A.rects.every((a, k) => near(a, B.rects[k])); + } + + function textOverText(items) { + const hits = []; + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const A = items[i], B = items[j]; + if (A.el === B.el || layeredCopy(A, B)) continue; + const bad = A.rects.some(a => B.rects.some(b => { const sa = shrink(a), sb = shrink(b); return inter(sa, sb) > 0.2 * Math.min(area(sa), area(sb)); })); + if (bad) hits.push({ a: A.text, b: B.text, why: 'text boxes overlap' }); + } + } + return hits; + } + + function textUnderOpaque(items) { + const hits = []; + for (const it of items) { + let found = 0, covered = 0, by = null; + for (const r of it.rects) { + for (let gy = 0; gy < 3; gy++) for (let gx = 0; gx < 6; gx++) { + const stack = document.elementsFromPoint(r.x1 + (r.x2 - r.x1) * (gx + 0.5) / 6, r.y1 + (r.y2 - r.y1) * (gy + 0.5) / 3); + const at = stack.findIndex(e => e === it.el || it.el.contains(e)); + if (at < 0) continue; + found++; + const cover = stack.slice(0, at).find(e => opaque(e, it.el)); + if (cover) { covered++; by = by || cover; } + } + } + if (found && covered / found > 0.3) hits.push({ a: it.text, b: label(by) || `<${by.tagName.toLowerCase()}>`, why: `${Math.round(100 * covered / found)}% covered by an opaque later-painted <${by.tagName.toLowerCase()}>` }); + } + return hits; + } + + // Any painted svg shape (fill or stroke, even a hairline, even pointer-events:none) in the fold. + function paintedShapes() { + const shapes = []; + for (const el of document.querySelectorAll('svg *')) { + if (!GEO.test(el.tagName) || el.closest(NOT_PAINTED) || !visible(el) || effOpacity(el) < 0.95) continue; + const cs = getComputedStyle(el), sw = parseFloat(cs.strokeWidth) || 0; + const fill = cs.fill !== 'none' && +cs.fillOpacity >= 0.95 && !/^line$/i.test(el.tagName); + const stroke = cs.stroke !== 'none' && sw > 0 && +cs.strokeOpacity >= 0.95; + if (!fill && !stroke) continue; + const r = el.getBoundingClientRect(), m = el.getScreenCTM(); + const pad = stroke ? sw * Math.max(1, m ? Math.hypot(m.a, m.b) : 1) / 2 + 1 : 1; + if (r.right + pad < 0 || r.left - pad > vw() || r.bottom + pad < 0 || r.top - pad > vh()) continue; + shapes.push({ el, fill, stroke, rgb: fill ? rgbOf(cs.fill) : null, box: { x1: r.left - pad, y1: r.top - pad, x2: r.right + pad, y2: r.bottom + pad }, z: effZ(el.ownerSVGElement || el) }); + } + return shapes; + } + + function shapeLabel(el) { + const bits = []; + for (let n = el; n && n.nodeType === 1 && bits.length < 2; n = n.parentElement) { + const nm = n.id || n.getAttribute('aria-label') || (typeof n.className === 'string' ? n.className : n.className && n.className.baseVal) || ''; + if (nm) bits.push(`${n === el ? '' : 'in '}${n.tagName.toLowerCase()}${n.id ? '#' + n.id : ' ' + String(nm).trim().split(/\s+/)[0]}`); + if (n.tagName.toLowerCase() === 'svg') break; + } + let cm = ''; + for (let n = el; n && n.nodeType === 1 && !cm; n = n.parentElement) for (let q = n.previousSibling; q && !cm; q = q.previousSibling) if (q.nodeType === 8) cm = q.nodeValue.trim().slice(0, 40); + return `<${el.tagName.toLowerCase()}>${bits.length ? ' ' + bits.join(' ') : ''}${cm ? ` (after "${cm}")` : ''}`; + } + + // painted under the text: only a low-contrast fill the text straddles the edge of (a callout set half over a dark plate) + function straddlesLowContrast(it, sh, inv) { + if (!sh.fill || !sh.rgb) return false; + const tc = rgbOf(getComputedStyle(it.el)[it.el instanceof SVGElement ? 'fill' : 'color']); + if (!tc || contrast(tc, sh.rgb) >= 3) return false; + for (const r of it.rects) { + const s = shrink(r); let tot = 0, inn = 0; + if (!boxesMeet(s, sh.box)) continue; + for (let y = s.y1 + 1; y < s.y2; y += 2) for (let x = s.x1 + 1; x < s.x2; x += 2) { + tot++; + if (sh.el.isPointInFill(new DOMPoint(x, y).matrixTransform(inv))) inn++; + } + if (tot && inn / tot >= 0.08 && inn / tot <= 0.92) return true; + } + return false; + } + + // painted over the text: two sample points inside the text box land in the shape's fill or stroke + function crossesText(it, sh, inv) { + for (const r of it.rects) { + const s = shrink(r); + if (!boxesMeet(s, sh.box)) continue; + let n = 0; + for (let y = s.y1 + 1; y < s.y2; y += 2) for (let x = s.x1 + 1; x < s.x2; x += 2) { + if (x < sh.box.x1 || x > sh.box.x2 || y < sh.box.y1 || y > sh.box.y2) continue; + const pt = new DOMPoint(x, y).matrixTransform(inv); + if (((sh.fill && sh.el.isPointInFill(pt)) || (sh.stroke && sh.el.isPointInStroke(pt))) && ++n >= 2) return true; + } + } + return false; + } + + function shapeOverText(items) { + const shapes = paintedShapes(), hits = [], hitTexts = new Set(); + for (const it of items) { + // a one- or two-character label (a sheet-grid coordinate, a callout number) may sit on the drawing's own lines + if (it.text.length <= 2) continue; + const tz = effZ(it.el); + for (const sh of shapes) { + if (sh.el === it.el || sh.el.contains(it.el) || it.el.contains(sh.el)) continue; + const m = sh.el.getScreenCTM(); if (!m) continue; + const inv = m.inverse(); + const following = !!(it.el.compareDocumentPosition(sh.el) & Node.DOCUMENT_POSITION_FOLLOWING); + const later = sh.z > tz || (sh.z === tz && following); + const under = !later && straddlesLowContrast(it, sh, inv); + if (!under && !(later && crossesText(it, sh, inv))) continue; + // a shape crossing the text explains an 'opaque cover' report; a straddled edge does not + if (!under) hitTexts.add(it.text); + hits.push({ a: it.text, b: shapeLabel(sh.el), why: under ? `straddles the edge of a low-contrast svg <${sh.el.tagName.toLowerCase()}> fill, so part of it is lost against the art` : `crossed by a later-painted svg ${sh.stroke && !sh.fill ? 'stroke' : 'shape'} <${sh.el.tagName.toLowerCase()}>` }); + break; + } + } + return { hits, hitTexts }; + } + + window.__foldText = () => { + const items = textItems(); + const geo = shapeOverText(items); + const covered = textUnderOpaque(items).filter(h => !geo.hitTexts.has(h.a)); + return { texts: items.length, hits: [...textOverText(items), ...covered, ...geo.hits] }; + }; +})(); diff --git a/plugins/design/skills/vary/scripts/roll-treatment.mjs b/plugins/design/skills/vary/scripts/roll-treatment.mjs new file mode 100644 index 0000000..bf0de80 --- /dev/null +++ b/plugins/design/skills/vary/scripts/roll-treatment.mjs @@ -0,0 +1,114 @@ +// roll-treatment.mjs — hero treatment model for roll.mjs (vary SKILL Step 3a): which treatment a concept carries, what each +// treatment obliges, and how dossier directives lock the roll. Pure functions; roll.mjs owns I/O and exits. + +// signature-device techniques (graphics.md section 2) +export const TECHNIQUES = ['seeded-illustration', 'procedural-subject', 'traced-line', 'mechanism-diagram', + 'annotated-photo', 'photo-direction', 'product-capture', 'scroll-scene', 'data-diagram', 'material-texture', 'print-process', + 'typographic', 'generative-pattern', 'shader']; +export const TREATMENTS = ['scene', 'pattern', 'photography', 'product-ui', 'typography', 'data-visual', 'utility']; +export const TECH_TREATMENT = { + 'seeded-illustration': 'scene', 'procedural-subject': 'scene', 'scroll-scene': 'scene', 'traced-line': 'scene', + 'mechanism-diagram': 'scene', 'print-process': 'scene', + 'generative-pattern': 'pattern', 'material-texture': 'pattern', shader: 'pattern', + 'annotated-photo': 'photography', 'photo-direction': 'photography', + 'product-capture': 'product-ui', typographic: 'typography', 'data-diagram': 'data-visual', +}; +export const MODE_TREATMENTS = { + operate: ['utility', 'data-visual', 'product-ui', 'typography'], + read: ['utility', 'typography', 'data-visual', 'photography', 'pattern'], + persuade: TREATMENTS.filter(t => t !== 'utility'), + experience: TREATMENTS.filter(t => t !== 'utility'), +}; +export const isLead = mode => mode === 'persuade' || mode === 'experience'; +export const fallbackTreatment = mode => (isLead(mode) ? 'typography' : 'utility'); +export const allowedFor = (mode, t) => (MODE_TREATMENTS[mode] || []).includes(t); +export const drawn = t => t === 'scene' || t === 'pattern'; + +export const OBLIGATION = { + scene: 'a full-bleed drawn scene or sheet; the scene bar (SKILL Step 3a S1-S8) applies', + pattern: 'a generative, material or print field carrying the type; brief and gallery critique, no crew', + photography: 'client photos from dossier.assets shown faithfully as the hero; crop, sequence and grade are the signature; the scene bar does not apply', + 'product-ui': 'the real product capture or live UI shown faithfully, tied to a named workflow; DPR-2 captures, callouts allowed; the scene bar does not apply', + typography: 'type is the picture: a typographic system or lettering idea that encodes a fact; no drawn scene is required', + 'data-visual': "a bespoke chart or diagram built from the dossier's data, with a text equivalent", + utility: 'no hero: the tool or document is the first screen; the details carry the signature; a visible page title in the fold', +}; +const SCALE = { + scene: 'hero, divider, micro (icon/bullet/empty state)', pattern: 'hero, divider, micro (icon/bullet/empty state)', + utility: 'details: icons, numerals, empty states', +}; +export const scaleLine = t => (SCALE[t] ? `must appear at 3 scales: ${SCALE[t]}` : 'must appear at hero and one more scale'); +export const minDevices = t => (!t || drawn(t) ? 2 : t === 'utility' ? 0 : 1); + +// a reason string when the concept's treatment, device count or grounding is invalid; null when fine +export function treatmentProblem(c, dossier) { + const t = c.treatment; + if (t !== undefined && !TREATMENTS.includes(t)) return `treatment must be one of ${TREATMENTS.join(', ')}, got ${t}`; + const n = Array.isArray(c.devices) ? c.devices.length : 0; + if (n < minDevices(t)) return `needs at least ${minDevices(t)} device${minDevices(t) === 1 ? '' : 's'}${t ? ` for treatment ${t}` : ''}, got ${n}`; + if (t && t !== 'typography' && t !== 'utility' && !c.devices.some(d => TECH_TREATMENT[d.technique] === t)) { + const need = Object.entries(TECH_TREATMENT).filter(([, v]) => v === t).map(([k]) => k); + return `treatment ${t} needs at least one device whose technique maps to it (${need.join(', ')})`; + } + const assets = Array.isArray(dossier?.assets) ? dossier.assets : []; + if (t === 'photography' && !assets.some(a => a && a.kind === 'photo' && a.usable === true)) return 'treatment photography needs at least one dossier.assets[] entry with kind "photo" and usable: true'; + const screens = dossier?.product?.screens; + const described = Array.isArray(screens) && screens.length > 0 && screens.every(x => typeof x === 'string' && x.trim()); + if (t === 'product-ui' && !assets.some(a => a && a.kind === 'screenshot') && !(typeof dossier?.product?.app_path === 'string' && dossier.product.app_path.trim()) && !described) return 'treatment product-ui needs a dossier.assets[] entry with kind "screenshot", a non-empty dossier.product.app_path, or a non-empty dossier.product.screens[] (screens described in the brief, rebuilt in HTML from the facts)'; + return null; +} + +// Merge dossier.directives[].locks ("key=value") into locks. Directives outrank the CLI: a conflicting CLI lock is an error string. +export function mergeDirectiveLocks(locks, dossier) { + const cli = { ...locks }; + for (const d of dossier?.directives || []) { + for (const s of d?.locks || []) { + const [k, ...rest] = String(s).split('='), v = rest.join('='); + if (k in cli && cli[k] !== v) return `directive "${d.text}" locks ${k}=${v}; the CLI asked ${cli[k]}`; + locks[k] = v; + } + } + return null; +} + +// Choose the treatment for a dealt concept and its tentative device. fromDevice = derived from the device (keep that device). +export function resolveTreatment({ concept, device, mode, lock }) { + const notes = []; + let t, fromDevice = false; + if (lock) { + t = lock; + if (concept?.treatment && concept.treatment !== lock) notes.push(`treatment locked to ${lock}; concept ${concept.id} names ${concept.treatment}, overridden`); + else notes.push(`treatment locked to ${lock}`); + } else if (concept?.treatment) t = concept.treatment; + else if (device) { t = TECH_TREATMENT[device.technique]; fromDevice = true; } + else t = fallbackTreatment(mode); + if (!lock && !allowedFor(mode, t)) { + const fb = fallbackTreatment(mode); + notes.push(`treatment ${t} is not allowed for mode ${mode}; fell back to ${fb}`); + t = fb; fromDevice = false; + } + return { treatment: t, fromDevice, notes, locked: !!lock }; +} + +export function directiveLines(dossier) { + return (dossier?.directives || []).map(d => `DIRECTIVES: "${d.text}"${d.locks?.length ? ` (locks: ${d.locks.join(', ')})` : ''}`); +} + +export function ruleSentence(treatment) { + if (!treatment || drawn(treatment)) return 'Write the art brief and pass the gallery critique before any UI page code.'; + if (treatment === 'utility') return 'Nothing to author before Step 3 (SKILL Step 3a: utility skips ahead); the tool is the first screen.'; + return 'Write the visual brief (asset/capture list) per SKILL Step 3a before any UI page code.'; +} + +// Under a typography or utility treatment the signature device is typographic: a drawn device would force an illustration the +// treatment (often a user directive) excludes. Returns the device to print and a note when it was replaced. +export function fitDevice(device, concept, treatment) { + if ((treatment !== 'typography' && treatment !== 'utility') || (device && TECH_TREATMENT[device.technique] === 'typography')) return { device, note: null }; + const typo = concept?.devices?.find(d => d.technique === 'typographic'); + const dv = typo || { technique: 'typographic', what: '(synthesized: no drawn device under a typography/utility treatment; carry the concept in type, numerals, rules and layout)' }; + return { device: dv, note: device ? `treatment ${treatment} takes a typographic device; dropped drawn device ${device.technique}` : null }; +} + +// A directive that asks for black and white / monochrome while locking ink-on-paper: no accent, no tinted seed. +export const noAccent = (dossier, strategy) => strategy === 'ink-on-paper' + && (dossier?.directives || []).some(d => /black\s*(and|&|\/)\s*white|monochrome|greyscale|grayscale/i.test(d?.text || '')); diff --git a/plugins/design/skills/vary/scripts/roll.mjs b/plugins/design/skills/vary/scripts/roll.mjs index a6915de..69fc0b3 100755 --- a/plugins/design/skills/vary/scripts/roll.mjs +++ b/plugins/design/skills/vary/scripts/roll.mjs @@ -16,15 +16,28 @@ * [--longevity long|campaign] [--lock world=] [--lock archetype=] * [--lock strategy=] [--lock seed=] [--lock palette=#hex] * [--lock display=] [--lock body=] + * [--lock treatment=] [--lock device=] * [--from ] [--reroll ] [--register safer|bolder] [--canon] - * [--grounded ] [--project ] [--json] [--no-memory] + * [--dossier --concepts ] [--sector ] [--concept-only] + * [--project ] [--json] [--no-memory] * * --from replay a roll exactly. --reroll n next hand under the same key * (excludes every world dealt in rounds 0..n-1). - * --grounded JSON array [{"name":"…","p":0.3,"why":"…"}] of the model's own cultural - * antecedents with self-estimated probabilities; the script picks from the - * TAIL (drops the top third) and prints it as GROUNDED ANTECEDENT. + * --dossier .vary/dossier.json: the project's facts [{id,kind,text,source}]. Required with --concepts. + * --concepts .vary/concepts.json: 4-7 concepts the MODEL wrote, each tied to dossier facts; the script, not the + * model, picks one (uniform; a recently used angle is demoted), then picks the + * signature-device technique from that concept's devices (recent techniques demoted). + * --sector the project's sector (enum below). Worlds whose avoid_sectors list it are removed; + * worlds whose sectors list it are weighted up. --lock world still wins. + * --lock concept= world-only veto: keep the concept, --reroll n re-deals world and device. With a concept dealt the + * world pool is concept.world_hints only; 5% of scene/pattern rounds (never operate/read, never a non-drawn treatment) print WILDCARD and widen it to every sector-allowed world, where sector-suited worlds keep their weight. + * Each concept needs a `temperament` (heritage-craft, warm-domestic, civic-calm, technical-precise, + * playful-bright, natural-raw, nocturnal-social, avant-garde); worlds not listing it are removed first. + * --concept-only print only the CONCEPT / FACTS / FUNCTIONAL RESKIN / SIGNATURE DEVICE block (no world). * --canon the category standard, played straight and with craft (the user's exit). + * treatment every concept may name a hero `treatment` (roll-treatment.mjs); otherwise it follows the dealt device's technique. + * dossier.directives[].locks ("key=value", from the user's own words) are merged into --lock and outrank it: + * a conflicting CLI lock exits 2. * * Exit codes: 0 ok · 2 bad arguments · 3 data missing/corrupt. * No dependencies. Node ≥ 18. @@ -34,6 +47,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } fr import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; +import { TECHNIQUES, TREATMENTS, TECH_TREATMENT, allowedFor, fallbackTreatment, mergeDirectiveLocks, resolveTreatment, fitDevice, noAccent, drawn, treatmentProblem, directiveLines, OBLIGATION, scaleLine, ruleSentence } from './roll-treatment.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const DATA = resolve(here, '..', 'data'); @@ -46,6 +60,19 @@ const DEMOTED_TICKETS = 1; // weight of a recently-used / decaying entry const DEFAULT_TICKETS = 0.5; // weight of a second-order-default face (Satoshi, Geist, Inter…): kept, rarely dealt const TAG_BONUS = 1; // extra weight for a font the world card names; capped so over-tagged faces don't dominate const TAG_CAP = 6; // fonts tagged with more worlds than this get no bonus +const HINT_MULT = 8; // world named in the dealt concept's world_hints +const SUIT_MULT = 1.5; // world.sectors includes --sector + +// controlled sector vocabulary (worlds.json `sectors` / `avoid_sectors` draw from this) +const SECTORS = ['food-drink', 'hospitality-travel', 'retail-ecommerce', 'trades-construction', 'industrial-hardware', + 'engineering-technical', 'healthcare-clinical', 'finance-insurance', 'legal-public', 'kids-education', + 'culture-arts', 'music-events-nightlife', 'fashion-beauty-luxury', 'sports-fitness', 'media-publishing', + 'dev-tools', 'enterprise-saas', 'nature-outdoor', 'maritime-logistics', 'nonprofit-activism', 'wellness', 'gaming']; +// the business's own register (concept.temperament); every world lists the 2-4 it can serve (worlds.json `temperaments`) +const TEMPERAMENTS = ['heritage-craft', 'warm-domestic', 'civic-calm', 'technical-precise', 'playful-bright', + 'natural-raw', 'nocturnal-social', 'avant-garde']; +// techniques that cannot be the hero device on a surface of that mode (the device stays at plate/micro scale there) +const HERO_FORBIDDEN = { operate: ['shader', 'scroll-scene'], read: ['shader'] }; // mode → colour strategies bold enough to be allowed on that surface (the gate) const MODE_STRATEGIES = { @@ -77,7 +104,10 @@ function parseArgs(argv) { case '--reroll': a.reroll = Number(take()); break; case '--register': a.register = take(); break; case '--canon': a.canon = true; break; - case '--grounded': a.grounded = take(); break; + case '--dossier': a.dossier = take(); break; + case '--concepts': a.concepts = take(); break; + case '--sector': a.sector = take(); if (!SECTORS.includes(a.sector)) die(2, `--sector ${a.sector} is not in the sector list: ${SECTORS.join(', ')}`); break; + case '--concept-only': a.conceptOnly = true; break; case '--project': a.project = take(); break; case '--json': a.json = true; break; case '--no-memory': a.memory = false; break; @@ -158,6 +188,9 @@ if (!['web', 'ios', 'android'].includes(args.platform)) die(2, '--platform must if (args.register && !['safer', 'bolder'].includes(args.register)) die(2, '--register must be safer | bolder'); if (args.register && args.reroll < 1) die(2, '--register steers a re-roll; pass --reroll with it'); if (args.scene && !['light', 'dark'].includes(args.scene)) die(2, '--scene must be light | dark'); +if (args.concepts && !args.dossier) die(2, '--concepts needs --dossier (every cited fact id must resolve in it)'); +if (args.locks.concept && !args.concepts) die(2, '--lock concept= needs --dossier and --concepts'); +if (args.conceptOnly && !args.concepts) die(2, '--concept-only needs --dossier and --concepts'); const worlds = loadJSON('worlds.json'); const archetypes = loadJSON('archetypes.json'); @@ -173,16 +206,142 @@ const recent = axis => new Set([...recentSet(recentGlobal, axis, GLOBAL_WINDOW), const notes = []; const coherence = []; +// ---- 0. concept (the model wrote the options; the script deals) -------------------------------- +function readJSONArg(flag, path) { + try { return JSON.parse(readFileSync(path, 'utf8')); } catch (e) { die(2, `${flag}: cannot read ${path}: ${e.message}`); } +} +function validateConcepts(list, dossier) { + if (!Array.isArray(list)) die(2, '--concepts: file must be a JSON array of concepts'); + if (list.length < 4 || list.length > 7) die(2, `--concepts: need 4-7 concepts, got ${list.length}`); + const factIds = new Set((dossier.facts || []).map(f => f.id)); + const ids = new Set(), angles = new Set(); + for (const [i, c] of list.entries()) { + const at = `--concepts[${i}]${c && c.id ? ` (${c.id})` : ''}`; + if (!c || typeof c.id !== 'string' || !c.id) die(2, `${at}: missing id`); + if (ids.has(c.id)) die(2, `${at}: duplicate id ${c.id}`); + ids.add(c.id); + if (typeof c.angle !== 'string' || !c.angle.trim()) die(2, `${at}: missing angle`); + const ang = c.angle.trim().toLowerCase(); + if (angles.has(ang)) die(2, `${at}: duplicate angle "${c.angle}" (each concept needs a different angle on the business)`); + angles.add(ang); + if (!Array.isArray(c.facts) || c.facts.length < 3) die(2, `${at}: needs at least 3 facts, got ${Array.isArray(c.facts) ? c.facts.length : 0}`); + for (const f of c.facts) if (!factIds.has(f)) die(2, `${at}: fact id ${f} is not in the dossier`); + if (typeof c.functional_reskin !== 'string' || !c.functional_reskin.trim()) die(2, `${at}: functional_reskin is empty`); + if (!Array.isArray(c.devices)) die(2, `${at}: devices must be an array`); + for (const d of c.devices) if (!d || !TECHNIQUES.includes(d.technique)) die(2, `${at}: unknown technique ${d && d.technique}; use one of ${TECHNIQUES.join(', ')}`); + const problem = treatmentProblem(c, dossier); if (problem) die(2, `${at}: ${problem}`); + if (!TEMPERAMENTS.includes(c.temperament)) die(2, `${at}: temperament must be one of ${TEMPERAMENTS.join(', ')} (the business's own register, from the dossier), got ${c.temperament ?? 'none'}`); + const hints = c.world_hints ?? []; + if (!Array.isArray(hints) || hints.length < 2 || hints.length > 3) die(2, `${at}: world_hints must name 2-3 world ids (worlds whose grammar suits this concept), got ${Array.isArray(hints) ? hints.length : 'none'}`); + for (const h of hints) if (!worlds.some(w => w.id === h)) die(2, `${at}: world_hint ${h} is not a world id; valid ids: ${worlds.map(w => w.id).join(', ')}`); + if (!hints.some(h => worlds.find(w => w.id === h).temperaments?.includes(c.temperament))) die(2, `${at}: no world_hint fits temperament ${c.temperament}; name at least one world whose temperaments list it`); + } +} +function validateDossier(d) { + if (!d || typeof d !== 'object' || Array.isArray(d)) die(2, '--dossier: file must be a JSON object'); + if (!Array.isArray(d.facts)) die(2, '--dossier: "facts" array is missing (schema in references/dossier.md: facts:[{id,kind,text,source}])'); + if (d.facts.length < 12) die(2, `--dossier: need at least 12 facts, got ${d.facts.length}`); + for (const [i, f] of d.facts.entries()) { + if (!f || typeof f.id !== 'string' || !f.id) die(2, `--dossier: facts[${i}] has no id`); + if (typeof f.source !== 'string' || !f.source.trim()) die(2, `--dossier: fact ${f.id} has no source (every fact needs a URL, file path or "user brief")`); + } + const kinds = new Set(d.facts.map(f => f.kind).filter(k => typeof k === 'string' && k)); + if (kinds.size < 4) die(2, `--dossier: need at least 4 distinct fact kinds, got ${kinds.size} (${[...kinds].join(', ') || 'none'})`); + if (d.sector && args.sector && d.sector !== args.sector) die(2, `--dossier: sector "${d.sector}" does not match --sector ${args.sector}`); +} +const dossierData = args.dossier ? readJSONArg('--dossier', args.dossier) : null; +if (dossierData) validateDossier(dossierData); +{ + const conflict = mergeDirectiveLocks(args.locks, dossierData); if (conflict) die(2, conflict); + if (args.locks.treatment && !TREATMENTS.includes(args.locks.treatment)) die(2, `--lock treatment=${args.locks.treatment} is not a treatment (${TREATMENTS.join(', ')})`); + if (args.locks.device && !TECHNIQUES.includes(args.locks.device)) die(2, `--lock device=${args.locks.device} is not a technique (${TECHNIQUES.join(', ')})`); +} +let treatment = null; +// with a treatment lock, prefer concepts whose treatment equals it or is unset; when none qualify the dealt concept stays and is overridden +const eligible = list => { const l = args.locks.treatment ? list.filter(c => !c.treatment || c.treatment === args.locks.treatment) : list; return l.length ? l : list; }; +let concept = null, device = null, conceptFacts = []; +if (args.concepts) { + const dossier = dossierData; + const list = readJSONArg('--concepts', args.concepts); + validateConcepts(list, dossier); + const factById = Object.fromEntries((dossier.facts || []).map(f => [f.id, f])); + const recentAngles = recentSet(recentLocal, 'angle', PROJECT_WINDOW); + // every concept is fact-backed, so none is dropped for being likely; only a recently used angle is demoted + const pickConcept = (cands, round) => weightedPick(cands, cands.map(c => (recentAngles.has(c.angle) ? DEMOTED_TICKETS : BASE_TICKETS)), key, `concept:${round}`); + if (args.locks.concept) { + // world-only veto: the concept stays, only the world and device re-roll + concept = list.find(c => c.id === args.locks.concept) || die(2, `--lock concept=${args.locks.concept} is not a concept id in --concepts (${list.map(c => c.id).join(', ')})`); + notes.push(`concept locked to ${concept.id}; --reroll changes only the world and device`); + } else { + const dealt = new Set(); + for (let r = 0; r < args.reroll; r++) { + const left = eligible(list).filter(c => !dealt.has(c.id)); + const prev = pickConcept(left.length ? left : list, r); + if (prev) dealt.add(prev.id); + } + let cPool = eligible(list).filter(c => !dealt.has(c.id)); + if (!cPool.length) { cPool = eligible(list); notes.push('re-roll exhausted the concepts; reusing'); } + concept = pickConcept(cPool, args.reroll); + } + conceptFacts = concept.facts.map(id => factById[id]); + const forbidden = HERO_FORBIDDEN[args.mode] || []; + let devCands = concept.devices.filter(d => !forbidden.includes(d.technique)); + if (!devCands.length && concept.devices.length) { devCands = concept.devices; notes.push(`every device of ${concept.id} is a poor hero for mode ${args.mode}; keep it at plate/micro scale, not hero`); } + const rt = recent('technique'); + const deal = (pool, maps) => weightedPick(pool, pool.map(d => (rt.has(d.technique) ? DEMOTED_TICKETS : BASE_TICKETS) * (maps && TECH_TREATMENT[d.technique] === maps ? 4 : 1)), key, `device:${args.reroll}`); + device = devCands.length ? deal(devCands) : null; + const rt2 = resolveTreatment({ concept, device, mode: args.mode, lock: args.locks.treatment }); + treatment = rt2.treatment; notes.push(...rt2.notes); + if (args.locks.device) { + device = concept.devices.find(d => d.technique === args.locks.device) || { technique: args.locks.device, what: '(locked by the user or a directive; the concept names no device of this technique)' }; + notes.push(`device locked to ${args.locks.device}`); + } else if (!rt2.fromDevice && devCands.length) device = deal(devCands, treatment); + if (!args.locks.device) { const fd = fitDevice(device, concept, treatment); device = fd.device; if (fd.note) notes.push(fd.note); } + coherence.push({ check: 'concept facts resolve in dossier', ok: conceptFacts.every(Boolean) }); + coherence.push({ check: 'device allowed as hero for mode', ok: !device || !forbidden.includes(device.technique) }); + coherence.push({ check: 'treatment allowed for mode', ok: rt2.locked || allowedFor(args.mode, treatment) }); +} else { + notes.push('no --concepts: subject-blind roll; for a new project vary requires Step 0-1 (dossier, then concepts)'); + if (args.locks.treatment) treatment = args.locks.treatment; +} +if (args.conceptOnly) { + const lines = [`SEED KEY: ${key} (reroll ${args.reroll}) · mode ${args.mode} · concept only`, ...conceptLines(concept, device, conceptFacts, treatment)]; + if (args.json) console.log(JSON.stringify({ key, reroll: args.reroll, mode: args.mode, concept: concept.id, angle: concept.angle, treatment, device, facts: conceptFacts, coherence, notes, contract: lines }, null, 1)); + else { + console.log(''); console.log(''); + console.log('COHERENCE:'); for (const r of coherence) console.log(` [${r.ok ? 'ok' : 'FAIL'}] ${r.check}`); + if (notes.length) { console.log('NOTES:'); for (const n of notes) console.log(` - ${n}`); } + console.log(''); console.log(`RULE: commit to this concept and device; the model does not swap them. Replay: --from ${key}${args.reroll ? ` --reroll ${args.reroll}` : ''}. Next hand: --from ${key} --reroll ${args.reroll + 1}.`); + } + if (args.memory) { + const pick = { ts: new Date().toISOString(), key, mode: args.mode, concept: concept.id, angle: concept.angle, technique: device?.technique, treatment }; + recordPick(files.global, pick, 64); + if (files.local) recordPick(files.local, pick, 32); + } + process.exit(0); +} + // ---- canon --------------------------------------------------------------------------------- if (args.canon) { - const canonArch = { persuade: 'split-screen', operate: 'sidebar-tool', read: 'single-column', experience: 'poster' }[args.mode]; + const canonArch = { persuade: phone ? 'poster' : 'split-screen', operate: phone ? 'index-list' : 'sidebar-tool', read: 'single-column', experience: 'poster' }[args.mode]; + if (!concept) { treatment = args.locks.treatment || fallbackTreatment(args.mode); notes.push(`canon with no concept: treatment ${treatment}`); } + else if (drawn(treatment) && !args.locks.treatment) { + // split-screen cannot carry a drawn scene (headline-in-scene, full-bleed rows), so canon never takes a drawn treatment + treatment = fallbackTreatment(args.mode); notes.push(`canon: concept ${concept.id} treatment is drawn; split-screen cannot carry it, using ${treatment}`); + if (!args.locks.device) { const fd = fitDevice(device, concept, treatment); device = fd.device; if (fd.note) notes.push(fd.note); } + } const body = fonts.find(f => f.id === 'system-ui') || fonts[0]; const out = contract({ key, reroll: args.reroll, mode: args.mode, platform: args.platform, world: { id: 'canon', name: 'Category standard, played straight', grammar: ['Match the two or three products the user names as peers; their craft level is the bar.', 'No irony, no smuggled quirk: convention executed at full fidelity.', 'Distinctiveness lives in details: spacing rhythm, focus states, empty states, copy that only this product could say.'], refuse: ['half-committed novelty on top of the conventional skeleton'], motion: { moment: 'one state transition tuned to the product (e.g. the primary action\'s success), nothing else animates', easing: 'ease-out, 150-250ms', forbidden: ['uniform fade-up on every section'] } }, - archetype: archById[canonArch], strategy: 'restrained', seed: null, display: body, body, + archetype: archById[canonArch], strategy: 'restrained', seed: null, display: body, body, concept, device, conceptFacts, treatment, }); emit(out, { canon: true }); + if (args.memory && concept) { + const pick = { ts: new Date().toISOString(), key, mode: args.mode, concept: concept?.id, angle: concept?.angle, technique: device?.technique, treatment }; + recordPick(files.global, pick, 64); + if (files.local) recordPick(files.local, pick, 32); + } process.exit(0); } @@ -193,24 +352,59 @@ if (args.locks.world) { const w = worlds.find(x => x.id === args.locks.world); if (!w) die(2, `--lock world=${args.locks.world} is not a world id`); pool = [w]; + if (args.sector && (w.avoid_sectors || []).includes(args.sector)) notes.push(`locked world ${w.id} lists ${args.sector} in avoid_sectors (user lock honoured)`); } if (args.register === 'safer') pool = pool.filter(w => w.decay === 'slow' && w.color_strategies.some(s => QUIET_STRATEGIES.has(s))); if (args.register === 'bolder') pool = pool.filter(w => w.modes.includes('experience') || w.decay !== 'slow'); +// sector fit: a world that lists the project's sector in avoid_sectors is not dealt (a user lock still wins) +// temperament fit: with a concept dealt, a world whose temperaments exclude the concept's is never dealt (the wildcard +// widens sector, never temperament) +// avant-garde worlds are dealt only to avant-garde concepts, by any path (hint, sector fit or wildcard) +const tempOk = w => !concept || !!args.locks.world || ((w.temperaments || []).includes(concept.temperament) + && (concept.temperament === 'avant-garde' || !(w.temperaments || []).includes('avant-garde'))); +const sectorOk = w => !args.sector || !(w.avoid_sectors || []).includes(args.sector); +if (concept && !args.locks.world) { + const before = pool.length; pool = pool.filter(tempOk); + if (pool.length < before) notes.push(`${before - pool.length} worlds removed: temperaments exclude ${concept.temperament}`); +} +if (args.sector && !args.locks.world) { + const before = pool.length; pool = pool.filter(sectorOk); + if (pool.length < before) notes.push(`${before - pool.length} worlds removed: avoid_sectors includes ${args.sector}`); +} if (!pool.length) { - // relax: adjacent mode, then everything non-trap + // relax: adjacent mode, then everything non-trap (the sector filter still applies) const adjacent = { persuade: 'experience', experience: 'persuade', read: 'persuade', operate: 'read' }[args.mode]; - pool = worlds.filter(w => !w.trap && w.modes?.includes(adjacent)); + pool = worlds.filter(w => !w.trap && w.modes?.includes(adjacent) && sectorOk(w) && tempOk(w)); notes.push(`world pool empty for mode=${args.mode}; relaxed to adjacent mode ${adjacent}`); - if (!pool.length) { pool = worlds.filter(w => !w.trap); notes.push('relaxed to full catalog'); } + if (!pool.length) { pool = worlds.filter(w => !w.trap && sectorOk(w) && tempOk(w)); notes.push('relaxed to full catalog'); } } +// relevance: with a concept dealt, the pool is concept.world_hints only; WILDCARD_P of the rounds widen to every +// sector-allowed world (sector-suited worlds keep their SUIT_MULT weight there and in the empty-pool fallback). The wildcard +// never fires for a non-drawn treatment or for operate/read: those stay inside their hints. Recency demotion stays inside whichever pool. +const WILDCARD_P = 0.05; +const narrowFor = (round, excluded) => { + const open = pool.filter(w => !excluded.has(w.id)); + if (!concept || args.locks.world) return { list: open }; + const wildOk = drawn(treatment) && args.mode !== 'operate' && args.mode !== 'read'; + if (wildOk && unit(key, `wild:${round}`) < WILDCARD_P) { + // the wildcard never deals an avant-garde-tagged world to a concept that is not itself avant-garde + const calm = open.filter(w => concept.temperament === 'avant-garde' || !(w.temperaments || []).includes('avant-garde')); + return { list: calm.length ? calm : open, wild: true }; + } + const hints = new Set(concept.world_hints || []); + const narrow = open.filter(w => hints.has(w.id)); + return narrow.length ? { list: narrow } : { list: open, empty: true }; +}; // exclusion chain for re-rolls: recompute rounds 0..n-1 under this key and drop what they dealt const excludedWorlds = new Set(); for (let r = 0; r < args.reroll; r++) { - const prev = pickWorld(pool.filter(w => !excludedWorlds.has(w.id)), r); + const prev = pickWorld(narrowFor(r, excludedWorlds).list, r); if (prev) excludedWorlds.add(prev.id); } -let worldPool = pool.filter(w => !excludedWorlds.has(w.id)); +let { list: worldPool, wild, empty } = narrowFor(args.reroll, excludedWorlds); if (!worldPool.length) { worldPool = pool; notes.push('re-roll exhausted the pool; reusing'); } +if (wild) notes.push('WILDCARD: this round (5% of rolls) widened the world pool beyond the concept hints; make the world earn its place or re-roll'); +if (empty) notes.push('no world matches the concept hints in this mode; pool widened to every sector-allowed world'); const world = pickWorld(worldPool, args.reroll); if (!world) die(3, 'no world could be assigned'); @@ -222,6 +416,9 @@ function pickWorld(cands, round) { if (args.longevity === 'long' && w.decay === 'fast') t = Math.min(t, DEMOTED_TICKETS); if (w.confidence === 'medium') t = Math.max(1, t - 1); if (w.confidence === 'verify') t = Math.min(t, DEMOTED_TICKETS); // grammar not fully corroborated: dealt rarely + // multiplicative, so recency still bites: a recent but hinted world is dealt about as often as an untouched, unhinted one + if (concept?.world_hints?.includes(w.id)) t *= HINT_MULT; + if (args.sector && (w.sectors || []).includes(args.sector)) t *= SUIT_MULT; return t; }); return weightedPick(cands, weights, key, `world:${round}`); @@ -246,12 +443,16 @@ const strategy = weightedPick(stratCands, stratCands.map(s => (rs.has(s) ? DEMOT // ---- 4. palette seed --------------------------------------------------------------------------- let seed = null, lockedHex = null; +const bw = noAccent(dossierData, strategy); +if (bw) notes.push('directive asks for black and white with ink-on-paper: no accent colour, no tinted palette seed'); if (args.locks.palette) { lockedHex = args.locks.palette.startsWith('#') ? args.locks.palette : `#${args.locks.palette}`; const ok = hexToOklch(lockedHex); const b = bucketOf(ok.h); const near = seeds.filter(s => s.bucket === b).map(s => ({ s, d: Math.hypot(s.oklch.l - ok.l, (s.oklch.c - ok.c) * 2) })).sort((x, y) => x.d - y.d)[0]; if (near && near.d < 0.15) { seed = near.s; notes.push(`brand ${lockedHex} matched to ${seed.id} (bucket ${b}); the literal hex stays the anchor`); } else notes.push(`brand ${lockedHex} (bucket ${b}) has no close seed; carry the literal hex as PALETTE SEED`); +} else if (bw) { + // no seed: true black and paper white only } else if (args.locks.seed) { seed = seeds.find(s => s.id === args.locks.seed) || die(2, `--lock seed=${args.locks.seed} is not a seed id`); } else { @@ -310,30 +511,19 @@ if (body && !body.variable && (body.weights || '').split(/[,;]/).length < 3 && b if (alt.length) { body = weightedPick(alt, alt.map(f => fontWeight(f, recent('body'))), key, `body-alt:${args.reroll}`); notes.push('body face swapped for one that ships ≥3 weights (no faux bold)'); } } -// ---- 6. grounded antecedents (Verbalized Sampling tail pick) --------------------------------------- -let grounded = null; -if (args.grounded) { - let list; try { list = JSON.parse(readFileSync(args.grounded, 'utf8')); } catch (e) { die(2, `--grounded: cannot read ${args.grounded}: ${e.message}`); } - if (!Array.isArray(list) || list.length < 3) die(2, '--grounded needs an array of ≥3 {name,p,why}'); - const ranked = [...list].sort((a, b) => (b.p ?? 0) - (a.p ?? 0)); - const drop = Math.max(1, Math.floor(ranked.length / 3)); - const tail = ranked.slice(drop); - grounded = weightedPick(tail, tail.map(() => 1), key, `grounded:${args.reroll}`); - grounded.rank = ranked.indexOf(grounded) + 1; grounded.of = ranked.length; -} - -// ---- 7. coherence check (script-side rows) ---------------------------------------------------------- +// ---- 6. coherence check (script-side rows) ---------------------------------------------------------- coherence.push({ check: 'strategy passes mode gate', ok: MODE_STRATEGIES[args.mode].includes(strategy) }); coherence.push({ check: 'seed light/dark compatible with --scene', ok: !seed || !args.scene || seed.light_or_dark === 'either' || seed.light_or_dark === args.scene }); coherence.push({ check: 'archetype allowed on platform', ok: !phone || !PHONE_EXCLUDED_ARCHETYPES.has(archetype.id) }); coherence.push({ check: 'fonts cover required scripts', ok: [display, body].every(f => !f || fontOk(f)) }); coherence.push({ check: 'body face ships ≥3 weights or is variable', ok: !body || body.id === 'system-ui' || body.variable || (body.weights || '').split(/[,;]/).length >= 3 }); +coherence.push({ check: 'world not in sector avoid list', ok: !args.sector || !(world.avoid_sectors || []).includes(args.sector) }); -// ---- 8. output -------------------------------------------------------------------------------------- -const result = contract({ key, reroll: args.reroll, mode: args.mode, platform: args.platform, world, archetype, strategy, seed, lockedHex, display, body, grounded }); +// ---- 7. output -------------------------------------------------------------------------------------- +const result = contract({ key, reroll: args.reroll, mode: args.mode, platform: args.platform, world, archetype, strategy, seed, lockedHex, display, body, concept, device, conceptFacts, treatment, bw }); emit(result, {}); if (args.memory) { - const pick = { ts: new Date().toISOString(), key, mode: args.mode, world: world.id, archetype: archetype.id, strategy, seed: seed?.id, display: display?.id, body: body?.id }; + const pick = { ts: new Date().toISOString(), key, mode: args.mode, world: world.id, archetype: archetype.id, strategy, seed: seed?.id, display: display?.id, body: body?.id, concept: concept?.id, angle: concept?.angle, technique: device?.technique, treatment }; recordPick(files.global, pick, 64); if (files.local) recordPick(files.local, pick, 32); } @@ -344,17 +534,29 @@ function fontLine(f) { const w = f.variable ? `variable ${f.weights}` : `weights ${f.weights}`; return `${f.name} (${f.class}; ${w}) · ${load}`; } +function conceptLines(cn, dv, facts, tr) { + if (!cn) return ['CONCEPT: none', ...(tr ? [`HERO TREATMENT: ${tr} — ${OBLIGATION[tr]}`] : []), ...directiveLines(dossierData)]; + const l = [`CONCEPT: ${cn.id} — ${cn.name || cn.id} — angle: ${cn.angle} — antecedent: ${cn.antecedent || 'n/a'}`, 'FACTS:']; + for (const f of facts) { const t = (f.text || '').replace(/\s+/g, ' '); l.push(` - ${f.id} "${t.length > 100 ? t.slice(0, 99) + '…' : t}" (${f.source || 'no source'})`); } + l.push(`FUNCTIONAL RESKIN: ${cn.functional_reskin}`); + l.push(`SIGNATURE DEVICE: ${dv ? `${dv.technique} — ${dv.what || ''}` : 'none'} · ${scaleLine(tr)}`); + if (tr) l.push(`HERO TREATMENT: ${tr} — ${OBLIGATION[tr]}`); + l.push(...directiveLines(dossierData)); + return l; +} function contract(c) { const lines = []; lines.push(`SEED KEY: ${c.key} (reroll ${c.reroll}) · mode ${c.mode} · platform ${c.platform}`); lines.push(`WORLD: ${c.world.id} — ${c.world.name}${c.world.one_line ? ' — ' + c.world.one_line : ''}`); - if (c.grounded) lines.push(`GROUNDED ANTECEDENT: ${c.grounded.name} (your #${c.grounded.rank} of ${c.grounded.of} by self-estimated likelihood) — ${c.grounded.why || ''}`); + lines.push(...conceptLines(c.concept, c.device, c.conceptFacts, c.treatment)); + if (c.world.suits) lines.push(`FIT: sector ${args.sector || 'unspecified'} · world suits: ${[].concat(c.world.suits).join(', ')} · world avoid_for: ${[].concat(c.world.avoid_for || []).join(', ')}`); lines.push(`ARCHETYPE: ${c.archetype.id} — ${c.archetype.structure}`); lines.push(` first viewport: ${c.archetype.first_viewport}; primary action: ${c.archetype.primary_action}; scroll: ${c.archetype.scroll}`); lines.push(` mobile: ${c.archetype.mobile_fallback}`); - lines.push(`STRATEGY: ${c.strategy}${c.world.palette_hint ? ' — ' + c.world.palette_hint : ''}`); + lines.push(`STRATEGY: ${c.strategy}${c.bw ? ' — true black + paper white only; NO accent colour (the directive overrides the world palette hint)' : c.world.palette_hint ? ' — ' + c.world.palette_hint : ''}`); if (c.lockedHex && !c.seed) lines.push(`PALETTE SEED: brand ${c.lockedHex} (locked) · oklch(${Object.values(hexToOklch(c.lockedHex)).join(' ')})`); else if (c.seed) lines.push(`PALETTE SEED: ${c.seed.id} · oklch(${c.seed.oklch.l} ${c.seed.oklch.c} ${c.seed.oklch.h}) · ${c.seed.hex_srgb} · role ${c.seed.role_hint} · text on it: ${c.seed.text_on_it} · ${c.seed.mood} (${c.seed.material})${c.lockedHex ? ` · brand anchor ${c.lockedHex}` : ''}`); + else if (c.bw) lines.push('PALETTE SEED: none — black and white by directive; neutrals only, no tint'); else lines.push('PALETTE SEED: none (canon) — use the brand colour or a tinted neutral'); if (c.seed?.avoid_with?.length) lines.push(` avoid: ${c.seed.avoid_with.join('; ')}`); lines.push(`DISPLAY FONT: ${fontLine(c.display)}`); @@ -369,12 +571,13 @@ function contract(c) { lines.push('SCENE: '); lines.push('FIRST VIEWPORT: '); lines.push('THESIS: '); + lines.push('SIGNATURE GRAPHIC: '); return { lines, c }; } function emit(out, extra) { if (args.json) { const { c } = out; - console.log(JSON.stringify({ key, reroll: args.reroll, mode: args.mode, platform: args.platform, world: c.world.id, archetype: c.archetype.id, strategy: c.strategy, seed: c.seed?.id || null, locked_hex: c.lockedHex || null, display: c.display?.id, body: c.body?.id, grounded: c.grounded || null, coherence, notes, contract: out.lines, ...extra }, null, 1)); + console.log(JSON.stringify({ key, reroll: args.reroll, mode: args.mode, platform: args.platform, world: c.world.id, archetype: c.archetype.id, strategy: c.strategy, seed: c.seed?.id || null, locked_hex: c.lockedHex || null, display: c.display?.id, body: c.body?.id, concept: c.concept ? { id: c.concept.id, angle: c.concept.angle } : null, treatment: c.treatment || null, device: c.device || null, facts: c.conceptFacts || [], coherence, notes, contract: out.lines, ...extra }, null, 1)); return; } console.log('