@struq/skill 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/bin/struq.mjs +132 -0
  4. package/package.json +41 -4
  5. package/skills/struq/SKILL.md +237 -0
  6. package/skills/struq/cinematic/README.md +409 -0
  7. package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
  8. package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
  9. package/skills/struq/cinematic/checklists/03-polish.md +17 -0
  10. package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
  11. package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
  12. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
  13. package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
  14. package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
  15. package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
  16. package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
  17. package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
  18. package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
  19. package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
  20. package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
  21. package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
  22. package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
  23. package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
  24. package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
  25. package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
  26. package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
  27. package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
  28. package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
  29. package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
  30. package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
  31. package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
  32. package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
  33. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
  34. package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
  35. package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
  36. package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
  37. package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
  38. package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
  39. package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
  40. package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
  41. package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
  42. package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
  43. package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
  44. package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
  45. package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
  46. package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
  47. package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
  48. package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
  49. package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
  50. package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
  51. package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
  52. package/skills/struq/cinematic/references/13-official-docs.md +53 -0
  53. package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
  54. package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
  55. package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
  56. package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
  57. package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
  58. package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
  59. package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
  60. package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
  61. package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
  62. package/skills/struq/references/anti-patterns.md +265 -0
  63. package/skills/struq/references/baseline-prompt.md +118 -0
  64. package/skills/struq/references/brand.md +143 -0
  65. package/skills/struq/references/brief-router.md +219 -0
  66. package/skills/struq/references/composition.md +191 -0
  67. package/skills/struq/references/depth-parallax.md +235 -0
  68. package/skills/struq/references/direction.md +188 -0
  69. package/skills/struq/references/feel-layer.md +107 -0
  70. package/skills/struq/references/gsap-api.md +293 -0
  71. package/skills/struq/references/iconography.md +255 -0
  72. package/skills/struq/references/imagery.md +144 -0
  73. package/skills/struq/references/loaders.md +287 -0
  74. package/skills/struq/references/micro-interactions.md +270 -0
  75. package/skills/struq/references/mobile-composition.md +70 -0
  76. package/skills/struq/references/nav-header.md +296 -0
  77. package/skills/struq/references/page-architecture.md +134 -0
  78. package/skills/struq/references/page-entrances.md +198 -0
  79. package/skills/struq/references/performance.md +235 -0
  80. package/skills/struq/references/pinning-scrub.md +341 -0
  81. package/skills/struq/references/pitfalls.md +290 -0
  82. package/skills/struq/references/route-transitions.md +281 -0
  83. package/skills/struq/references/scaling.md +151 -0
  84. package/skills/struq/references/scroll-reveals.md +280 -0
  85. package/skills/struq/references/seo.md +219 -0
  86. package/skills/struq/references/setup.md +397 -0
  87. package/skills/struq/references/showcase.md +87 -0
  88. package/skills/struq/references/stack-targets.md +78 -0
  89. package/skills/struq/references/struq-imagery.md +107 -0
  90. package/skills/struq/references/svg-motion.md +390 -0
  91. package/skills/struq/references/transitions.md +160 -0
  92. package/skills/struq/references/triage.md +71 -0
  93. package/skills/struq/references/typography.md +720 -0
  94. package/skills/struq/references/vibe-presets.md +287 -0
  95. package/skills/struq/references/webgl-r3f.md +274 -0
  96. package/skills/struq/references/webgl-scenes.md +593 -0
@@ -0,0 +1,255 @@
1
+ # Iconography: one icon family + craft doctrine
2
+
3
+ ## Decide here
4
+
5
+ Planning reads only this section ([direction.md](direction.md) §4); the rest is for the build.
6
+
7
+ - **Family, in this order, stop at the first that fits:** (1) the brand's own drawn set; (2) **Struq Icons**, the default (`npm i @struq/icons`, MIT, 182 icons: 118 two-tone with a hover motion, 14 status, 50 glyphs; browse at [struq.nl/iconen](https://struq.nl/iconen)); (3) **Phosphor, weight `light`** (`@phosphor-icons/react`, `thin` for luxury) when the set lacks what the site needs and drawing it is not worth it, or the concept wants a softer, rounder line; (4) **Iconoir or Tabler** at stroke 1.25 when the concept wants a sharper, technical line.
8
+ - **One icon family per site, never mixed.** Never two, never Lucide by reflex. If the chosen family lacks an icon, draw it to that family's rules or move the whole site down the list (§2).
9
+ - **Struq Icons on a Struq site:** `strokeWidth={1.5}` next to light display type (`1.25` for luxury); the accent mapped to the site's own `--color-accent` (§2), not the set's default flame; `states.css` on, so icons rest in one tone and the accent shows where you act.
10
+ - **Size and color:** one size per context (`size={20}` inline, `size={24}` nav, or a `size-5` / `size-6` class so the icon rides the root-rem knob), ink-colored; color only for state and semantics.
11
+ - **Budget:** functional icons only (nav, close, chevrons, form states, CTA anchors). No 3-up feature-card icons, no Sparkles, Rocket or Zap, no emoji, no colored chips.
12
+ - **Motion:** the set's own hover motion (200ms, hover and focus only, never on load, never looping, off under reduced motion) is inside the budget. Other animated icons under 500ms on click, never looping in standard UI, static under reduced motion.
13
+ - **Record in STRUQ.md SYSTEM:** `Icons: Struq Icons, @struq/icons, strokeWidth 1.5, accent = --color-accent, states.css on`.
14
+
15
+ ---
16
+
17
+
18
+ Two separate decisions, never collapse them:
19
+
20
+ 1. **Whether an icon earns its place**: the Struq taste gate. Most marketing
21
+ sections need *fewer* icons than the reflex wants; icon-as-filler is an AI tell.
22
+ 2. **How the icon behaves once it's in**: the craft doctrine below (family, register,
23
+ color, state, animation timing, accessibility).
24
+
25
+ The gate (§1) governs *where*; the doctrine (§2 to §8) governs *how*. A perfectly-crafted
26
+ icon in the wrong place is still wrong.
27
+
28
+ ---
29
+
30
+ ## 1. The gate: does it earn its place?
31
+
32
+ Keep the existing bans hard:
33
+
34
+ - **Never** as 3-up feature-card filler (centered icon + h3 + two gray lines), the single
35
+ most recognizable generated section ([anti-patterns.md](anti-patterns.md) §2, §13).
36
+ - **Never** Sparkles / Rocket / Zap / lightning, or emoji (🚀✨💡). They are the visual signature
37
+ of generated UI.
38
+ - **Never** in a colored circle/chip background.
39
+ - **Never** five sizes / five strokes scattered across a page.
40
+
41
+ Icons **earn their place** when they do real work:
42
+
43
+ - **Functional affordances**: nav, search, close, chevrons, form-field states, toggles.
44
+ - **Scannability** in menus and dashboards: a per-row icon lets the eye decode the list
45
+ faster than text alone (humans parse a glyph in ~1/10s).
46
+ - **Button visual-anchors**: an icon on a CTA gives a fast hint of the action.
47
+ - **Paragraph / feature separators**: between blocks of a "wall of text," an icon marks a
48
+ visual point of separation. It must *reinforce* the message, never *replace* it.
49
+
50
+ When in doubt, **no icon**. Hairline rules, ghost type, a single oversized numeral as an
51
+ accent, or real imagery often carry decoration better in a marketing context.
52
+
53
+ ---
54
+
55
+ ## 2. Source: one coherent family per project, chosen in this order
56
+
57
+ The family is a SYSTEM decision, recorded in STRUQ.md ([direction.md](direction.md) §4), not
58
+ something picked per component. Resolve it top to bottom and stop at the first that fits:
59
+
60
+ | # | Family | When | Install | Weight to match light display type |
61
+ |---|---|---|---|---|
62
+ | 1 | **The brand's own icons** | The client already has a drawn set | their files, inlined as components | as drawn |
63
+ | 2 | **Struq Icons** | The default for every Struq build: a two-tone set on one 24 grid (a base tone for the shape, an accent on the part that carries the meaning), each icon with its own hover motion and a draw-in variant | `npm i @struq/icons`; sprite, plain SVG and Iconify JSON (prefix `struq`) for non-React stacks | `strokeWidth={1.5}` (the set's default 2 is Lucide weight; `1.25` for luxury) |
64
+ | 3 | **Phosphor** | A softer, rounder line, or a site that needs icons the Struq set does not have | `npm i @phosphor-icons/react`, or Iconify `ph:` | `weight="light"` (or `"thin"` for luxury) |
65
+ | 4 | **Iconoir** or **Tabler** | A sharper, more technical or architectural line | `npm i iconoir-react` / `@tabler/icons-react`, or Iconify `iconoir:` / `tabler:` | Tabler `stroke={1.25}`; Iconoir `strokeWidth={1.25}` |
66
+
67
+ ### Struq Icons: the wiring
68
+
69
+ Import the stylesheets once, at the root, before the site's own stylesheet; then import icons by name. Every icon is a named PascalCase export (`Search`, `ArrowRight`, `External`, `ChevronDown`, `Close`); the barrel tree-shakes. Check a name against the installed `dist/index.d.ts` or [struq.nl/iconen](https://struq.nl/iconen) before using it: the set names icons by meaning, so the up-right arrow is `External`, not `ArrowUpRight`.
70
+
71
+ ```tsx
72
+ // app/layout.tsx (Next) or src/main.tsx (Vite): once, before globals.css
73
+ import "@struq/icons/tokens.css";
74
+ import "@struq/icons/icons.css";
75
+ import "@struq/icons/states.css"; // optional: one tone at rest, the accent on hover, focus and current
76
+ ```
77
+
78
+ ```css
79
+ /* globals.css: the set's accent follows the site's palette, not its default flame.
80
+ On body, so it wins over tokens.css's :root and .dark rules by inheritance. */
81
+ body {
82
+ --icon-base: currentColor;
83
+ --icon-accent: var(--color-accent);
84
+ --icon-accent-live: var(--color-accent);
85
+ }
86
+ ```
87
+
88
+ ```tsx
89
+ import { ArrowRight, Search } from "@struq/icons";
90
+
91
+ <a href="/work" className="font-ui text-ui inline-flex items-center gap-2">
92
+ Selected work <ArrowRight size={20} strokeWidth={1.5} />
93
+ </a>
94
+
95
+ <button type="button" className="grid size-12 place-items-center">
96
+ <Search size={24} strokeWidth={1.5} label="Search" /> {/* the only content: give it a label */}
97
+ </button>
98
+ ```
99
+
100
+ - **Props:** `size` (16, 20, 24, 32 or 48 px; a `size-5` class overrides it in rem), `strokeWidth`, `label` (names it for screen readers; without one it is `aria-hidden` decoration), `animate` (`"hover"` default, `"draw"`, `"none"`), `color` and `accentColor` (any CSS colour, set per icon; they beat the variables). Other SVG props pass through.
101
+ - **Server components.** The icons use no hooks, so they render in server components. Only the draw-in entry is a client component.
102
+ - **Draw-in** for one empty state, first run or hero moment, never a list: `import { StruqIconDraw } from "@struq/icons/draw"`, `<StruqIconDraw icon={Inbox} size={48} delay={0.2} />`. It needs `motion` (already in the stack), draws once, never loops, and shows the finished icon on the server, without JS and under reduced motion.
103
+ - **Name clashes.** `Link`, `Image`, `File` and `Text` collide with `next/link`, `next/image` and DOM globals; import the `LinkIcon`, `ImageIcon`, `FileIcon` and `TextIcon` aliases instead.
104
+ - **Accent off in one area:** `data-icon-accent="off"` on a parent paints the accent in the base colour (needs `icons.css`). `data-icons="live"` keeps the accent on at rest in an area when `states.css` is loaded.
105
+ - **Status icons** (one tone) take their colour from the place they sit in: they follow `currentColor`, or a token through `color="var(--color-...)"`, never a hex.
106
+ - **Without React:** `sprite.svg` (copy it to `public/`, `<use href="/sprite.svg#search" />`, two-tone, no hover motion), plain SVG files under `svg/`, or `iconify.json` (prefix `struq`, for Iconify loaders in Vue, Svelte, Astro or Tailwind). Same family, same rules.
107
+ - **A missing icon** is drawn to the set's rules (24 grid, round caps and joins, the site's stroke, base and accent as two parts) or the whole site moves to row 3. Never patch the gap with a Phosphor glyph.
108
+
109
+ **Never Lucide as the reflex.** It is the default of every generated UI, so it reads as
110
+ generated. **Never two families on one site.** If the chosen family is missing an icon, either
111
+ draw the missing icon to its rules (its grid, its stroke, its caps and joins) or move the whole
112
+ site to the next family. Patching gaps with a second set is the icon version of three display
113
+ fonts on one page.
114
+
115
+ **Iconify** is an optional transport for families 3 and 4 (and for Struq Icons outside React) when you want any family through one
116
+ component: one API over about 200k icons across every major set. It is a delivery method, not a
117
+ second family.
118
+
119
+ ```bash
120
+ # Next / any React # Vite (compile-time, zero runtime, tree-shaken)
121
+ npm i @iconify/react npm i -D unplugin-icons @iconify/json
122
+ ```
123
+
124
+ ```tsx
125
+ // @iconify/react: works in both Next and Vite
126
+ import { Icon } from "@iconify/react";
127
+ <Icon icon="ph:arrow-up-right" className="size-5 text-ink" /> // Phosphor
128
+ <Icon icon="iconoir:search" className="size-5 text-ink" /> // Iconoir
129
+ ```
130
+
131
+ ```ts
132
+ // Vite alternative: unplugin-icons, imported as components (no runtime fetch)
133
+ import ArrowUpRight from "~icons/ph/arrow-up-right";
134
+ <ArrowUpRight className="size-5 text-ink" />
135
+ ```
136
+
137
+ **Pick one family and stay in it.** Consistency in *style, weight, corner treatment, and
138
+ fill-vs-stroke* is what makes an icon set read as designed. Lock the chosen family in the
139
+ project's icon component so nothing else can be imported by accident.
140
+
141
+ ---
142
+
143
+ ## 3. Two registers: system vs decorative
144
+
145
+ | Register | Use | Traits |
146
+ |---|---|---|
147
+ | **System** | menus, buttons, inputs, table rows, anything **≤24px** | minimal, reduced to essentials, legible at small size, single stroke |
148
+ | **Decorative / expressive** | bigger explanatory moments, feature anchors, animated (Lordicon/Lottie) | richer, more detail, only at **larger** sizes |
149
+
150
+ **Complex/decorative icons die at small sizes**: their detail turns to mud below ~24px.
151
+ If it's small, it's a system icon. If it's expressive, it's big. Don't mix the registers
152
+ within one role.
153
+
154
+ One stroke across the set, as the §2 table sets it (Struq Icons `strokeWidth={1.5}`,
155
+ Phosphor `weight="light"`, Tabler `stroke={1.25}`, Iconoir `strokeWidth={1.25}`), one size per
156
+ context (e.g. `size-5` inline, `size-6` nav), ink-colored by default. Struq Icons' two tones
157
+ are not two colours in the §4 sense: at rest under `states.css` they read as one tone, and the
158
+ accent is the site's one accent, shown only where the visitor acts.
159
+
160
+ ---
161
+
162
+ ## 4. Color
163
+
164
+ - **One color across the set** by default: ink. An icon set in four hues reads as no
165
+ decision made (same rule as the one-accent color system).
166
+ - Color earns its place only for **state** (hover/pressed/active) and **semantics**:
167
+ red/warning for errors, a success check, etc.
168
+ - Never rely on color *alone* to carry meaning (accessibility, see §7).
169
+
170
+ ---
171
+
172
+ ## 5. States & feedback
173
+
174
+ An icon the user can act on must *look* actionable and must confirm the action:
175
+
176
+ - Distinct **default / hover / pressed**, via motion, color, or both. A clickable icon
177
+ that never changes reads as decoration.
178
+ - **Feedback through the icon itself:** a checkmark confirms success, a warning sign flags
179
+ an error, a state-swap (empty→full trash) signals what happened. Digital UI lacks the
180
+ physical click of a real switch, so this feedback matters *more*, not less.
181
+
182
+ ---
183
+
184
+ ## 6. Animated icons (Lordicon / Lottie): timing is locked
185
+
186
+ Animated icons convey more meaning than static ones and drive engagement, but only with
187
+ discipline. The same budget as micro-interactions applies: **2 to 3 per view**, never a field
188
+ of looping motion.
189
+
190
+ **Timing (hard limits):**
191
+
192
+ | Trigger | Duration | Notes |
193
+ |---|---|---|
194
+ | **Click / tap** | **≤ 500ms** | must keep up with repeat clicks; longer feels out of sync and becomes a distraction by the 3rd press |
195
+ | **Hover (decorative)** | **1500-3000ms** | the delight sweet spot; more complex symbol → longer end of the range |
196
+ | GIF fallback loop | **2000-5000ms delay between loops** | only if tech forces a looping GIF |
197
+
198
+ **Rules:**
199
+
200
+ - **No constant loops in dashboards / standard UI.** They become an unwelcome
201
+ distraction (especially for attention-sensitive users). Trigger on hover instead.
202
+ A looping animation is fine *as a page loader*, not as ambient dashboard decoration.
203
+ (This rhymes with the skill-wide "nothing moves that the user didn't cause" principle,
204
+ [feel-layer.md](feel-layer.md) §5.)
205
+ - **No extreme flashing or flickering**: a seizure/motion-sensitivity hazard. Smooth the
206
+ frequency; never strobe.
207
+ - **Natural physics only.** Motion that breaks real-world dynamics (unnatural bounce,
208
+ impossible 3D, irregular movement) leaves users unsettled and confused. Smooth, weighted,
209
+ accurate. Every movement must serve a purpose and match the icon's meaning (a security
210
+ icon shouldn't "wave and wiggle").
211
+ - **Don't overload.** Too many animated icons, especially on loop, drown the message.
212
+
213
+ Animated icons are a micro-interaction: they obey the same library split and gating as
214
+ everything in [micro-interactions.md](micro-interactions.md). Lottie/Lordicon players are
215
+ state/lifecycle-driven (Motion territory for triggering), hover effects are
216
+ `@media (hover: hover)`-gated, and **reduced-motion → static icon** (render the still
217
+ frame, never the animation).
218
+
219
+ ---
220
+
221
+ ## 7. Accessibility
222
+
223
+ - **Meaningful icon:** give it an accessible name via `aria-label` (or visible label, §8).
224
+ An icon that carries content needs alt text so screen readers convey it.
225
+ - **Decorative icon:** `aria-hidden`, which keeps it out of the a11y tree entirely.
226
+ - **Never color-alone** for meaning (color-vision-deficient and low-vision users). Pair
227
+ with shape, label, or motion.
228
+ - **Touch target ≥ 48×48px**: the tappable area can exceed the visual glyph. Keep spacing
229
+ between grouped tappable icons so they don't misfire.
230
+ - **Reduced motion** → static icon (see §6).
231
+
232
+ ---
233
+
234
+ ## 8. Labels, universal icons, conflicts
235
+
236
+ - **Labels:** before users click, they need to know what to expect. Universal icons (home,
237
+ play, stop, pin, search) are self-evident; anything ambiguous needs a **1 to 3 word label**
238
+ using an action word (Buy, Continue, Subscribe). Icons reinforce; labels disambiguate.
239
+ - **Universal icons reduce cognitive load.** Lean on the globally-recognized set where it
240
+ fits, beyond cultural differences.
241
+ - **Conflicting icons** (heart vs star both mean "favorite/save/rate"): don't avoid them,
242
+ **consciously pick one** and label its function clearly. Don't use one icon for two
243
+ purposes or two icons for one purpose.
244
+ - **Cultural context** matters for unique/symbolic icons, so consider the audience.
245
+ - **Test** new icons with outsiders for *recognizability* (do they understand it?) and
246
+ *memorability* (do they recall it a week later?). Re-design or add a label if not.
247
+
248
+ ---
249
+
250
+ **Cheap tell:** Lucide `Sparkles`/`Rocket`/`Zap` in colored circle chips, 3-up, as
251
+ feature decoration; an animated icon looping forever in a dashboard; a 500ms+ click
252
+ animation that lags the cursor; a tiny decorative icon turned to mud at 16px.
253
+ **Instead:** One deliberately chosen icon family, system vs decorative registers kept separate,
254
+ ink-colored with color only for state/semantics, animated only where it informs and
255
+ within the timing limits, labeled where not universal, `aria` correct, ≥48px targets.
@@ -0,0 +1,144 @@
1
+ # Imagery & Art Direction: one grade, declared ratios, treated assets
2
+
3
+ Imagery is now where generated builds fail hardest: the CSS can be flawless and the page still screams "AI" because the images are ungraded stock, glossy renders, or six unrelated looks. The fix is the same shape as the color system: **decide once, apply everywhere**. One grade, one ratio set, one sourcing standard.
4
+
5
+ Media *behavior* (window parallax, masked reveals, aspect-ratio for CLS) lives in [depth-parallax.md](depth-parallax.md) and [scroll-reveals.md](scroll-reveals.md); this file is about what the pixels *are*.
6
+
7
+ ---
8
+
9
+ ## 1. One grade per page
10
+
11
+ Every image on a page passes through the **same treatment**, chosen to match the preset's temperature ([vibe-presets.md](vibe-presets.md)). Mixed grading (a warm lifestyle shot next to a cool product render next to a saturated stock photo) is the single most reliable imagery tell. It says the images were *collected*, not *directed*.
12
+
13
+ Pick one:
14
+
15
+ | Grade | Character | Pairs with |
16
+ |---|---|---|
17
+ | **Warm film** | lifted blacks, warm midtones, gentle contrast | Warm Editorial |
18
+ | **Desaturated cool** | -20 to -40% saturation, cool shift, deep blacks | Dark Cinematic |
19
+ | **High-contrast BW** | full monochrome, punchy | the Brutalist genre appendix only, when the brief declares the genre (color arrives only via the accent) |
20
+ | **Soft neutral** | low contrast, airy highlights, near-monochrome palette subjects | Soft Modern (cool-leaning), Luxury Minimal (warm-leaning) |
21
+ | **Duotone** | surface color + ink (or accent) mapped to shadows/highlights | any preset, as the *signature* look |
22
+
23
+ Apply the grade in one reusable layer, not per-image in an editor:
24
+
25
+ ```tsx
26
+ // components/ui/GradedImage.tsx: every content image renders through this
27
+ export function GradedImage({ src, alt, ratio = "4/3", className = "" }: {
28
+ src: string; alt: string; ratio?: string; className?: string;
29
+ }) {
30
+ return (
31
+ <div className={`relative overflow-hidden ${className}`} style={{ aspectRatio: ratio }}>
32
+ <img src={src} alt={alt} className="h-full w-full scale-[1.18] object-cover
33
+ [filter:saturate(0.82)_contrast(1.05)]" />
34
+ {/* the tint: surface temperature washed over every image */}
35
+ <div aria-hidden className="absolute inset-0 bg-surface-0/15 mix-blend-color" />
36
+ </div>
37
+ );
38
+ }
39
+ ```
40
+
41
+ - The `filter` + tint values are **per-project constants**: tune once against the preset, then never per-image. (Static CSS filters are fine; it's *scrubbed* filters that cost, see [setup.md](setup.md) §5.)
42
+ - Duotone variant: `[filter:grayscale(1)]` on the img + an accent-colored overlay with `mix-blend-screen` (dark pages) or `mix-blend-multiply` (light pages).
43
+ - `scale-[1.18]` is the parallax headroom from [depth-parallax.md](depth-parallax.md) §2, so grading and panning share the same frame component.
44
+
45
+ **Cheap tell:** every image at its source colors, six looks on one page. **Instead:** One `GradedImage` component; swap the page's grade by editing one filter line.
46
+
47
+ ---
48
+
49
+ ## 2. The ratio set: declared like tokens
50
+
51
+ Pick **2 to 3 aspect ratios per project** and use nothing else. A page whose media is 4/3, 16/9, 3/2, 1/1, and 21/9 at random reads as unplaced; the same images in a disciplined ratio set read as a system. (Explicit ratios are also the CLS guard, see [setup.md](setup.md) §6.)
52
+
53
+ | Role | Default | Alternatives by genre |
54
+ |---|---|---|
55
+ | Standard content / work thumb | `4/3` | `3/2` editorial, `1/1` brutalist grid |
56
+ | Full-bleed statement (1 per ~3 viewports) | `21/9` | `2/1` |
57
+ | Portrait accent (people, product detail) | `3/4` | `4/5` |
58
+
59
+ Declare them where the tokens live and reference by role, never ad-hoc:
60
+
61
+ ```css
62
+ @theme {
63
+ --ratio-standard: 4/3;
64
+ --ratio-wide: 21/9;
65
+ --ratio-portrait: 3/4;
66
+ }
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 3. Texture: grain, and when to stop
72
+
73
+ One **global grain overlay** unifies mixed sources better than any filter. It puts every image (and the surfaces) on the same "stock":
74
+
75
+ ```tsx
76
+ // One fixed element at the end of <body>. z-index above content, below nav (composition.md z-ladder).
77
+ <div aria-hidden className="pointer-events-none fixed inset-0 z-40 opacity-[0.05] mix-blend-overlay"
78
+ style={{ backgroundImage: "url(/noise.png)", backgroundSize: "128px" }} />
79
+ ```
80
+
81
+ - `noise.png`: a 128px tileable monochrome noise tile (or an inline SVG `feTurbulence` data-URI). Opacity 0.03 to 0.06: visible on a dark surface if you look, never legible as a pattern.
82
+ - Grain suits film/cinematic/editorial registers; **skip it** for Luxury Minimal (clean air is the brand) and product-UI screenshots (they must stay crisp).
83
+ - Budget: grain is the *only* always-on texture. Grain + vignettes + gradients + blobs = mud.
84
+
85
+ ---
86
+
87
+ ## 4. Sourcing ladder: where images come from
88
+
89
+ In order of preference:
90
+
91
+ 1. **Real assets**: the client's work, product, people, places. Always first; treatment (§1) makes even average photography cohesive.
92
+ 2. **Struq's beeldbank**: real, graded, CC0 sector packs from struq.nl, one pack per site so the grade stays single, downloaded into the project and credited. The first stop when the client has no photos, or not enough. How to pick, fetch and credit: [struq-imagery.md](struq-imagery.md).
93
+ 3. **Generated on-brief**, with any capable image model (Flux/SDXL-class local checkpoints or a hosted generator). The standard when real assets don't exist and no beeldbank pack fits. Treat each prompt as an art-direction document, not a subject description, and generate the whole set in one session with the same model and settings so it reads as one shoot. Prompt for the *page's* grade, not per-image whims:
94
+ - State the grade in every prompt: light temperature ("warm late-afternoon window light" / "cool overcast studio light"), palette bias matching the preset's neutrals, film character ("35mm film grain, muted colors" vs "clean digital").
95
+ - Keep **one lighting world** across the set. Consistent light does more for coherence than consistent subjects.
96
+ - Compose for the crop: prompt the ratio you'll place (§2), and leave dead space where type will sit if it's a lockup image ([composition.md](composition.md) §3).
97
+ - Avoid the AI-render look: no glossy 3D blobs, no purple-glow gradients, no hyperdetailed-everything. The §1 grade pass over generated output hides most remaining tells.
98
+ 4. **Treated stock**: last resort, and only *through* the grade layer. Untreated stock (beaming laptop people, handshake photos) is below the floor.
99
+
100
+ **Beeldbank, stock and generated images show context, never the client's product.** The city, the canal, a rider, a room, a material: yes. A stranger's e-bike, a generated bottle or a stock ceramic presented as the product: never, because the visitor reads it as the thing they would buy. Until the real product is shot, it is a placeholder frame (§4b), a declared 3D stand-in ([webgl-scenes.md](webgl-scenes.md) §4, "No model file yet"), or type.
101
+
102
+ **Never:** browser-chrome screenshot mockups tilted in perspective, generic "dashboard glow" renders, or watermark-adjacent placeholder imagery. If a section has no honest image, the strongest move is **no image**: type, space, and surface carry it (same logic as the no-icon rule, [iconography.md](iconography.md) §1).
103
+
104
+ ---
105
+
106
+ ## 4b. Placeholders: when the real image is not here yet
107
+
108
+ A slot that will hold a real image (planned in the shot list, not yet shot or generated) ships as a **graded tonal frame**, never a grey box and never random stock dropped in to fill it. It must look like part of the page's system, so the layout can be judged honestly before the photography exists.
109
+
110
+ - **Frame:** the declared ratio for that role (`--ratio-standard`, `--ratio-wide`, `--ratio-portrait`, §2), with the same radius and mask as the real `GradedImage` frame.
111
+ - **Tone:** a vertical gradient between two adjacent surface steps of the palette (for example `surface-2` to `surface-3`), with the page's grain on top. No icon, no "image" glyph, no diagonal cross.
112
+ - **Caption:** the shot-list line (subject and ratio) set in the eyebrow style, bottom left inside the frame's padding, in `ink-muted`. Example: `ROASTER MACHINE, 4/5`.
113
+ - **Markup:** `role="img"` with an `aria-label` that names the planned subject, so the placeholder is honest to screen readers too.
114
+ - **Never inside a showpiece.** A media-led showpiece is not staged on placeholders; it waits for the real assets ([showcase.md](showcase.md) Choosing).
115
+
116
+ ```tsx
117
+ // components/ui/ImagePlaceholder.tsx: the stand-in for one shot-list line
118
+ export function ImagePlaceholder({ caption, ratio = "4/3", className = "" }: {
119
+ caption: string; ratio?: string; className?: string;
120
+ }) {
121
+ return (
122
+ <div role="img" aria-label={`Planned image: ${caption}`}
123
+ className={`relative overflow-hidden bg-linear-to-b from-surface-2 to-surface-3 ${className}`}
124
+ style={{ aspectRatio: ratio }}>
125
+ <span className="eyebrow text-eyebrow absolute bottom-4 left-4 text-ink-muted">
126
+ {caption}
127
+ </span>
128
+ </div>
129
+ );
130
+ }
131
+ ```
132
+
133
+ ---
134
+
135
+ ## 5. Accessibility & weight
136
+
137
+ - **Content images:** real `alt` describing the subject. **Decorative/texture images** (ghost imagery, grain, backdrop media): `alt=""` or `aria-hidden`, so "image of background texture" never leaks to screen readers.
138
+ - Ship AVIF/WebP with sensible `sizes`; a full-bleed hero stays at or under 250KB at 1920w and a content image at or under 150KB at 1440w, the budgets in [performance.md](performance.md) §1. Never 4MB.
139
+ - Ambient/looping video obeys the motion doctrine: muted, `playsinline`, poster, paused under `prefers-reduced-motion`. It also counts as the page's one autonomous mover ([feel-layer.md](feel-layer.md) §5).
140
+
141
+ ---
142
+
143
+ **Cheap tell:** mixed-grade stock + a tilted browser-chrome screenshot + a purple-glow 3D blob, all at random ratios, alt-less.
144
+ **Instead:** One grade constant, one grain layer, 2 to 3 declared ratios, assets real or generated on-brief, treated identically. The imagery reads as *photographed for this site*.