# Architecture

## Monorepo
```
packages/photo-sdk/   # the product (React + TS, self-contained CSS, no ML deps)
  src/
    components/       # PhotoGallery, AppShell, Sidebar, TopToolbar, views/, editor/, Lightbox,
                      # MediaGrid, PhotoTile, ContextMenu, Modal, SelectionBar, AIAnalyzer, AnnotationLayer
    store/            # Zustand store (createGalleryStore) + selectors + React context
    adapters/         # StorageAdapter interface + localStorage default (Supabase planned)
    ai/               # AIProvider interface (detect/faces/ocr/caption/embed/generativeEdit) + helpers
    lib/              # classify, smartAlbums, grouping, edits, media (import/EXIF), format, download
    icons/            # original SF-style SVG set
    styles/sdk.css    # design tokens (light/dark/semi-dark) + components, prefixed .apg-
apps/web/             # Next.js demo
  src/app/            # landing, /gallery, /api/ai/edit (Gemini proxy), layout, middleware (nonce CSP)
  src/lib/ai/         # tensorflowProvider (coco-ssd), createDemoAIProvider (+ Gemini generativeEdit)
  src/lib/seed.ts     # demo dataset (Picsum images + local video + synthetic metadata)
```

## Data flow
`<PhotoGallery>` → creates a per-instance Zustand store → `GalleryProvider` (context) → `AppShell` →
Sidebar + Toolbar + `ViewRouter`. The store holds media/albums/people/selection/view/theme/AI status.
A pluggable `StorageAdapter` loads/saves state (debounced). Inputs are normalized + sanitized through
`normalizeMediaItem` (URL-scheme allow-list, string coercion) on both the `photos` prop and adapter load.

## Adapters (storage) — all optional
`StorageAdapter = { name, load(), save(state), putBlob?, clear?, applyChanges?, putMedia? }`.
- Default: `createLocalStorageAdapter()` (metadata in localStorage, blobs in IndexedDB).
- `applyChanges?(changes: StateChanges)` — **incremental persistence**. When present the store calls it
  *instead of* `save()`, sending only entities whose object reference changed since the last successful
  persist (`upsert*` / `remove*` per collection, plus `labelAliases` / `deletedLabels` on a shallow diff).
  The store keeps per-instance id→reference snapshots seeded from `load()`, serializes overlapping
  writes, and rolls the snapshot back on rejection so the next persist retries the same diff.
- `putMedia?(id, blob, { name, mime }): Promise<{ ref, url }>` — **durable bytes**, preferred over
  `putBlob`. `url` goes to `MediaItem.src` (may be a short-lived signed URL); `ref` goes to
  `MediaItem.storageRef` and is what survives a reload.
- Planned: Supabase (Postgres + Storage), S3/R2/GCS/Azure/MinIO, REST/GraphQL. Swap via the `adapter` prop.

## Embedding in a host app
The gallery is standalone-by-default but fully suppressible. `GalleryConfig` carries
`embedded`, `chrome { titlebar, sidebar, toolbar, themeSwitcher }` (`DEFAULT_CHROME`),
`keyboardShortcuts`, `currentUser: GalleryUser` and `shareBaseUrl`; `<PhotoGallery>` exposes each as a
prop and pushes the recomputed config back into the store via `setConfig` on every change, so theme /
tokens / identity are live rather than mount-time-only. `AppShell` skips `<Sidebar>` / `<TopToolbar>`
per `chrome`, and `useKeyboardShortcuts(enabled)` takes a flag (never a conditional hook call).
`embedded` adds `.apg--embedded` (`height: 100%`, `cursor: pointer` on controls); overlays stay
`position: fixed`, and the Info panel honours `--apg-overlay-top`.
`hiddenViews: ViewId[]` (default `[]`) drops sidebar rows and Collections cards (e.g. `screenshots`,
`sys:documents`); a section label is omitted when all its rows are hidden, and `AppShell` falls back to
`library` if the active view becomes hidden.
With `currentUser` set, comments carry `{ authorId, author, authorAvatar }` and only the author may
delete — a client mirror of the server rule. The same identity is stamped as display metadata on
`MediaItem.uploadedBy` (imports + camera capture, never overwriting an existing value) and on each
`MediaVersion` (`addVersion` / `restoreVersion` → `{ authorId, author, authorAvatar }`); the Info panel
surfaces both. `be-crm` still owns `owner_principal_id` — `uploadedBy` is display-only.
`defaultFullscreen` seeds `fullscreen` in the store (`setFullscreen` / `toggleFullscreen`; a toolbar
button next to Info drives it). It adds `.apg--fullscreen` — `position: fixed; inset: 0; z-index: 1400;
height: 100dvh` — so the gallery fills the viewport whatever height the host gave its container. The
overlays needed no change: `position: fixed; inset: 0` resolves against the viewport, which full screen
now coincides with, and `z-index: 1400` opens a stacking context their 1000–1300 layers live inside.
Escape un-maximises, but only after the lightbox/editor/camera/modal/context-menu check and after
clearing an object focus or selection.
`lockProvider` makes the Recently Deleted lock server-backed and per-user: `status` / `set(password |
null)` / `verify`. When present the store never reads or writes the `apg:lock-hash` localStorage entry
— `lockConfigured` (seeded from `status()` in `init()`) is the UI's source of truth, and `lockError`
separates `'wrong-password'` (verify resolved false) from `'unavailable'` (verify rejected). Without a
provider the localStorage path is byte-for-byte unchanged.
Full prop/token tables: [`packages/photo-sdk/README.md`](../packages/photo-sdk/README.md#embedding-in-a-host-app).

## Map view
Leaflet is dynamically imported (never at module scope, so SSR is safe) and driven through refs.
`clusterByLocation` groups located media by coordinate precision that rises with zoom, so pins split
apart as you go in. Clicking a pin `flyTo`s it — `max(currentZoom, 14)` for a lone photo, `max(…, 12)`
for a cluster, never zooming out — then pans up by half the sheet height so the pin clears the sheet
that is about to cover the bottom half. Multi-photo pins carry a total-count badge and a hover
mini-slider: a strip of up to 5 thumbnails plus a `+N` chip that auto-advances the preview every 900 ms
while hovered. The single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped —
MapView measures it and sets `--tip-dx` to slide it back inside the map, adding `.apg-pin__tip--below` to
flip it under the pin near the top edge — so it can't overflow or get clipped. The strip is built with plain DOM (matching the marker code, and keeping place names /
filenames on `textContent`, never `innerHTML`); every interval is registered in a set that marker
teardown, mode changes and unmount all drain, because a leaked one would mutate detached DOM forever.
The location sheet has its own filter bar: a search field reusing `searchMedia` (so "objects" mean the
same thing as in the global search), a single **date-range** control (`.apg-daterange` — a button that
opens a popover of presets `All dates / Last 7 days / Last 30 days / This year` plus a custom From/To
pair, all driving one `takenAt` from/to state), and a row of object-label chips built from the cluster
— all AND-combined, with a live "N of M photos" count, one clear button that resets everything, and an
empty state when nothing survives.

## Toolbar & Info panel
The top toolbar floats as a rounded glass bar (margins all round, `--apg-radius-lg`, `--apg-shadow-md`),
matching the sidebar, in embedded and full-screen alike. It stays in normal flow inside `.apg-main`, so
`.apg-viewport` begins below it and scroll behaviour is unchanged — no overlap, no manual top-padding.
The library-scale segmented is now a compact `.apg-scalemenu` dropdown that reuses the `.apg-menu`
popover (menu role, checkmarks, arrow-key nav), which also removes the overlap it used to have with the
zoom-out button.
The Info panel adds: a real inline `<video>` preview (`.apg-info__thumb--video`) at the top for video items
(not a frozen poster `<img>`); an "Uploaded by" identity block and per-version author; comments rendered as
cards (`.apg-comment`) with a divider before the compose form; click-to-edit `caption`
and multi-line `note` (saved on blur/Enter via `updateMedia`, and `note` is in the search haystack);
and full-address reverse geocoding. On opening a located item whose `location.formatted` is unset, it
fetches OpenStreetMap Nominatim once (`addressdetails=1`), parses `road/…/countryCode/formatted` into
`GeoLocation`, and persists via `updateMedia` (survives reload, never re-fetched — an in-flight/failed
id guard prevents loops and double-fetches). A hardened host CSP must allow `nominatim.openstreetmap.org`
in `connect-src`.

## AI providers — all optional, free unless noted
`AIProvider` methods (each optional): `detectObjects`, `detectFaces`, `caption`, `ocr`, `embedImage`,
`embedText`, `generativeEdit`. The SDK ships **no ML deps**; providers are injected by the host:
- `tensorflowProvider` (COCO-SSD) — free, in-browser object detection.
- `createDemoAIProvider` — combines detection + Gemini `generativeEdit` (proxied through `/api/ai/edit`).
- Planned: face-api.js (faces), tesseract.js (OCR), transformers.js / Hugging Face (semantic search, captions).
`AIAnalyzer` (headless) runs detection across the library and writes `objects`/`objectLabels` back.

## Theming
CSS variables on `.apg[data-theme]`. Themes: `light`, `dark`, `semi-dark` (glass sidebar + light content).
Customizable via props → CSS vars: `--apg-accent`, `--apg-radius*`, plus the full `ThemeTokens` map
(backgrounds, cards, toolbar/menu glass, separators, hover/active, text tiers, glass border, font,
shadows, tile-favourite, overlay/editor backgrounds, segmented pill, sidebar width, toolbar height) —
`xLight`/`xDark` for theme-paired values, bare names for theme-independent ones. Applied as inline CSS
vars on the root, filtered through a `url()/expression()/javascript:/<>{}` guard. Token → variable
table: [`packages/photo-sdk/README.md`](../packages/photo-sdk/README.md#theme-tokens--css-variables).
All feature toggles via `features`.

## Security
Per-request **nonce CSP** in `apps/web/src/middleware.ts` (`script-src 'self' 'nonce-…' 'strict-dynamic'`,
no `unsafe-inline`), plus X-Frame-Options/nosniff/Referrer/Permissions-Policy. API keys are server-side
only (read in route handlers; never `NEXT_PUBLIC_`). Imports are type/scheme validated.

See [`ROADMAP.md`](ROADMAP.md) for status of every capability.
