---
name: collage-objects
description: Build "collage objects", a playful brand illustration style drawn in code, where chunky unlit 3D objects made of flat parts act as windows onto cut-paper collage (marker scribbles, print grain, product UI fragments, public-domain photographs), sit still at rest, and play as stop-motion on hover with every part re-cut each frame, in a dusty OKLCH palette with light and dark modes. Use this whenever someone wants this look or anything near it, such as a collage-style brand kit, 3D icons with hand-drawn or collage textures, playful illustrations for a hero, modal, onboarding or sign-in screen, a fresh colour palette balanced in OKLCH, or sourcing public-domain imagery for brand art. Covers the whole job end to end, from measuring references to verifying the result in a browser.
---

# Collage objects, end to end

A complete recipe for a brand illustration style: a few chunky objects (a text cursor, binoculars, a paper plane, a block of keys) built from flat parts, each part a window onto a piece of collage. Everything is drawn by code except a handful of small public-domain photographs.

The working implementation this was written from is in `reference/`, which comes with the full skill in the vault (https://vault.sourcetms.com/skills/collage-objects; members install it with the sourcetms-vault MCP). Without it, the steps below stand on their own. With it, read the two core files before writing anything:

| File                           | What it holds                                                                               |
| ------------------------------ | ------------------------------------------------------------------------------------------- |
| `reference/sheets.ts`          | Palette (light and dark), stroke and grain helpers, every collage sheet, photograph loading |
| `reference/scene.ts`           | Geometry helpers, the shader, four objects, stop-motion playback, mounting and cleanup      |
| `reference/collage-object.tsx` | React wrapper that lazy-loads the two files above                                           |
| `reference/collage.css`        | Palette as CSS variables with a dark override, canvas and tile styles                       |
| `reference/SOURCES.md`         | Provenance table for the photographs in `reference/photos/`, and the licence rules          |

`scripts/` has the helpers used along the way; each is described where it is needed.

To try the reference as it is: copy `sheets.ts` and `scene.ts` to `src/lib/collage/`, `collage-object.tsx` and `collage.css` beside your components, and the photographs to the folder served at `/collage/`. The wrapper imports `~/lib/collage/scene`, so alias `~` to `src` or change the import. Then install `three` and `@types/three`. If `reference/photos/` is missing (an agent install copies text files only), rebuild the nine photographs with `scripts/commons_download.py` followed by `scripts/crop_photos.py` (Python 3 and ffmpeg). They come out under the exact names `sheets.ts` loads.

## The look, in eight rules

These were arrived at by building the wrong thing twice and comparing frame by frame with the reference. Trust them over instinct.

1. **Cuttings are flat sheets behind the canvas.** Artwork does not wrap the object. Each part is a window onto a sheet fixed to the screen, so lettering and grain stay upright and undistorted however the object is turned. Artwork that is UV-mapped onto the surface, turning and squashing with it, looks like an ordinary textured 3D model. That was the first mistake.
2. **Hover plays stop-motion, not a smooth turn.** About eight times a second the object snaps to a new angle and every part is handed different artwork, as if the collage were re-cut between frames. A smooth turntable spin was the second mistake.
3. **Nothing is lit.** No lights, shading, shadows or smooth gradients. The object reads as 3D only through its silhouette and the borders between parts.
4. **Two colours plus ink and paper per object.** With every part changing every frame, more turns to confetti.
5. **Plenty of plain colour and ink.** About half an object's parts should be plain at any moment. Dark edges and plain faces hold the shape together.
6. **Paper has texture, and some of it is real.** Speckled grain that thins one colour into another (the only gradient allowed), stone-like speckle, engraved line work, small serif captions, and real printed matter: old newspapers, patent drawings, archive photographs.
7. **Artwork is far too big for the part.** Each part shows only a crop of its sheet, so its edge cuts through a letter or a loop the way scissors would.
8. **Chunky and simple.** Boxes, tubes, wedges and flat folds with generous thickness. If a shape needs more than about eight parts it is too fussy.

At rest an object is completely still and centred on a quiet tile. Things that were tried and turned down: parts bobbing in a wave, objects rocking on their own, artwork sliding inside a part, the object growing on hover, bright coloured tiles, and saturated primary colours.

## Step 1: Agree on what to draw

Pick three to five objects that stand for what the product does, not generic tech icons. For a news site that people read, watch and subscribe to: a text cursor, binoculars, a paper plane, keyboard keys. Then list what the product itself can lend to the collage: its calendar grid, feed rows, charts, logo mark, headline typeface. Those become sheets, and they are what makes the result belong to this brand and no other.

## Step 2: Build the palette from references

Ask for reference images and measure them. Do not pick colours by eye.

```
python3 scripts/oklch.py sample reference.png 330 395     # L, C and H at a point
python3 scripts/oklch.py hex 56 0.08 232                  # hex for an OKLCH colour; warns if it can't be shown
```

Look for the structure, then keep the structure and change the hues:

- **Lightness bands.** Good palettes cluster. Note which lightness values recur.
- **Chroma ceiling.** Papery palettes sit around 0.04 to 0.13 with one hot accent near 0.165. Brighter brand palettes still rarely pass 0.18. Above that looks neon beside photographs.
- **Ladders.** One hue at two or three lightness steps lets an object look rich without adding a hue.
- **Grounds.** Usually a warm off-white and a cool charcoal.

The reference palette, which a user chose after rejecting two brighter ones:

|                       | Light                   | Dark                   |
| --------------------- | ----------------------- | ---------------------- |
| Petrol                | `oklch(56% 0.08 232)`   | `oklch(72% 0.075 235)` |
| Sky                   | `oklch(87% 0.04 240)`   | `oklch(83% 0.058 245)` |
| Seafoam               | `oklch(76% 0.07 172)`   | `oklch(76% 0.065 174)` |
| Moss                  | `oklch(56% 0.10 148)`   | `oklch(66% 0.105 150)` |
| Mustard               | `oklch(79% 0.125 88)`   | `oklch(82% 0.10 75)`   |
| Coral, the hot accent | `oklch(65% 0.165 37)`   | `oklch(68% 0.165 37)`  |
| Lilac                 | `oklch(86% 0.04 315)`   | `oklch(84% 0.065 358)` |
| Tile                  | `oklch(94.5% 0.006 90)` | `oklch(24% 0.008 230)` |

Dark mode is its own palette, not the light one dimmed:

- Colours step up to pastels so they glow on charcoal.
- **Ink**, the colour of plain structural faces, turns to cream, because dark faces sink into a dark tile.
- **Line**, the colour of strokes and type, stays dark in both, since it always sits on a light or coloured sheet. Keep these two roles separate in the code from the start.

Use the hot accent on one small part per object and never as a ground. If borrowing the structure of another brand's palette, keep clear of its hues.

## Step 3: Draw the sheets

A sheet is a 1024 px square canvas: a flat palette colour plus one or two oversized motifs. See `DRAWN` in `reference/sheets.ts`.

- **Make sheets tile.** Draw every motif nine times, once per neighbouring tile, so anything crossing an edge wraps. Repeating patterns need a period that divides 1024.
- **Seed the randomness** so every visit draws the same sheet.
- **Marker strokes:** run a Catmull-Rom spline through jittered points and draw it in short segments whose width varies slightly, with round caps. Shapes that read as handwriting: cursive loops, zigzags, a ring that overshoots and never closes. Widths of 22 to 64 px.
- **Grain:** thousands of small specks whose density follows a function that repeats at the edges. This is how one colour thins into another.
- **Product sheets:** numbered calendar cells, bars standing for headlines, activity squares, the logo mark as a `Path2D`, a headline set very large in the brand's serif.
- **Type:** wait for the font (`document.fonts.load`) before drawing, or canvas silently falls back to a system serif.
- **Build lazily.** Draw a sheet the first time an object asks for it, and keep one canvas per colour mode.

Aim for about half the sheets plain or nearly plain.

## Step 4: Add public-domain photographs

Real printed matter is what stops the collage looking like clean vector art.

1. **Find.** `scripts/commons_search.py "typewriter patent drawing" "ENIAC"` searches Wikimedia Commons and lists only files whose licence metadata says public domain or CC0. Other good sources: the Library of Congress, NASA, the US National Archives, and museum open-access collections.
2. **Check the licence on the source page.** Take only public domain or CC0. A licence that asks for credit or forbids changes does not qualify, because a collage crops and recolours without credit. Works of the US government and anything published in the US before 1929 are the safe ground. "Public domain" on Commons usually means in the United States.
3. **Choose subject matter from the product's world,** and prefer line engravings and high-contrast photographs. Soft mid-grey photographs turn to mud.
4. **Download** with `scripts/commons_download.py` (edit its list). Run it from an empty folder: it saves the originals to `raw/` and a licence record per file to `records.json`. Commons rate-limits scripts: space requests a few seconds apart, and send a plain User-Agent with no personal details.
5. **Crop** with `scripts/crop_photos.py` (set each crop, and the file name `sheets.ts` loads, at the top): a square detail, greyscale, high contrast, 768 px, webp at about quality 62 (20 to 170 KB each). Crop out recognisable people and company names. Public domain covers copyright, not likeness or trademark.
6. **Record provenance** in a table like `reference/SOURCES.md`: file, what it shows, date, author, licence, source link. Public-domain works need no credit, but the table is the proof.
7. **Colour in the browser,** so one file serves several colours. Fill the sheet with a palette colour, then draw the greyscale image with `globalCompositeOperation = "multiply"` (darks in ink on coloured paper) or `"screen"` (darks in the colour on white).
8. **Load on demand.** Create the sheet as plain colour at once, print the photograph when it arrives, then tell the renderer to re-upload. The object never waits on the network.

Tell the user what you took, the licence basis, and anything worth a second look, such as legible old headlines or a person left in frame. You are not their lawyer; say so.

## Step 5: Build the objects

Work in three.js, in units of roughly -1.5 to 1.5. Build each object from named parts, where a part is a bag of triangles that will share one piece of artwork. See the builders in `reference/scene.ts`.

- **Boxes:** take the six faces separately so the front, the back and the edges can each be a different part.
- **Tubes and cones:** generate them in two halves split along their length, so half a turn brings different artwork round.
- **Thin paper:** add the same triangle twice, once with `FrontSide` and once with `BackSide`.
- **Coplanar surfaces flicker.** Offset one slightly, as a lens sits a hair proud of its rim.

Give each part a list of about four cuttings in the object's two colours plus ink and paper: one or two plain, the rest patterned or photographic. The first is what shows at rest. Parts that should stay constant, such as rims and edges, take a single ink cutting. A cutting is a sheet name, a `scale` (world units one sheet covers; 2 to 4.5 for drawn sheets, about 3.5 and up for photographs) and an offset that slides the sheet to choose what sits behind the part.

Return a resting pose for each object: a turn, a lean toward the viewer, and the half-width and half-height the camera must fit. A playing object shows every angle, so size for the widest one. Use a long lens (about 20 degrees field of view) so it reads as a graphic.

## Step 6: The window shader

This is the heart of the look. Every part uses one small `ShaderMaterial` that looks its sheet up by where the pixel is on the canvas, not where it is on the object:

```glsl
vec2 p = (gl_FragCoord.xy - centre) / unit;          // canvas position in world units
vec2 q = vec2(p.x * turn.x - p.y * turn.y, p.x * turn.y + p.y * turn.x);
gl_FragColor = texture2D(map, q / scale + at);
#include <colorspace_fragment>
```

- `centre` is half the drawing buffer and `unit` is device pixels per world unit at the object. Share those two uniform objects across every part of a mount and update them on resize.
- Without `#include <colorspace_fragment>` the colours come out dark, because the sheets are stored as sRGB.
- Set the textures to `RepeatWrapping` and `SRGBColorSpace`.

## Step 7: Stop-motion playback

- At rest, draw once and stop. No animation loop runs.
- While the object's card is hovered or holds keyboard focus, every 125 ms: advance the turn by a twelfth of a rotation, give each part the next cutting in its list with a fresh pseudo-random offset, and redraw. Start each part at a different place in its list so they do not change in step.
- Answer the pointer at once by advancing on enter, not a frame later.
- When the pointer leaves, play on to the next resting pose (the starting angle or its opposite) and stop. Never ease, glide or reverse.
- Ignore touch, which has no hover to end it. With `prefers-reduced-motion`, attach no listeners.
- Let a `data-collage-hover` attribute on an ancestor name the card, so hovering the text plays the object too.

## Step 8: Put it on a page

- **Lazy-load.** three.js is about 135 KB compressed. Import the scene module dynamically inside an effect, never at the top level of a server-rendered file.
- **One renderer per canvas,** with `alpha: true` so the tile shows through. Browsers allow about sixteen WebGL contexts, so three or four objects on a screen is fine and a grid of twenty is not.
- **Tiles are quiet.** Paper by day, charcoal by night, and the object carries the colour. Give the tile a size and let the canvas fill it.
- **Follow the colour scheme.** Pick the sheet set from `prefers-color-scheme`, and on change swap each texture's image in place and redraw.
- **Fail quietly.** Fade the canvas in once the first frame is drawn. If WebGL is missing or the context is lost, hide the canvas so the plain tile shows.
- **Decoration only.** Mark the canvas `aria-hidden` and let the words beside it carry the meaning.
- **Links beside an object:** make the whole card the link by stretching the action's `::after` over it. Style the label in ink followed by a round badge in a collage colour holding an arrow; on hover the arrow slides out the way it points and a second follows it in. Press scales the card to 0.985. Keep these under 300 ms, on `transform` only, behind `(hover: hover) and (pointer: fine)`.

Bugs that cost time once:

- Do not call `renderer.forceContextLoss()` on cleanup. A canvas keeps one context for life, and React's development double-mount hands the dead context to the second mount, which shows a broken-image face.
- A cloned texture needs `needsUpdate = true` or it never uploads and renders black.
- Dispose geometries, materials and textures on unmount, and remove every listener.
- Some dev servers cannot follow a dependency being optimised mid-session; pre-list `three` if so.

## Step 9: Look at it

Type-checks say nothing about whether an object reads. `scripts/capture.mjs` drives headless Chrome to hover a point and save consecutive frames:

```
GAP=130 SERIES=6 node scripts/capture.mjs http://localhost:5173/ out.png 1280 860 640 430
DARK=1 node scripts/capture.mjs http://localhost:5173/ dark.png 1280 860
```

- Check each object at rest and through at least six consecutive frames, in light and in dark, wide and narrow.
- Run one capture at a time. Several at once starve each other and freeze the animation, which looks like a bug and is not.
- Confirm the server answering is yours. On a machine with several dev servers, a 200 from the wrong one is easy to mistake; look for text only your page contains.
- A stuttering preview is usually the capture. A 10 fps GIF looks rough beside a 60 fps recording.
- Check the tile still looks intentional with the canvas hidden.

When judging against a reference recording, step through it frame by frame (`ffmpeg -vf fps=60,tile=...`) before describing how it works. Two wrong accounts of the reference were given here by watching at normal speed.

## What to tell the user at the end

Say what was built and where, show stills from both colour modes and a strip of hover frames, list the photographs and their licence basis, and name what was not checked: usually the motion at full speed in a real browser, phones, and anything a headless capture cannot show.
