Plan -- Shelfie

The thesis

It's 2026. Every other book catalog wants you to scan barcodes or punch in ISBNs one at a time. Shelfie doesn't. You photograph a shelf. Claude reads every spine. We attach a confidence to each detection and color-code the ones worth a glance.

No 1D barcode dance. No 13-digit ISBN typing. Cameras have gotten too good and Claude vision has gotten too smart to keep pretending otherwise. OCR all the way.

The verb is "shelfie." You shelfie a shelf. You shelfied your office last weekend. Use it everywhere -- buttons, copy, marketing.

Architecture

[Phone camera, PWA]
        |
        v
+--------------------+
|  /api/spines       |
|  Claude vision     |
|  -> [{title, author, confidence}]
+--------+-----------+
         |
         v
+--------------------+
|  Review UI         |
|  green / yellow / red bands
|  one-tap fix on yellow + red
+--------+-----------+
         |
         v
+--------------------+
|  /api/enrich       |
|  OpenLibrary -> GB |
|  -> Book metadata  |
+--------+-----------+
         |
         v
+--------------------+        +-----------------+
|  /library          |------->|  Vector store   |
|  cover grid (home) |        |  (lance)        |
|  spines / stacks   |        +-----------------+
|  table view        |
|  search + filters  |
|  subjects + gaps   |
|  graph (optional)  |
+--------------------+

Tech stack

Layer Choice Reason
Framework Next.js 15 + TypeScript + Tailwind PWA-friendly, App Router, Edward's default
Vision Anthropic Claude Sonnet 4.6 (vision) Reads spine text robustly even on tilted, blurry shots
Metadata OpenLibrary REST API (primary), Google Books (fallback) OpenLibrary is free, full metadata; Google Books fills gaps
Embeddings Voyage AI voyage-3 Strong on book and title semantics; cheap
Vector store LanceDB (file-based) Zero-ops; runs from data/library.lance
Graph layout Cosmograph WebGL Handles 10k+ nodes smoothly when someone wants the graph view
Storage SQLite via better-sqlite3 Local-first; no cloud dependency
Caching Anthropic prompt caching on the spine-reading system prompt Same system prompt, many photos
Tests Vitest Fast unit tests for the pure lib/ logic; run with npm test

Data model

type Book = {
  id: string;              // hash(title + author)
  title: string;
  author: string;
  isbn13?: string;
  publishedYear?: number;
  subjects: string[];
  cover_url?: string;
  influences?: string[];   // book ids this book references or argues with
  shelf_id: string;
  source_photo_id: string;
  spine_confidence: number;
  read_status: "unread" | "reading" | "read" | "lent";
  lent_to?: string;
  lent_at?: string;
  embedding?: number[];    // 1024-dim, voyage-3
};

type Photo = {
  id: string;
  uploaded_at: string;
  shelf_label?: string;    // "TTRPG cabinet"
  width: number;
  height: number;
  blob_path: string;
};

type Edge = {
  from: string;
  to: string;
  type: "shared_subject" | "author_overlap" | "explicit_reference" | "co_purchased";
  weight: number;
};

Confidence bands

Each detected spine carries a spine_confidence in 0..1. The review UI groups them into three bands.

Band Range Meaning Border
Green >= 0.85 The system is confident. No action needed. Emerald 500
Yellow 0.60 to 0.85 Probably right. Glance and confirm. Amber 400
Red < 0.60 The system has questions. Two taps to fix. Rose 500

Bands are derived at render time from spine_confidence. Thresholds tunable in lib/confidence.ts.

Spine-reading prompt (system, cached)

You are reading photographs of bookshelves. Each image contains book spines, often at angles, sometimes blurry, sometimes upside-down. Your job: list every visible book.

Output via the emit_spines tool. For each spine: title, author (best guess), and confidence 0..1. Confidence below 0.5 means "I see text but cannot resolve it." Always include those rows so the user can correct them. Confidence above 0.85 means "I am sure of this title and author."

Never invent. If a spine is fully obscured, omit it.

Edward voice: terse JSON only. No prose.

The model gets called with tool_choice: { type: "tool", name: "emit_spines" } and the photo as an image block. Photos are downsampled to 1568px on the long edge before sending. That downscale is what made dense shelves lose books, so the spine reader now crops the full-resolution photo into overlapping strips and reads each one (see Phase 8).

Enrichment pipeline

  1. Look up the spine result (title, author) against OpenLibrary /search.json?q=.... Take the top hit if Levenshtein distance under 4 on title.
  2. If no OpenLibrary hit, fall back to Google Books volumes?q=intitle:"...".
  3. If neither, mark metadata_status: "unknown" and surface for one-tap user correction.
  4. Pull subjects, publication year, ISBN, cover URL.
  5. Compute the embedding once and store in LanceDB.
  6. For "influences," do a second-pass call to Claude with the book's existing description and ask for the three most-referenced or most-argued-against books. Opt-in (slower, costs more).

UX shape

Primary surfaces

  1. /scan -- Camera. One big "Shelfie a shelf" button. Camera or batch upload.
  2. /scan/review/[id] -- The shelf you just shelfied. Cover grid with green / yellow / red outlines. Two taps to fix any yellow or red. Confirm shelf label, save.
  3. /library -- Home after the first scan. Cover grid by default, with toggle to Spines / Stacks / Table.
  4. /library/[id] -- A single book. Cover, full metadata, your shelf location, related books you also own, three suggestions you don't.
  5. /library/subjects -- Topical browse.
  6. /library/gaps -- For each cluster, Claude proposes books you're missing. One safe pick, one ambitious pick, one heretical pick.
  7. /library/stats -- Counts, oldest book, longest-running subject, most-owned author, last shelfie.
  8. /library/graph -- Optional Cosmograph render of the whole library. Bury it behind a button on /library.

The mockup at mockups/library-home.html shows the cover-grid home, the green / yellow / red review screen, the gaps view, and the four library shapes (Grid, Spines, Stacks, Table). Mockup runs off the 37 real books in fixtures/spines/.

Milestones

The order leads with everything needed to render a real, deduped library on screen, because that is the demo. Review (the green/yellow/red UI) is M6 and ships once the home page works -- it is the magic moment but it depends on a believable library to land into.

M1 -- Spine fixtures, dedupe, enrichment (done)

M2 -- Library home, four views (the demo's lead)

M3 -- Subjects and stats

M4 -- Book detail

M5 -- Gaps

M6 -- Review screen (the magic moment)

M7 -- Search

M8 -- Export

M9 -- Graph (optional, behind a button)

M10 -- Status, polish (ongoing)

Hosted multi-tenant build-out

After the local Next.js app worked end-to-end on the deduped 73-book library, the next phase is to host it at shelfiebook.com with multi-tenant accounts, real persistence, public sharing, and security worth trusting.

Stack

Schema

users                 -- Supabase Auth
libraries(id, user_id, name, is_public, slug, created_at)
photos(id, library_id, storage_path, shelf_label, scanned_at, ...)
books(id, library_id, normalized_key, title, author, isbn13, year,
       subjects[], cover_url, metadata_status, spine_confidence,
       read_status, rating, notes, lent_to, lent_at,
       override_origin, deleted_at, ...,
       UNIQUE(library_id, normalized_key))
book_photos(book_id, photo_id)
embeddings(book_id, voyage_v3 vector(1024))   -- pgvector
smart_shelves(id, library_id, name, query_json, position)

Row-Level Security

Security baseline

Migration path

  1. lib/db.ts chokepoint. Behind SHELFIE_DB_MODE, every page reads through it. Today's JSON path stays; Supabase is the new branch.
  2. Auth pages (/login, /signup), middleware gates everything except marketing//, /u/[username], /api/public/*.
  3. First-login migration: read shelfie_overrides_v1 and shelfie_reading_v1 from localStorage, upload to the user's books rows, mark migrated.
  4. Move photos into R2 bucket shelfie-photos/<library_id>/<photo_id>.jpeg. Signed URLs for private libraries; public-read for shared.
  5. Vercel cron re-enriches metadata_status='unknown' books on a 6-hour cadence.
  6. Backups + restore drill before public launch.

Public sharing

Build sequence

The order leads with the data layer because everything else depends on it.

  1. PLAN.md, git init, push to GitHub. Local copy stays canonical.
  2. Supabase chokepoint at lib/db.ts. Dual-mode (local/supabase) so dev does not depend on a network.
  3. Schema + RLS migrations (supabase/migrations/0001_init.sql).
  4. Auth pages and middleware. Reserved usernames blocked.
  5. localStorage → server migration on first login.
  6. Real camera flow. /scan capture → /api/spines (now also emitting per-spine bbox [x,y,w,h]) → /scan/review/[id] (live, not fixture) → /api/enrich. Pixel-true highlight in the review modal.
  7. Bundle release: smart shelves, lent-to, notes, wishlist, reading list. All read/written through lib/db.ts, all rendered through the existing LibraryBrowser filter pipeline.
  8. Public profiles at /u/[username]. ISR.
  9. UX modernization pass: type system (Fraunces+Inter), color tokens, dark mode, a11y baseline, keyboard shortcuts, URL-driven filters, toast+undo, mobile drawer, lucide icons.
  10. Vercel deploy + shelfiebook.com domain attach + Supabase site_url.
  11. Voyage embeddings into pgvector; "Books like this" on detail page.
  12. Re-shelfie diff (added/removed/moved). /library/me Claude profile of subject distribution against an aggregate baseline.
  13. Backups, soft-delete, restore drill in RUNBOOK.md.

Steps 1-5 unblock everything else and ship in the first sprint. 6-9 are the visible product. 10-13 are launch-grade polish.

Hardest problem

Spine reading at scale, with confidence the user can act on. A typical bookshelf photo has 25 to 50 books. Half the spines are partially obscured, tilted, or printed in decorative typography that confuses OCR. Claude vision is the best general option; the failure mode is the model confidently inventing plausible-sounding titles that don't exist.

The fix is twofold. First, structured output via tool use forces the model to attach a confidence to every entry, with explicit instructions that "I see text but can't resolve it" is an acceptable answer. Second, the enrichment pipeline only treats a spine as "known" once it matches a real OpenLibrary or Google Books record. Anything that doesn't match goes into the yellow or red queue for two-tap correction. The user's feedback teaches the system about edge fonts (dot-matrix Penguin Classics covers, decorative D&D hardcovers) and gradually tightens accuracy.

The green / yellow / red bands expose this cleanly. The user knows what to ignore and what to fix at a glance. Edward's two real shelves came back 30 green, 5 yellow, 2 red across 37 books, which is a representative ratio.

Risks

  1. Cover-only books with no spine text. The photo of the front cover is the only signal. Mitigation: per-book "tap to retake" lets the user shoot the cover instead.
  2. Cost. A house with 1500 books at ~50 spines per photo = 30 photos at maybe 2k tokens each. About $0.20 to shelfie a whole house. Real-world: maybe ten times that with retries. Still cheap.
  3. Privacy. Photos of bookshelves leak political and religious info. Mitigation: an "encrypt at rest" mode that runs the embeddings client-side and stores everything in a local SQLite. Skip cloud sync entirely.
  4. OpenLibrary blanks for niche books. Mitigation: Google Books is a strong fallback. If both fail, the book is still indexed with whatever Claude saw on the spine, flagged red until the user fixes it. Edward's TTRPG indie titles (Lairs & Legends, Secret Art series) hit this case in testing; the user-correction flow handled them in two taps each.

Out of scope (v1)

Post-launch roadmap (Phases 1-2 done, 2026-04-30)

Resume here on next session

When Edward says "read and execute PLAN.md," the next work is the top of docs/improvement-backlog.md (written during Phase 9, prioritized into six tiers). Tier 1 starts with the soft-delete gate (block soft-deleted users at the middleware layer), then wiring semantic search into the search bar. Phases 1-9 have shipped to production: Phases 1-5 and Phase 6 on 2026-05-03, Phase 7 on 2026-05-14, Phase 8 on 2026-06-04, Phase 9 on 2026-06-12. Phase 9 closed all three Phase 7 follow-ups (secondary-surface dark-mode sweep, amber discipline, the Tab focus trap) and the Graph view bug. The Phase 6 design pre-flight (mockups/admin.html) and the Phase 7 baseline plus punch-list (mockups/user-app-current.html, docs/user-ux-punchlist.md) are the sources of truth for visual decisions -- do not relitigate them.

Standing rules every session must follow (also in memory):

Phase 1 -- Multi-media classification (DONE, 2026-04-29)

books.media_type column. Spine reader classifies each item as book / boardgame / video_game / dvd / cd / vinyl / comic / other. LiveReview lets the user fix the type per spine. Library has a Type filter and a small uppercase pill on non-book grid cards.

Phase 2 -- Collector mode (DONE, 2026-04-30)

Migration 0008_collector.sql shipped: books.collector_meta jsonb, book_copies table with RLS, profiles.collector_mode boolean. Per-user Collector mode toggle on Settings, surfaced only on Pro (Collector tier). BookEditor reveals condition / edition / printing / signed / dust jacket / asking + acquired price / notes when collector mode is on. BookDetailView has an "I have another copy" panel that does CRUD against /api/books/[id]/copies. Library has "Multiple copies" and "Signed" chip filters, hidden unless collector mode is on. API: /api/books/[id] PATCH accepts collector_meta; /api/books/[id]/copies for copy CRUD. Both Pro-gated server-side.

Phase 3 -- Multi-media enrichment adapters (DONE, 2026-04-30)

lib/enrich-providers/ ships with five files: cache.ts (24h in-process Map cache + ProviderResult shape), bgg.ts (BoardGameGeek XML2, keyless), discogs.ts (vinyl + CD with format filter), igdb.ts (Twitch OAuth + IGDB v4, token cached in module memory), tmdb.ts (movies + TV, picks the higher-popularity hit, genre IDs translated via cached lookup tables), index.ts (lookupByMediaType dispatcher + liveProviders() health check). lib/enrich.ts exposes lookupBook(title, author) for the OL/GB fallback; enrichSpine now branches by media_type and short-circuits non-book/non-comic when nonBookEnabled is false. /api/suggest accepts media_type= query param, gates non-book/non-comic suggestions behind currentPlan() !== 'free', returns empty for free users on non-book queries (no error). LiveReview.enrichInBackground threads media_type per spine. Missing keys silently degrade to metadata_status='unknown'. Env vars for production: DISCOGS_TOKEN, IGDB_CLIENT_ID, IGDB_CLIENT_SECRET, TMDB_API_KEY -- need provisioning by Edward; until set, only OpenLibrary + BGG run live.

Phase 3 (original brief, kept for reference)

Phase 1 classifies items but only books get covers and metadata fetched. Phase 3 adds per-type providers so a board game, vinyl, video game, or DVD picks up a real cover and subjects.

Adapters (one file each, all exporting the same shape):

Each adapter exports the same signature: lookup(title: string, author: string): Promise<{ cover_url?: string; year?: number; subjects?: string[]; isbn?: string } | null>. Errors return null and log; never throw to callers.

Wiring:

Env vars to add via vercel env add ... production:

DISCOGS_TOKEN
IGDB_CLIENT_ID
IGDB_CLIENT_SECRET
TMDB_API_KEY

If a key is missing in production, the adapter returns null and the item stays at metadata_status='unknown' rather than failing the request. Add a one-line health check in the README's Status section listing which providers are live.

Caching: wrap each adapter's HTTP call in the existing 24-hour fetch cache (lib/enrich.ts has the helper). Identical title+author lookups should not re-hit the upstream API.

Out of scope for Phase 3: import from collector services (LibraryThing, BoxedCollector). Cross-provider deduplication (a vinyl on Discogs that's also a CD on Discogs is two rows). Audio fingerprinting.

Phase 4 -- Shared libraries (TWO sessions)

Edward's policy: every member must have a paid tier; quota is the largest member's quota (not summed); a "Household Library" tier comes later.

Session A -- schema + queries (DONE, 2026-05-02)

Migration 0009_sharing.sql shipped: library_members(library_id, user_id, role check in ('owner','editor'), joined_at), book_user_state(book_id, user_id, read_status, rating, notes, lent_to, lent_at, wishlist), owner backfill, RLS helpers rewritten (visible_library_ids / owned_library_ids pivot through library_members; new admin_library_ids for owner-only ops), on_library_created trigger seeds the owner row, libraries_update/libraries_delete policies now gate on admin_library_ids. Server pivot done in lib/current-library.ts, lib/api-key.ts, app/api/books/route.ts, app/api/scans/route.ts, app/api/demo-shelfie/route.ts, app/api/migrate-local/route.ts. app/api/books/[id] PATCH splits per-user fields (read_status, rating, notes, lent_to, lent_at, wishlist) into book_user_state upserts; shared fields stay on books. app/api/user-state GET returns the user's state; components/UserStateHydrator.tsx (mounted in Providers) seeds shelfie_reading_v1 and shelfie_book_meta_v1 localStorage stores from server truth on first load so reading hooks reflect book_user_state. A follow-up migration drops the moved columns from books after Session B ships.

Session B -- invite flow + plan policy (DONE, 2026-05-02)

Migration 0010_sharing_invites.sql shipped: library_invites(id, library_id, email, role, token, invited_by, invited_at, expires_at, accepted_at, accepted_by) with a partial unique index on (library_id, lower(email)) WHERE accepted_at IS NULL and admin-only RLS. lib/sharing.ts exposes mintInviteToken, memberPlans, effectiveQuota, checkWriteAllowance (shared library writes blocked when no member is paid; insert blocked over max(member.books_quota)), and sendInviteEmail (Resend, no-op without RESEND_API_KEY). Routes: /api/sharing GET (members + pending invites + quota) and POST (mint token, email link), /api/sharing/invites/[id] DELETE (revoke), /api/sharing/members/[userId] DELETE (remove or self-leave with last-owner protection), /api/sharing/accept POST (consume token + add member with paid-member check). /auth/accept-share?token=... page handles the redirect-through-login flow via the existing magic-link ?next= plumbing. SettingsForm gained a Sharing section listing members, pending invites, role + email invite form (gated on Bibliophile/Collector). /api/quota and QuotaIndicator now report shared quota with a "shared · upgrade required" pill when no member is paid. /api/books and /api/scans call checkWriteAllowance before insert. Settings page reads libraries through library_members so shared libraries appear there too. Env: RESEND_API_KEY, RESEND_FROM_EMAIL, NEXT_PUBLIC_SITE_URL already provisioned (used by other email flows).

Phase 5 -- Marketing landing pages (DONE, 2026-05-03)

Four audience pages live: /for/board-gamers, /for/collectors, /for/audiophiles, /for/film-collectors. Single dynamic route at app/for/[audience]/page.tsx with generateStaticParams for SSG and per-audience generateMetadata (title, description, canonical, OpenGraph, Twitter card). Audience copy + cover walls live in lib/audiences.ts (one record per audience: pageTitle, pageDescription, hero h1/sub, three stats, three steps, pull-quote with bullets, five FAQs, twelve cover-wall items). Layout reuses the homepage hero + 3-step + pricing + final CTA shape. Voice pass applied: every em-dash-shaped -- removed from rendered prose, parenthetical asides added per section, "Short answer: yes/no" in FAQs, longer hero subs tightened. Sitemap added at app/sitemap.ts (homepage, marketing pages, the four audience pages, with audience pages at priority 0.8). Homepage gets a four-pill discoverability strip ("Not just books") above the footer; footer nav lists all four audience pages on every audience page and on home.

Phase 6 -- Admin interface

This is for Edward (and any future co-admins), not end users. The goal is to keep Shelfie supportable: see what is happening, find a user fast, fix data when something breaks, and watch the deploy/release stream without leaving the app.

Scope priority. Build the dashboard and the user table first; everything else slots in after. The whole admin lives at /admin with a sidebar and is gated by an is_admin flag on profiles. No mention of "admin" appears anywhere in the user-facing UI.

Access control

/admin -- dashboard (the at-a-glance home)

A single dense page that is the right answer to "is anything weird?" at a glance.

Design pre-flight (done, 2026-05-03). Mockup at mockups/admin.html covers the Dashboard, Users table, and single-user drill-in. Reviewed via the frontend-design skill against Stripe / Linear / Vercel. The findings below are baked into the spec; do not reopen them in implementation.

/admin/users -- the user table

The core support tool. One row per user, server-rendered, sortable, filterable, paginated. Columns:

Filters above the table:

Search is one input across email + username + display_name + library name. Hits Supabase via a single SQL function with pg_trgm for fuzzy matching.

Table-level affordances (all decided in pre-flight):

Click a row to drill into /admin/users/[id]:

/admin/admins -- admin roster

/admin/deploys -- release log

The "what just changed?" feed. Pulled live from Vercel + git so Edward can see, on one page, what shipped and when.

/admin/migrations -- schema log

/admin/support -- inbox

/admin/health -- ops checks

Single page that runs the existing verify-db / smoke-db checks on demand and reports pass/fail with timing. Add a "Provider status" tile that runs the liveProviders() health check from lib/enrich-providers/index.ts so Edward can see at a glance whether DISCOGS / IGDB / TMDB are responding.

/admin/spines -- model performance

For tuning the spine reader. Only visible to admins.

/admin/audit -- audit log

Read-only table of admin_audit_log, sortable by actor, action, time. Filter by actor or by target user. Every destructive admin action lands here.

Design system for the admin (decisions from pre-flight)

The admin uses the same warm-stone + amber palette as the user app, but commits more aggressively. Defaults to dial in on day one:

UX principles for the admin

Required loading, empty, and edge-case states

Every screen ships with these states designed (not "we'll add them later"):

Things that need to exist that the mockup did not show

Tracked here so they ship in the right session, not retrofit later.

Build sequence (sessions)

  1. Session A -- foundations and the dashboard. (DONE, 2026-05-03) Migration 0011_admin.sql shipped (profiles.is_admin + admin_granted_by + admin_granted_at, admin_audit_log with append-only RLS, admin_saved_views, admin_user_notes, admin_pinned_users; Edward backfilled via a do-block). lib/admin.ts exposes requireAdmin() (notFound on non-admin, redirect on unauth), currentAdmin(), isAdminUser(), logAdminAction(), undoToken(). Middleware short-circuits /admin and /api/admin to /404 for non-admins. The shell at /admin ships with an inverted-ink left sidebar (#22201d), Lucide iconography, a custom shelfie-spine SVG, dark + light parity, and a topbar that opens cmdk. The dashboard renders a bare KPI strip (total users, signups 7d, books today/week, active 24h/7d, MRR placeholder), plan-mix bar (Reader/Bibliophile/Collector), "Five things to look at" with severity-coded left-edge bars, two recharts time series (signups area, books bars over 30d), a "What just shipped" rail mixing audit + recent shelfies, and a time-of-day greeting. /admin/admins lists is_admin profiles, supports add-by-email + revoke (typed dialog + undo toast). /api/admin/admins POST + DELETE write audit. /admin/audit is the read-only log with searchParams-driven actor + action filters and target deeplinks. Command palette (cmdk), ? shortcuts overlay, and g d / g a / g u / g m sequential shortcuts wired in AdminShell. Recharts added to deps. Bumped to 0.7.0.

  2. Session B -- user table and drill-in. Split into B.1 + B.2 because the action surface deserves its own review.

    • Session B.1 (DONE, 2026-05-03). Read-only user surfaces. /admin/users ships with lib/admin-users.ts driving the queries (profile join with auth-admin emails, books_count + library_count + last_active aggregated from library_members + books, 7-day spark per user). The page renders with searchParams-driven plan / activity-bucket filters, plain-text filter tabs (UsersFilterTabs), live fuzzy typeahead (UserSearchInput -> /api/admin/users/search), virtualized first-page server render plus IntersectionObserver-driven infinite scroll fetching /api/admin/users, plan-tinted avatars (PlanAvatar: stone / amber / ink for Reader / Bibliophile / Collector), PlanPill, right-aligned numerics (tabular-nums), inline-SVG sparklines (Sparkline), j/k row navigation with row-into-view scroll, x to toggle select, sticky bulk action bar (counts + disabled Email/Export/Tag for B.2), saved views (SavedViewsBar -> /api/admin/saved-views, backed by admin_saved_views). /admin/users/[id] drill-in shows the same row data large, KeyStats bar, libraries grid with covers-as-avatars (LibraryCard, top 4 covers in 2x2), activity timeline (ActivityTimeline: books + audit + invites + memberships), internal notes (UserNotes -> /api/admin/users/[id]/notes, audit-logged), Stripe deeplink, and a stubbed action surface (UserActions) showing the B.2 menu disabled. Sidebar Users row enabled. Command palette + g u shortcut + ? overlay updated. Bumped to 0.8.0.
    • Session B.2 (DONE, 2026-05-03). Migration 0012_admin_actions.sql shipped: profiles.soft_deleted_at + soft_deleted_by, plan_grant_expires_at + plan_grant_original_plan + plan_grant_original_quota + plan_granted_by, and the admin_impersonations audit table (admin-readable, service-role write only). lib/impersonate.ts mints + verifies an HMAC-signed cookie payload ({adminId, targetId, exp}, 1h TTL) keyed off IMPERSONATION_SECRET (or CRON_SECRET / service role as fallback). <ImpersonationBanner /> server component mounted in the root layout shows a sticky rose-600 bar across every user-facing page with End session (<EndImpersonationButton /> -> DELETE /api/admin/impersonate). Action surface lives in <UserActions user={user} /> (rewritten from B.1 stub): magic-link (clipboard copy of the generated auth.admin.generateLink action_link), 30-day Bibliophile grant (snapshots original plan + quota; reverts via Settings or cron), revert-grant, refund-last-invoice (Stripe charges.list + refunds.create), soft-delete + restore, promote / revoke admin. Destructive actions go through <TypedConfirmDialog> (IMPERSONATE / REFUND / DELETE phrases). One unified /api/admin/users/[id]/actions route dispatches each action; every successful action writes admin_audit_log. The drill-in surfaces soft-delete + grant banners in the page header. The drip cron (/api/cron/drip) gained a revertExpiredGrants pre-pass so granted plans auto-revert. Bumped to 0.9.0. New env var to set in production for cookie HMAC stability across deploys: IMPERSONATION_SECRET (random 32-byte hex). Falls back to CRON_SECRET / service-role key, but a dedicated value is cleaner.
  3. Session C (DONE, 2026-05-03). Five secondary surfaces shipped behind the same admin shell.

    • /admin/deploys: lib/admin-deploys.ts queries Vercel v6/deployments (auth via VERCEL_TOKEN + VERCEL_PROJECT_ID, optional VERCEL_TEAM_ID); falls back to git log -50 --pretty when the API is unreachable. Production / preview tab via ?scope=. Source row noted on the page so it's clear which path served the data.
    • /admin/migrations: lib/admin-migrations.ts joins the _migrations table against supabase/migrations/*.sql (read at runtime via outputFileTracingIncludes in next.config.ts). Status pills (applied / pending / missing), SHA-12, applied_at, byte size. Red drift banner above the table when any pending row exists. /admin/migrations/[version] shows the SQL preview in a mono pre block.
    • /admin/support: lib/admin-support.ts reads support_tickets. Plain-text status tabs with counts (open / in_progress / resolved / closed / all). <TicketRow> does mailto Reply (subject prefixed with "Re:", body quoted) and Resolve / Reopen via PATCH /api/admin/tickets/[id] (audit-logged).
    • /admin/health: lib/admin-health.ts runs eight cheap checks on demand (db connect, core tables visible, profile count, auth admin reachable, Stripe / Resend / Cron envs present, liveProviders() enumeration). Each row shows status dot + detail + ms timing. Pass / degrade / fail counters at the top.
    • /admin/spines: lib/admin-spines.ts aggregates books.spine_confidence over the last 30 days into a 10-bucket histogram with green / yellow / red coloring (>=0.85 / >=0.6 / else). KPI strip shows total reads + green/yellow/red percentages. Worst 100 lowest-confidence rows render with cover thumbnail, confidence, and override_origin, deeplinking to /library/[id].
    • Sidebar "Operations" group (Deploys / Migrations / Support / Health / Spines) enabled. Command palette gained entries for each. Bumped to 0.10.0. New env vars to consider for production: VERCEL_TOKEN + VERCEL_PROJECT_ID (+ VERCEL_TEAM_ID if applicable) so /admin/deploys uses the Vercel API rather than the git-log fallback.

    Weekly "Five things" digest cron is the open Session C follow-up; not blocking.

Skip nothing in Session A: foundations, palette, dark mode, command palette, undo, skeleton states, audit. Sessions B and C can interleave with other work.

Out of scope (v1)

Phase 7 -- User-site UX review (DONE, 2026-05-14)

Shipped. Step 1 captured all 14 primary user-facing surfaces into mockups/user-app-current.html (light + dark, faithful to production's color strategy). Step 2 produced docs/user-ux-punchlist.md via the frontend-design rubric: 8 must-fix, ~20 polish, 6 defer, each tagged with its owning pass. Step 3 ran all five implementation passes, each committed and deployed on its own:

Step 4 regression ran per pass: npm run typecheck clean, all changed routes 200, key surfaces verified in the browser in dark mode.

Follow-ups discovered during Phase 7 (all three closed by Phase 9, 2026-06-12):

The original Step 1-4 spec is kept below for the record.

Once the admin ships and stabilizes, take the same frontend-design skill that critiqued the admin and turn it on the rest of the app: home, marketing pages, library views, scan flow, review screen, settings. The admin pre-flight forced a design conversation that improved the spec; the user-facing app deserves the same audit before we start swinging at "more features."

Out-of-scope for Phase 7: rewriting the brand. The voice, palette, and Fraunces-Inter pair stay. This phase polishes the existing surface, doesn't restart it.

Step 1 -- Capture state (one session)

Step 2 -- Critique pass

Step 3 -- Implementation passes

Each of these is its own session. Do not bundle.

Step 4 -- Regression catch

After each pass: re-screenshot the surfaces, diff against the captured baseline, run npm run typecheck and the existing smoke tests. The user-site review must not break the existing UI -- the keep-UI-simple rule still applies.

Out of scope (Phase 7)

Phase 8 -- Spine-recall tiling (DONE, 2026-06-04)

Edward flagged the real failure mode: the whole-image read drops books off the list entirely. A wrong guess is cheap (two taps to fix). A book the system never reports is invisible, and the user has no way to know it was skipped. So recall is the metric that matters.

Root cause: a 4032px shelf photo gets downscaled to 1568px on the long edge before Claude sees it. Pack 20-plus books into that frame and each spine lands 30-50px wide. The text blurs, and the model satisfices -- it emits a believable-length list, then stops.

The fix is overlapping vertical strips. Cut the full-resolution photo into a few overlapping columns, read each at native resolution with fewer neighbors, then union and dedupe. Fewer books per crop means bigger spines and near-complete recall.

What shipped in lib/claude.ts:

Env vars (optional):

New dependency: sharp ^0.34.5 for server-side cropping (vips under the hood).

Validation. Ground truth for both shelves came from reading fine overlapping tiles, then hand-checking against full-resolution crops. It lives at fixtures/eval/IMG_6393.truth.json (22 books) and fixtures/eval/IMG_6394.truth.json (18 books). The harness at scripts/spine-eval.ts runs the production readSpines path with tiling toggled on and off, so the comparison is apples to apples. Run it with npx tsx scripts/spine-eval.ts eval IMG_6394.

Shelf Whole-image two-pass (tiling off) Tiling fix
IMG_6394 (18 books) 15/18 recall, 7 extras 18/18 (100%), 4 extras
IMG_6393 (22 books) 21/22 recall, 3 extras 21/22 (95%), 2 extras

The fix recovers three books the whole-image read dropped on the fiction shelf (The Land: Founding, The Dungeon Anarchist's Cookbook, Dungeon Crawler Carl) and trims phantom extras at the same time. The leftover extras are author fragments and header text, the two-taps-to-fix kind.

The one stubborn miss (Marvel Rivals Timestream Adventure on the TTRPG shelf) is the thinnest spine in a run of six near-identical Marvel titles. Every approach reads it as the bare series root, so it lands detected but without its subtitle.

Open follow-ups:

Phase 9 -- Codebase-wide review (DONE, 2026-06-12)

A multi-agent review swept the whole codebase across six dimensions (dark mode, UX design, client perf, server perf, code quality, accessibility), adversarially verified every finding against the code, then implemented the confirmed set. 58 findings confirmed and shipped, version 0.13.0. Highlights:

What did not ship got logged: docs/improvement-backlog.md holds the prioritized list (six tiers, soft-delete gate first). The low-severity polish list and the full review digests live with the session, summarized into the backlog's Tier 6.

Bug queue

Graph view renders blank (FIXED, Phase 9, 2026-06-12)

components/LibraryGraph.tsx now targets the Cosmograph v2.3 API: points/links ride in the constructor config (pointIdBy, linkSourceBy, plus the index columns the runtime validator demands despite optional typings), selection uses selectPointOnClick with a DOM listener that resolves ids via getPointIdsByIndices, cleanup calls destroy(). Node size follows degree, so hub books render larger. The page-level computeEdges collapses duplicate pairs into one weighted edge. Render verified in the browser (73 points, 129 links, labels).

Traps for whoever touches this next: