imagery — treated, textured, never fake
Stage: Phase 3 — Foundation (treatments, SYSTEM.md §imagery); assets land in Phase 6 — Build - Reads: design/DIRECTION.md, design/BRIEF.md, design/SYSTEM.md §color, design/SITEMAP.md - Writes: design/SYSTEM.md §imagery + treatment CSS in app/globals.css + generated assets in public/images/
Standard
Every image on the site passes through ONE named treatment derived from the OKLCH palette — untreated stock beside brand color reads as template. Zero gray boxes, zero placeholder.com, zero stock cliché (handshake, laptop-with-latte, team-laughing-at-salad). When no real photography exists — the usual case — generated SVG/CSS art is designed well enough to ship, and honestly labeled for replacement. Textures (mesh, grain) are subliminal: felt, not seen.
Two art-direction moves shape the palette before any treatment is picked (award-canon; palette math stays in color): Content-Derived Color — sample the accent from the actual hero object/product (2–3 OKLCH values pulled from the hero image at build time) so the site's color IS the product's color; this is computed-from-content, never the authored Three-Token atlas. Invert the Genre Palette — name the category's default color story (fintech blue, sci-fi dark, luxury black/gold, wellness sage) and commit to its credible opposite. Both stay AA-safe: derived and inverted hues drive backgrounds and accents, never body copy.
Photo treatments — pick ONE
Tint — cheap duotone approximation for thumbnails, cards, hover states:
.img-tint { position: relative; overflow: hidden; }
.img-tint > img { filter: grayscale(1) contrast(1.08); }
.img-tint::after {
content: ""; position: absolute; inset: 0;
background: var(--color-accent); mix-blend-mode: color; opacity: 0.9;
}
True duotone — luminance mapped to two palette colors, for hero-grade surfaces. Inline the filter once in the root layout:
<svg width="0" height="0" aria-hidden="true">
<filter id="duotone" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix"
values=".299 .587 .114 0 0 .299 .587 .114 0 0 .299 .587 .114 0 0 0 0 0 1 0"/>
<feComponentTransfer>
<feFuncR type="table" tableValues="0.10 0.96"/>
<feFuncG type="table" tableValues="0.09 0.90"/>
<feFuncB type="table" tableValues="0.13 0.80"/>
</feComponentTransfer>
</filter>
</svg>
Apply via filter: url(#duotone). Per channel, tableValues = shadow color then highlight color in sRGB 0-1 — convert the palette pair from oklch first; never eyeball the numbers.
Grain — feTurbulence noise overlay. Opacity 0.03-0.08, never more:
.grain::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
opacity: 0.05; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
Overlay/scrim — gradient scrim guaranteeing text contrast over media:
.scrim::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(to top, var(--scrim) 0%, transparent 60%);
}
--scrim = the dark neutral at 0.7-0.85 alpha. Verify AA against the LIGHTEST point the text overlaps — computed contrast, not eyeballed.
Combining: one treatment + grain is the ceiling. Grain rides on top of anything; duotone + scrim on the same image means the image was wrong to begin with.
Generated textures
Gradient mesh — 3-4 stacked radial-gradients, colors from SYSTEM.md §color at 0.3-0.6 alpha. A purple-to-blue default mesh is on the banned list.
.mesh {
background:
radial-gradient(at 20% 25%, var(--mesh-1) 0%, transparent 55%),
radial-gradient(at 80% 15%, var(--mesh-2) 0%, transparent 50%),
radial-gradient(at 55% 90%, var(--mesh-3) 0%, transparent 55%),
var(--color-background);
}
Noise — the same feTurbulence data URI: baseFrequency 0.6-0.9 for fine grain, 0.15-0.3 for coarser paper texture. Keep stitchTiles='stitch' or the tile seams show.
Parametric geometry — the texture for directions where organic noise is off-brief: Swiss, Brutalist, Playful Geometric, and Retro-Futurist grounds want structure, not grain. Generate the tile from a formula so it repeats seamlessly and re-derives at any size:
- Wave field — sample
y = A·sin(2πx/λ)at ~16 points per period into a cubic path; λ must divide the tile width exactly or the seam shows. Stack 2-3 phase-shifted copies at descending opacity for depth. - Lattice — an SVG
<pattern>whose unit cell carries one mark (dot, plus, tick) at the cell node; half-drop by adding a second layer atpatternTransform="translate(w/2 0)". Cell 24-64px, mark at 4-6% opacity. - Hatch — parallel rules at the motif family's one fixed angle (ultraweb:shape-language owns that angle), spacing on the
--spacingmultiplier so the field lands on the grid.
Amplitude, wavelength, and cell size are the only knobs — record the three numbers in SYSTEM.md §imagery so a divider, a section ground, and a card texture read as one field. shape-language's blob-and-squiggle ban still holds: a parametric wave is a repeating structure, an amoeba is decoration.
All three are CSS backgrounds: zero network requests (data URIs), zero layout cost, pointer-events: none on overlay layers.
Honest placeholders
- First choice: no image. A section designed typographically over mesh/texture/geometry beats a section waiting for a photo.
- Generated SVG art: mesh background + 1-2 geometry elements from ultraweb:shape-language + optional oversized low-opacity monogram or word. Built at the slot's final aspect ratio, saved to
public/images/. - Name honestly (
placeholder-team.svg, neverteam.svg) and list every generated asset in SYSTEM.md §imagery with what should replace it — handoff republishes the list. - Never: gray boxes,
bg-muteddivs standing in for images, placeholder.com/picsum, hotlinked Unsplash, "image coming soon".
next/image wiring
- Raster photos:
placeholder="blur"with a blurDataURL;fillalways pairs withsizes; the LCP image getspreload—priorityis deprecated in Next 16. - Generated SVGs: plain
<img>or CSS background — next/image adds nothing for SVG. Decorative pieces getalt=""(or live as backgrounds); content images get real alt text describing content, never "image". - Every SVG this skill emits — generated art, pattern tiles, inline filters — ships through ultraweb:media-optimization's SVGO pass first; if the piece is animated, that pass keeps its IDs and viewBox.
- Raster logo, no vector: ask the client for the real SVG first, always — it exists more often than they think (ultraweb:assets records what came back in design/ASSETS.md). Only when it genuinely doesn't, trace the mark with
vtracerand hand-clean the paths; never trace fine type or a wordmark — ultraweb:identity resets those in the site's own face and owns the resulting files in public/brand/. - Treatment layers (scrim, grain, tint) are CSS on a wrapper, never baked into exported assets — dark mode must be able to re-decide them.
Dark mode
Treatments are re-decided per theme, not inverted: scrim flips toward the dark neutral, grain blend usually moves overlay → soft-light, mesh alpha drops ~30% so it doesn't glow. Screenshot every treated surface in BOTH themes before calling it done.
Process
- Read DIRECTION.md: does this direction lead with photography, illustration, or pure type/graphic? Record the stance in SYSTEM.md §imagery.
- Pick ONE treatment; implement it as reusable classes in app/globals.css (+ the inline SVG filter in the root layout if true duotone).
- Generate the texture kit (mesh vars, noise data URI) from the palette.
- Inventory every image slot in SITEMAP.md; per slot decide: real asset / generated art / none.
- Build generated assets at final aspect ratios; wire raster through next/image (blur, sizes,
preloadfor LCP). - Verify: screenshot every treated surface in both themes; run the anti-pattern greps.
Anti-patterns
Greppable — each should return zero:
placeholder.com/placehold.co/picsum.photos/images.unsplash.comalt="image"/alt="photo"/alt="placeholder"bg-gray-200orbg-mutedon an empty div posing as an image slot
Visual — caught by screenshots:
- Two treatments on one site (duotone heroes, untreated cards) — one hand, one treatment
- Grain layer opacity above 0.08 — texture became dirt
- Mesh in default purple-blue — banned unless DIRECTION.md justifies it
- Stock-cliché photos even when "real" — reject the asset, redesign the slot
Worked example — Casa Verde, Lisbon farm-to-table restaurant
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.