Adding a navara_three example
First read .claude/skills/navara-usage/SKILL.md (and the reference file matching your example's topic) for correct API usage. The canonical process doc is web/navara_three/example/README.md — follow it for process (directories, dev server, screenshots). For code style, prefer this skill's boilerplate: the README's code template predates the current conventions (it uses addDefaultAtmosphereLayers() and inline data instead of DefaultPlugin + source/layer split).
Philosophy (from README — enforced in review)
"Don't hide our API inside abstractions in the example"
- Call
view.addLayer(), layer.on("featureUpdated", ...), layer.update() directly — no wrapper functions that obscure API calls.
- One example = one feature. Don't combine unrelated features.
Two example tracks — and who decides what goes where
Dev/demo examples (the default track): pages/<name>/ (URL /<name>) or pages/<category>/<name>/ (URL /<category>-<name>). Existing category dirs: styling/, terrain/, plugins/, use-cases/, debug/, mesh-layers/, or root (uncategorized). A directory only acts as a category when it has no main.ts of its own (see vite.config.example.ts) — e.g. pages/atmosphere/ and pages/camera/ are single examples, not categories, and the README's basic//effects/ categories don't exist yet. New examples — including anything for development or debugging — go here.
Curated gallery: pages/examples/<section>/<name>/ with a meta.ts next to main.ts.
The gallery is curated, not exhaustive. Pages under pages/examples/ require design approval and are planned against the gallery's overall design. Never add a page there for development/debug purposes, and never add one proactively "for coverage" the way docs pages are added — only add a gallery example when explicitly asked to, with the placement already decided.
Sections and the ExampleMeta type are declared in pages/examples/sections.ts (getting-started, 2d, 2.5d, 3d, basemap, terrain, source, styling, interaction, lighting-effect). meta.ts shape:
import type { ExampleMeta } from "../../sections";
export default {
section: "getting-started",
order: 1,
title: { en: "Hello World", ja: "Hello World" },
description: { en: "One-line summary.", ja: "一行の説明。" },
docs: "three/tutorial/basic-visualization", // docs-site path or absolute URL
} satisfies ExampleMeta;
Provide both en and ja for title/description (a bare string is a fallback for all languages).
File structure convention
Non-trivial examples split into two files:
// main.ts — thin entry: construct the view, delegate
import ThreeView from "@navaramap/three";
import { run, type CustomDescriptions } from "./run";
const view = new ThreeView<CustomDescriptions>({ shadow: true });
run(view);
// run.ts — the actual logic
export type CustomDescriptions = DefaultDescriptions; // or a union adding custom descriptors
export const run = async (view: ThreeView<CustomDescriptions>) => {
const defaultPlugin = new DefaultPlugin();
view.addPlugin(defaultPlugin);
const attribution = new AttributionPlugin();
view.addPlugin(attribution);
await view.init();
const scene = defaultPlugin.addDefaultPhotorealScene();
view.setCamera({ ... });
// ... addSource / addLayer / addEffect ...
// ... Tweakpane UI ...
attribution.show([TERRAIN_DATASETS.gsi, TILE_DATASETS.gsiSeamlessphoto]);
};
Tiny examples (hello-world scale) may inline everything in main.ts and use top-level await directly (await view.init()) — no run() wrapper or async IIFE. The example bundler supports top-level await.
Curated gallery code layout — main.ts is the displayed story
The detail page (pages/detail/DetailApp.tsx) renders only the example's main.ts (collected via a vite ?raw glob) as its "Source" section. Structure gallery examples so that single file reads as the feature's API story (reference: pages/examples/getting-started/layers/):
main.ts — view/plugin setup + the feature's Navara API calls, written with top-level await (no run() wrapper). Keep addSource / addLayer / layer.update() / layer.delete() calls direct and visible (philosophy rule). Comments: only a few one-liners stating non-obvious API facts (e.g. why extruded polygons need clampToGround: false); no header doc comment — the example's summary belongs in meta.ts (title / description), not main.ts.
data.ts — bulky inline data (GeoJSON fixtures etc.) as a typed exported constant. It is not shown on the detail page, so main.ts stays readable. A fixture used by more than one example moves to example/data/<name>.ts (named after the data, exporting a matching constant — e.g. data/gorakShepHuts.ts) instead of being copied per directory. Each page still keeps its own data.ts, re-exporting the shared constant as data, so main.ts always imports the same way and never reaches across example directories:
// pages/examples/effect/selective-bloom/data.ts
export { gorakShepHuts as data } from "../../../../data/gorakShepHuts";
// main.ts — the `data` name lets addSource use shorthand
import { data } from "./data";
const source = view.addSource({ type: "geojson", data });
UI chrome → example/helpers/button.ts — gallery demos use a few plain DOM buttons via addButton(label) (fixed top-left bar styled for the neutral basemap; returns a plain HTMLButtonElement — drive it with .textContent / .disabled / .onclick from main.ts). Tweakpane is for dev/debug pages only, not the gallery. Never move Navara API calls into helpers — helpers hold presentation only.
initializeExample(view, loadingMeshes?) (required) — every gallery example calls initializeExample(view) (example/helpers/initialize.ts) as the last line of main.ts, with no explanatory comment. Pages that add async-loading meshes (GLTF models, 3D Gaussian Splats) pass their handles: initializeExample(view, [splat]). It bundles the example-harness plumbing that is not part of the API story — the opaque name signals readers to skip it; no monkey-patching, it only listens to public events. Currently that plumbing is scene-loaded signalling: the detail page (pages/detail/DetailApp.tsx) renders the loading overlay (progress bar + percentage; scene loading has no measurable progress, so a pseudo-progress eases toward 90% and snaps to 100% on the demo's signal) over the demo iframe, and the demo posts SCENE_LOADED_MESSAGE once the page settles — no engine postUpdate for a quiet window (a single idle event is not reliable: the first can fire mid-setup) and every passed mesh has emitted its load event (GLTFModelDesc, InstancedGltfModelMeshDesc, SplatMeshDesc). Spark loads splats through its own pipeline that emits no engine events, which is why splat handles must be passed; give any new async-loading desc the same load event and pass its handle the same way. Add any future per-page boilerplate inside initializeExample, not as new lines in main.ts.
Gallery visual conventions (from the AD_EXAMPLE.md direction):
- Neutral stage: the grayscale basemap
https://papers.reearth.land/styles/grayscale/tilejson.json added via TileJsonPlugin (tilejson.addSource({ type: "raster-tile", url }) + view.addLayer({ type: "raster", source })), so the data colors are the hero.
- Data colors: one vivid accent per state rather than one hue per geometry (e.g. blue
#0091ff, switching to orange #ff6b2c to visualize a style update).
- Lighting for meshes/extrusions (Lambert materials render black unlit):
view.addLight({ ambient: { intensity: 0.6 } }) + view.addLight({ sun: { intensity: 1.8 } }) with a fixed UTC view.atmosphere.date, instead of the full photoreal scene. Unlit content needs no lights at all: clamp-to-ground (draped) vectors, point/billboard sprites, and raster basemaps render identically with zero lights — pure-2D pages should add none.
- Fill the frame with the subject. Frame the camera so the feature being demonstrated dominates the shot — no small subject floating in empty basemap. Full-globe shots: the globe nearly fills or slightly overflows the frame (e.g. draped world polygons at
height: ~6_500_000, straight down near the equator — at higher latitudes the geodetic normal misses the globe center and the sphere drifts off-center). Screen-space symbols (billboard/point/text with sizeInMeters: false) are sized for the 1200×750 capture viewport, so pick sizes that read in a 400px-wide thumbnail (pin ~240px, labels ~72px).
- Animations advance by wall-clock time, never per frame. A fixed per-frame increment runs 2× faster on a 120 Hz display. Scale progression by the elapsed time between
requestAnimationFrame timestamps (capped, e.g. Math.min(time - prevTime, 100), so a suspended tab doesn't jump) — see the smoothline reveal and the sun-time day loop.
- Vary the camera angle across the lineup — not everything top-down. Mix straight-down shots with oblique/side compositions where the scene recedes toward the horizon or shows sky (
distance + shallow pitch like -8…-38). A narrow FOV (view.camera.fov = 25 after setCamera) gives a compressed telephoto shot for small subjects like a GLTF model. When two examples share a subject (two globes, two GLTF models), give them clearly different compositions: different altitude, lens, viewing angle, and stage (grayscale vs black basemap).
Shared helpers — use these, don't reinvent
Under example/helpers/:
constants.ts — TERRAIN_DATASETS, TILE_DATASETS, TILES_3D_DATASETS, VECTOR_DATASETS, LOCAL_DATASETS (GSI tiles/terrain, PLATEAU 3D Tiles, etc. with attribution metadata)
control.ts — addCameraControl(view, pane), addDateControl(view, pane), addHidePaneKeyShortcut
panel.ts — addFieldsToFolder for Tweakpane folders with many fields
button.ts — addButton(label, onClick?) plain DOM buttons for gallery examples (see the gallery code layout section)
initialize.ts — initializeExample(view) example-harness bootstrap + SCENE_LOADED_MESSAGE, required in every gallery example; the detail page owns the loading overlay (see the gallery code layout section)
keys.ts — API keys (e.g. GOOGLE_MAPS_API_KEY)
Dev/debug page UI is Tweakpane (new Pane({ title }) + .addBinding(...).on("change", ...)); gallery example UI is plain DOM buttons from button.ts. The gallery/detail pages additionally use React + local shadcn/ui components (example/components/ui, imported via @/components/ui/*) — those are example-only, not part of the library.
Checklist for a new example
- Pick the track: dev/debug or unprompted additions →
pages/<category>/<name>/; curated gallery (pages/examples/) only with explicit design approval. Create the directory.
- Write
main.ts (+ run.ts, + meta.ts for the gallery track). Code comments in English.
- Run it:
cargo make dev (or pnpm --filter @navaramap/three dev) → http://localhost:5173/examples/<category>-<name>.
- Show data credits via
AttributionPlugin when using external datasets.
- Screenshot for the index card:
pnpm navara_three screenshots <page> (dev server must be running; adjust wait time in web/navara_three/scripts/generate-screenshots.ts via PAGE_CONFIGS if the scene loads slowly). The script hides everything except the canvas (buttons, panels, attribution) and, for curated demos, waits for the initializeExample scene-loaded postMessage before capturing, so thumbnails show only the settled scene.
- Before committing:
pnpm run build:example, pnpm run format, pnpm run lint, pnpm run test (from the repo root).
Verifying an example actually works (not just loads)
- Dev server:
pnpm --filter @navaramap/three dev picks the next free port when 5173 is taken — pass the real port to the screenshot script via SERVER_URL=http://localhost:<port>/examples (the /examples base is required).
- Gallery demo URLs are slash-form:
/demo/<section>/<slug> (e.g. /demo/getting-started/source); only legacy pages use the dash form.
- Drive the page headlessly (playwright: load
/demo/..., collect pageerror, count canvas, click the example's buttons, screenshot before/after) and look at the images — a page with a canvas and no errors can still be a broken or badly framed scene. Tune camera/sun from what you see.
- If every page throws
SyntaxError: ... does not provide an export named ... for a navara_wasm_* module, the WASM binaries are stale relative to the TS source — rebuild them (cargo make build-dev-all), then reload. If that build fails with "requires rustc X", run it with a newer installed toolchain: RUSTUP_TOOLCHAIN=<ver> cargo make build-dev-all (don't change the rustup override).
Judging gallery thumbnails as a lineup
Thumbnails are viewed side by side on the index, so after pnpm navara_three screenshots <path...> review them as a set, not one by one: composite the section's .avif files into one strip (sharp: resize each to 400×250, composite onto one canvas, output PNG) and check —
- one vivid accent per state (blue
#0091ff) against neutral stages, not a new hue per example;
- adjacent cards alternate light/dark stages (grayscale basemap vs space/dark map) so the row doesn't blur together;
- no two near-identical compositions — when two examples share a subject (e.g. two space globes), mirror the composition via
atmosphere.date (put the terminator on opposite sides) or change altitude/framing.
1---2name: navara-add-example3description: Conventions for adding or modifying examples in web/navara_three/example. Use when creating a new example page, editing an existing one, registering it in the gallery, or generating its screenshot.4---56# Adding a navara_three example78**First read [.claude/skills/navara-usage/SKILL.md](../navara-usage/SKILL.md)** (and the reference file matching your example's topic) for correct API usage. The canonical process doc is `web/navara_three/example/README.md` — follow it for *process* (directories, dev server, screenshots). For *code style*, prefer this skill's boilerplate: the README's code template predates the current conventions (it uses `addDefaultAtmosphereLayers()` and inline `data` instead of DefaultPlugin + source/layer split).910## Philosophy (from README — enforced in review)1112> "Don't hide our API inside abstractions in the example"1314- Call `view.addLayer()`, `layer.on("featureUpdated", ...)`, `layer.update()` directly — no wrapper functions that obscure API calls.15- One example = one feature. Don't combine unrelated features.1617## Two example tracks — and who decides what goes where18191. **Dev/demo examples (the default track):** `pages/<name>/` (URL `/<name>`) or `pages/<category>/<name>/` (URL `/<category>-<name>`). Existing category dirs: `styling/`, `terrain/`, `plugins/`, `use-cases/`, `debug/`, `mesh-layers/`, or root (uncategorized). A directory only acts as a category when it has **no** `main.ts` of its own (see `vite.config.example.ts`) — e.g. `pages/atmosphere/` and `pages/camera/` are single examples, not categories, and the README's `basic/`/`effects/` categories don't exist yet. New examples — including anything for development or debugging — go here.202. **Curated gallery:** `pages/examples/<section>/<name>/` with a `meta.ts` next to `main.ts`.2122 **The gallery is curated, not exhaustive.** Pages under `pages/examples/` require **design approval** and are planned against the gallery's overall design. Never add a page there for development/debug purposes, and never add one proactively "for coverage" the way docs pages are added — only add a gallery example when explicitly asked to, with the placement already decided.2324 Sections and the `ExampleMeta` type are declared in `pages/examples/sections.ts` (`getting-started`, `2d`, `2.5d`, `3d`, `basemap`, `terrain`, `source`, `styling`, `interaction`, `lighting-effect`). `meta.ts` shape:2526```typescript27import type { ExampleMeta } from "../../sections";2829export default {30 section: "getting-started",31 order: 1,32 title: { en: "Hello World", ja: "Hello World" },33 description: { en: "One-line summary.", ja: "一行の説明。" },34 docs: "three/tutorial/basic-visualization", // docs-site path or absolute URL35} satisfies ExampleMeta;36```3738Provide both `en` and `ja` for title/description (a bare string is a fallback for all languages).3940## File structure convention4142Non-trivial examples split into two files:4344```typescript45// main.ts — thin entry: construct the view, delegate46import ThreeView from "@navaramap/three";47import { run, type CustomDescriptions } from "./run";48const view = new ThreeView<CustomDescriptions>({ shadow: true });49run(view);50```5152```typescript53// run.ts — the actual logic54export type CustomDescriptions = DefaultDescriptions; // or a union adding custom descriptors55export const run = async (view: ThreeView<CustomDescriptions>) => {56 const defaultPlugin = new DefaultPlugin();57 view.addPlugin(defaultPlugin);58 const attribution = new AttributionPlugin();59 view.addPlugin(attribution);60 await view.init();61 const scene = defaultPlugin.addDefaultPhotorealScene();62 view.setCamera({ ... });63 // ... addSource / addLayer / addEffect ...64 // ... Tweakpane UI ...65 attribution.show([TERRAIN_DATASETS.gsi, TILE_DATASETS.gsiSeamlessphoto]);66};67```6869Tiny examples (hello-world scale) may inline everything in `main.ts` and use **top-level `await`** directly (`await view.init()`) — no `run()` wrapper or async IIFE. The example bundler supports top-level await.7071## Curated gallery code layout — main.ts is the displayed story7273The detail page (`pages/detail/DetailApp.tsx`) renders **only the example's `main.ts`** (collected via a vite `?raw` glob) as its "Source" section. Structure gallery examples so that single file reads as the feature's API story (reference: `pages/examples/getting-started/layers/`):7475- **`main.ts`** — view/plugin setup + the feature's Navara API calls, written with top-level `await` (no `run()` wrapper). Keep `addSource` / `addLayer` / `layer.update()` / `layer.delete()` calls direct and visible (philosophy rule). Comments: only a few one-liners stating non-obvious API facts (e.g. why extruded polygons need `clampToGround: false`); **no header doc comment** — the example's summary belongs in `meta.ts` (`title` / `description`), not main.ts.76- **`data.ts`** — bulky inline data (GeoJSON fixtures etc.) as a typed exported constant. It is not shown on the detail page, so main.ts stays readable. **A fixture used by more than one example moves to `example/data/<name>.ts`** (named after the data, exporting a matching constant — e.g. `data/gorakShepHuts.ts`) instead of being copied per directory. Each page still keeps its own `data.ts`, re-exporting the shared constant as `data`, so main.ts always imports the same way and never reaches across example directories:7778 ```typescript79 // pages/examples/effect/selective-bloom/data.ts80 export { gorakShepHuts as data } from "../../../../data/gorakShepHuts";81 ```8283 ```typescript84 // main.ts — the `data` name lets addSource use shorthand85 import { data } from "./data";86 const source = view.addSource({ type: "geojson", data });87 ```88- **UI chrome → `example/helpers/button.ts`** — gallery demos use a few plain DOM buttons via `addButton(label)` (fixed top-left bar styled for the neutral basemap; returns a plain `HTMLButtonElement` — drive it with `.textContent` / `.disabled` / `.onclick` from main.ts). **Tweakpane is for dev/debug pages only, not the gallery.** Never move Navara API calls into helpers — helpers hold presentation only.89- **`initializeExample(view, loadingMeshes?)` (required)** — every gallery example calls `initializeExample(view)` (`example/helpers/initialize.ts`) as the **last line** of main.ts, with no explanatory comment. Pages that add async-loading meshes (GLTF models, 3D Gaussian Splats) pass their handles: `initializeExample(view, [splat])`. It bundles the example-harness plumbing that is *not* part of the API story — the opaque name signals readers to skip it; no monkey-patching, it only listens to public events. Currently that plumbing is scene-loaded signalling: the **detail page** (`pages/detail/DetailApp.tsx`) renders the loading overlay (progress bar + percentage; scene loading has no measurable progress, so a pseudo-progress eases toward 90% and snaps to 100% on the demo's signal) over the demo iframe, and the demo posts `SCENE_LOADED_MESSAGE` once the page settles — no engine `postUpdate` for a quiet window (a single `idle` event is not reliable: the first can fire mid-setup) and every passed mesh has emitted its `load` event (`GLTFModelDesc`, `InstancedGltfModelMeshDesc`, `SplatMeshDesc`). Spark loads splats through its own pipeline that emits no engine events, which is why splat handles must be passed; give any new async-loading desc the same `load` event and pass its handle the same way. Add any future per-page boilerplate inside `initializeExample`, not as new lines in main.ts.9091Gallery visual conventions (from the AD_EXAMPLE.md direction):9293- Neutral stage: the grayscale basemap `https://papers.reearth.land/styles/grayscale/tilejson.json` added via `TileJsonPlugin` (`tilejson.addSource({ type: "raster-tile", url }) ` + `view.addLayer({ type: "raster", source })`), so the data colors are the hero.94- Data colors: one vivid accent per state rather than one hue per geometry (e.g. blue `#0091ff`, switching to orange `#ff6b2c` to visualize a style update).95- Lighting for meshes/extrusions (Lambert materials render black unlit): `view.addLight({ ambient: { intensity: 0.6 } })` + `view.addLight({ sun: { intensity: 1.8 } })` with a **fixed UTC** `view.atmosphere.date`, instead of the full photoreal scene. **Unlit content needs no lights at all**: clamp-to-ground (draped) vectors, `point`/`billboard` sprites, and raster basemaps render identically with zero lights — pure-2D pages should add none.96- **Fill the frame with the subject.** Frame the camera so the feature being demonstrated dominates the shot — no small subject floating in empty basemap. Full-globe shots: the globe nearly fills or slightly overflows the frame (e.g. draped world polygons at `height: ~6_500_000`, straight down near the equator — at higher latitudes the geodetic normal misses the globe center and the sphere drifts off-center). Screen-space symbols (billboard/point/text with `sizeInMeters: false`) are sized for the 1200×750 capture viewport, so pick sizes that read in a 400px-wide thumbnail (pin ~240px, labels ~72px).97- **Animations advance by wall-clock time, never per frame.** A fixed per-frame increment runs 2× faster on a 120 Hz display. Scale progression by the elapsed time between `requestAnimationFrame` timestamps (capped, e.g. `Math.min(time - prevTime, 100)`, so a suspended tab doesn't jump) — see the smoothline reveal and the sun-time day loop.98- **Vary the camera angle across the lineup — not everything top-down.** Mix straight-down shots with oblique/side compositions where the scene recedes toward the horizon or shows sky (`distance` + shallow pitch like `-8`…`-38`). A narrow FOV (`view.camera.fov = 25` after `setCamera`) gives a compressed telephoto shot for small subjects like a GLTF model. When two examples share a subject (two globes, two GLTF models), give them clearly different compositions: different altitude, lens, viewing angle, and stage (grayscale vs black basemap).99100## Shared helpers — use these, don't reinvent101102Under `example/helpers/`:103104- `constants.ts` — `TERRAIN_DATASETS`, `TILE_DATASETS`, `TILES_3D_DATASETS`, `VECTOR_DATASETS`, `LOCAL_DATASETS` (GSI tiles/terrain, PLATEAU 3D Tiles, etc. with attribution metadata)105- `control.ts` — `addCameraControl(view, pane)`, `addDateControl(view, pane)`, `addHidePaneKeyShortcut`106- `panel.ts` — `addFieldsToFolder` for Tweakpane folders with many fields107- `button.ts` — `addButton(label, onClick?)` plain DOM buttons for gallery examples (see the gallery code layout section)108- `initialize.ts` — `initializeExample(view)` example-harness bootstrap + `SCENE_LOADED_MESSAGE`, required in every gallery example; the detail page owns the loading overlay (see the gallery code layout section)109- `keys.ts` — API keys (e.g. `GOOGLE_MAPS_API_KEY`)110111Dev/debug page UI is **Tweakpane** (`new Pane({ title })` + `.addBinding(...).on("change", ...)`); gallery example UI is plain DOM buttons from `button.ts`. The gallery/detail pages additionally use React + local shadcn/ui components (`example/components/ui`, imported via `@/components/ui/*`) — those are example-only, not part of the library.112113## Checklist for a new example1141151. Pick the track: dev/debug or unprompted additions → `pages/<category>/<name>/`; curated gallery (`pages/examples/`) only with explicit design approval. Create the directory.1162. Write `main.ts` (+ `run.ts`, + `meta.ts` for the gallery track). Code comments in English.1173. Run it: `cargo make dev` (or `pnpm --filter @navaramap/three dev`) → `http://localhost:5173/examples/<category>-<name>`.1184. Show data credits via `AttributionPlugin` when using external datasets.1195. Screenshot for the index card: `pnpm navara_three screenshots <page>` (dev server must be running; adjust wait time in `web/navara_three/scripts/generate-screenshots.ts` via `PAGE_CONFIGS` if the scene loads slowly). The script hides everything except the canvas (buttons, panels, attribution) and, for curated demos, waits for the `initializeExample` scene-loaded postMessage before capturing, so thumbnails show only the settled scene.1206. Before committing: `pnpm run build:example`, `pnpm run format`, `pnpm run lint`, `pnpm run test` (from the repo root).121122## Verifying an example actually works (not just loads)123124- Dev server: `pnpm --filter @navaramap/three dev` picks the next free port when 5173 is taken — pass the real port to the screenshot script via `SERVER_URL=http://localhost:<port>/examples` (the `/examples` base is required).125- Gallery demo URLs are slash-form: `/demo/<section>/<slug>` (e.g. `/demo/getting-started/source`); only legacy pages use the dash form.126- Drive the page headlessly (playwright: load `/demo/...`, collect `pageerror`, count `canvas`, click the example's buttons, screenshot before/after) and **look at the images** — a page with a canvas and no errors can still be a broken or badly framed scene. Tune camera/sun from what you see.127- If every page throws `SyntaxError: ... does not provide an export named ...` for a `navara_wasm_*` module, the WASM binaries are stale relative to the TS source — rebuild them (`cargo make build-dev-all`), then reload. If that build fails with "requires rustc X", run it with a newer installed toolchain: `RUSTUP_TOOLCHAIN=<ver> cargo make build-dev-all` (don't change the rustup override).128129## Judging gallery thumbnails as a lineup130131Thumbnails are viewed side by side on the index, so after `pnpm navara_three screenshots <path...>` review them **as a set, not one by one**: composite the section's `.avif` files into one strip (sharp: resize each to 400×250, `composite` onto one canvas, output PNG) and check —132133- one vivid accent per state (blue `#0091ff`) against neutral stages, not a new hue per example;134- adjacent cards alternate light/dark stages (grayscale basemap vs space/dark map) so the row doesn't blur together;135- no two near-identical compositions — when two examples share a subject (e.g. two space globes), mirror the composition via `atmosphere.date` (put the terminator on opposite sides) or change altitude/framing.