diff --git a/CLAUDE.md b/CLAUDE.md
index 42a837c..3b3d3bb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -105,12 +105,48 @@ Rules:
`saezuri-illustrations` repo (a flat folder the app auto-downloads per detected
species); the AvianVisitors cutouts under `public/assets/` remain gitignored dev
placeholders so the UI is testable locally. Growing that set is ongoing, not a v1 gate.
-## Common commands
+## Code map
+
+Where things live, so a change lands in the right place fast.
-To be filled in once the scaffold lands. Expected shape:
+- **Collage render path:** `src/pages/CollagePage.tsx` fetches the data and renders
+ `src/collage/Collage.tsx`, which measures the viewport, resolves each species to art,
+ packs the tiles, and maps them to `src/collage/BirdTile.tsx` (one absolutely-positioned
+ `` per bird). Silhouette hover is arbitrated at the container
+ (`hitTest.ts`), not per tile — the tiles are `pointer-events: none`.
+- **Layout / packer:** `src/collage/layout.ts` — `computeLayout(inputs, vp)` is the
+ deterministic, seeded (`src/lib/prng.ts`) count-driven sizing + silhouette packer
+ (`pack.ts`); reimplemented from AvianVisitors, not copied. Same inputs + viewport ⇒ same
+ layout, so polls and resizes don't churn. `layoutSignature(tiles)` fingerprints the
+ arrangement (`sci`/`n`/`key`, viewport-independent) via `src/lib/hash.ts` (`fnv1a`).
+- **Entrance bloom:** the `gtile-in` keyframes in `src/index.css` (`.gtile.entering`),
+ disabled under `prefers-reduced-motion`. It is a **one-shot CSS mount animation** — it
+ replays only when React remounts a tile, i.e. when the tile `key`
+ `` `${blossomKey}:${sig}:${sci}` `` changes: `blossomKey` is the window preset and `sig`
+ is `layoutSignature`. So the bloom fires on load, window switch, and any in-place update
+ (poll / focus revalidation) that yields a genuinely different layout — not on identical
+ polls or plain resizes. There is no positional transition on `.gtile`; tiles that don't
+ remount just snap.
+- **Data hooks (browser reads static files only):** `src/hooks/useRecentSpecies.ts`
+ (`/snapshot.json`, 12s poll) selects the active window; `src/hooks/useLayoutManifest.ts`
+ (`/layout-manifest.json`, 30s poll) supplies per-species masks/dims/versions. Both use
+ SWR's default `revalidateOnFocus`. The dictionary hooks (`useDictionaryIndex.ts`,
+ `useSpeciesDictionary.ts`) deliberately set `revalidateOnFocus: false`. There is no
+ `SWRConfig` provider. User preferences (`useThemePreference.ts`, `useLanguagePreference.ts`)
+ are per-client `localStorage` under `saezuri:*` keys.
+- **Domain (framework-free, shared with the server):** `src/domain/` — `species.ts`
+ (aggregation + localization), `asset.ts` (`resolveArt`, `imagePath` with the `?v=` hash
+ cache-bust), `snapshot.ts`, `manifest.ts`, `slug.ts`.
+- **Refresh service (the sole BirdNET-Go client):** `src/server/` — holds the SSE stream,
+ gates/aggregates species, and publishes `/snapshot.json`, `/layout-manifest.json`, and
+ the e-ink PNG frames (`render.ts`, reusing `computeLayout`). Run it with `npm run refresh:dev`.
+
+## Common commands
-- `npm run dev` for the Vite dev server.
-- `npm run build` for the production bundle.
+- `npm run dev` (Vite dev server) / `npm run dev:mock` (`VITE_MOCK=1`, synthesizes species
+ from the local manifest so the collage runs with no backend).
+- `npm test` (vitest), `npm run typecheck` (`tsc --noEmit`), `npm run check` (Biome).
+- `npm run build` for the production bundle; `npm run refresh:dev` runs the refresh service.
- `docker compose up --build` to run the container against a configured `BIRDNETGO_URL`.
## Git hygiene
diff --git a/src/collage/Collage.tsx b/src/collage/Collage.tsx
index cab346f..27b7e15 100644
--- a/src/collage/Collage.tsx
+++ b/src/collage/Collage.tsx
@@ -12,7 +12,7 @@ import type { Species } from '../domain/species.ts'
import { BirdTile } from './BirdTile.tsx'
import { HoverChip } from './HoverChip.tsx'
import { hitTest } from './hitTest.ts'
-import { computeLayout, type LayoutInput, type Viewport } from './layout.ts'
+import { computeLayout, type LayoutInput, layoutSignature, type Viewport } from './layout.ts'
import { decodeMaskCached } from './pack.ts'
interface Props {
@@ -20,9 +20,11 @@ interface Props {
manifest: LayoutManifest
/** Bloom tiles in on mount (disable for screenshots). */
animate?: boolean
- /** Namespaces the tile keys so a change remounts every tile — used to replay
- * the entrance bloom when the whole set turns over (e.g. switching windows),
- * while a same-key poll still re-blooms only newly-arrived birds. */
+ /** Namespaces the tile keys so a change remounts every tile and replays the
+ * entrance bloom. Combined here with a signature of the current layout, so the
+ * bloom also replays when an in-place update (poll / focus revalidation) yields
+ * a genuinely different arrangement — not only when the window switches. Pass
+ * the window preset; it keeps windows in separate key namespaces. */
blossomKey?: string
/** Rendered when there are no birds in the window. */
emptyState?: ReactNode
@@ -101,6 +103,12 @@ export function Collage({ species, manifest, animate = true, blossomKey = '', em
return computeLayout(inputs, vp)
}, [species, manifest, vp])
+ // Fingerprint the arrangement (species / counts / art slots, not pixel coords)
+ // so the tile keys below change — and the bloom replays — exactly when a poll or
+ // focus revalidation lands a genuinely different layout, and never on a plain
+ // resize or an identical poll. Memoized so hover re-renders don't recompute it.
+ const sig = useMemo(() => layoutSignature(tiles), [tiles])
+
const fallbackUrl = imagePath(manifest.fallbackKey, manifest.ver?.[manifest.fallbackKey])
const cx = vp.width / 2
const cy = vp.height / 2
@@ -137,7 +145,7 @@ export function Collage({ species, manifest, animate = true, blossomKey = '', em
const dist = Math.hypot(t.x + t.w / 2 - cx, t.y + t.h / 2 - cy)
return (
{
expect(first).toEqual(again)
})
})
+
+function tile(sci: string, n: number, overrides: Partial = {}): LaidTile {
+ return { ...input(sci, n), x: 0, y: 0, w: 10, h: 10, parked: false, ...overrides }
+}
+
+describe('layoutSignature', () => {
+ const base = [tile('a', 3), tile('b', 1), tile('c', 5)]
+
+ it('is stable for the same species, counts, and art keys', () => {
+ expect(layoutSignature(base)).toBe(layoutSignature([tile('a', 3), tile('b', 1), tile('c', 5)]))
+ })
+
+ it('ignores tile order', () => {
+ expect(layoutSignature(base)).toBe(layoutSignature([tile('c', 5), tile('a', 3), tile('b', 1)]))
+ })
+
+ it('ignores pixel coordinates, so it holds across a resize', () => {
+ const moved = base.map((t) => ({ ...t, x: t.x + 100, y: t.y + 40, w: t.w * 2, h: t.h * 2 }))
+ expect(layoutSignature(moved)).toBe(layoutSignature(base))
+ })
+
+ it('changes when a detection count changes', () => {
+ expect(layoutSignature([tile('a', 3)])).not.toBe(layoutSignature([tile('a', 4)]))
+ })
+
+ it('changes when a species enters or leaves', () => {
+ expect(layoutSignature(base)).not.toBe(layoutSignature([tile('a', 3), tile('b', 1)]))
+ })
+
+ it('changes when the resolved art key changes (fallback→art, perched→flight)', () => {
+ expect(layoutSignature([tile('a', 3, { key: 'a' })])).not.toBe(
+ layoutSignature([tile('a', 3, { key: 'a-2' })]),
+ )
+ })
+})
diff --git a/src/collage/layout.ts b/src/collage/layout.ts
index 9ac7f76..bb99886 100644
--- a/src/collage/layout.ts
+++ b/src/collage/layout.ts
@@ -1,3 +1,4 @@
+import { fnv1a } from '../lib/hash.ts'
import { type DecodedMask, isParked, maskPack, type PlaceableTile } from './pack.ts'
// Count-driven sizing + packing, reimplemented from study of AvianVisitors'
@@ -173,3 +174,16 @@ export function computeLayout(inputs: readonly LayoutInput[], vp: Viewport): Lai
parked: isParked(t),
}))
}
+
+/** A short fingerprint of what makes a layout the layout it is: which species are
+ * present (`sci`), how loud each is (`n`, which drives tile size), and which art
+ * slot each resolved to (`key`, which sets the mask + aspect and flips on
+ * fallback→real-art or perched→flight). Sorted so tile order never matters, and
+ * deliberately blind to pixel coordinates — so it is stable across viewport
+ * resizes and identical polls, and changes only when the arrangement genuinely
+ * differs. The collage feeds it into the tile keys so a changed layout remounts
+ * the tiles and replays the entrance bloom (see Collage.tsx). */
+export function layoutSignature(tiles: readonly LaidTile[]): string {
+ const parts = tiles.map((t) => `${t.sci}:${t.n}:${t.key}`).sort()
+ return fnv1a(parts.join('|')).toString(36)
+}
diff --git a/src/lib/hash.ts b/src/lib/hash.ts
new file mode 100644
index 0000000..3bf212e
--- /dev/null
+++ b/src/lib/hash.ts
@@ -0,0 +1,15 @@
+// FNV-1a, the 32-bit variant. A fast, dependency-free string hash used to fold a
+// long fingerprint down to a short, stable token — the e-ink frame signature and
+// the collage's re-bloom key both fold their per-species strings through this so
+// two identical inputs always collapse to the same value.
+const FNV_OFFSET = 0x811c9dc5
+const FNV_PRIME = 0x01000193
+
+export function fnv1a(str: string): number {
+ let h = FNV_OFFSET
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i)
+ h = Math.imul(h, FNV_PRIME)
+ }
+ return h >>> 0
+}
diff --git a/src/server/render.ts b/src/server/render.ts
index ec8066a..5b9027e 100644
--- a/src/server/render.ts
+++ b/src/server/render.ts
@@ -6,6 +6,7 @@ import { decodeMaskCached } from '../collage/pack.ts'
import { resolveArt, rollFlight } from '../domain/asset.ts'
import type { LayoutManifest } from '../domain/manifest.ts'
import type { Species } from '../domain/species.ts'
+import { fnv1a } from '../lib/hash.ts'
import { createPrng } from '../lib/prng.ts'
// Frame compositor: renders a window's gated species into a fixed-size PNG for
@@ -27,18 +28,6 @@ export interface FrameOptions {
const SHADOW = { color: 'rgba(26,22,18,0.1)', blur: 6, offsetY: 2 }
const DEFAULT_AR = 1.4
-const FNV_OFFSET = 0x811c9dc5
-const FNV_PRIME = 0x01000193
-
-function fnv1a(str: string): number {
- let h = FNV_OFFSET
- for (let i = 0; i < str.length; i++) {
- h ^= str.charCodeAt(i)
- h = Math.imul(h, FNV_PRIME)
- }
- return h >>> 0
-}
-
/** Stable seed per species so its pose holds across renders (no e-ink churn),
* while ~FLY_PROB of the roster still fly. */
function seedFor(sci: string): number {