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

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.

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 Phase 6 Session C (deploys / migrations / support / health / spines). Phases 1-5 and Phase 6 (Session A + Session B.1 + Session B.2) shipped to production on 2026-05-03. The Phase 6 design pre-flight (mockups/admin.html) is the source of truth for visual decisions -- do not relitigate them. Phase 7 (user-site UX review) follows Phase 6. The Bug queue (Graph view) and the soft-delete gate (block soft-deleted users from the user-facing app at the middleware layer) are open follow-ups -- ship Session C first unless Edward says otherwise.

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 -- secondary tools.
    • /admin/deploys (Vercel API proxy + git fallback, click-through diff).
    • /admin/migrations (status + SQL diff view, drift banner).
    • /admin/support (inbox, mark-resolved, mailto reply).
    • /admin/health (verify-db, smoke-db, providers + rate-limit trends).
    • /admin/spines (confidence histogram, worst 100, % green trend across deploys).
    • Weekly "Five things" digest cron.

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

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)

Bug queue

Graph view renders blank

/library/graph shows a black panel with no nodes. Cause: components/LibraryGraph.tsx calls the v1 Cosmograph API (new Cosmograph(container, config)) but we're on @cosmograph/cosmograph ^2.3.0, which moved to new Cosmograph({ container, ...config }) and renamed .remove() to .destroy(). The onClick handler signature also changed.

Fix steps:

  1. Read the v2.3 docs to confirm the constructor shape, the data-setter (setData vs setConfig({ data })), and the click-event signature.
  2. Rewrite components/LibraryGraph.tsx to match: container goes inside the config object; replace graph.remove() with graph.destroy(); update onClick call.
  3. Verify in the browser at /library/graph -- nodes visible, click selects a book, the right-hand detail panel populates.
  4. If the v2 API is materially different (e.g. requires a separate React wrapper), consider switching to @cosmograph/react instead of the vanilla package.

Until fixed, the link stays in the More menu and on the book-detail "View as graph" button -- it does not return errors, just renders empty.