Rendered from the repository — the file stays the source of truth.
Session A6 — Demo polish (2026-08-18)
Branch: feat/demo-polish · Spec: roadmap A6 (empty states, error
handling, NF-15 quotas, seeded demo notebook)
Goal and outcome
The webapp reads demo-ready: every screen has a designed empty state,
failures surface visibly, per-user quotas close the NF-15 minimum and
advance SF-11, and bun run seed:demo provisions the demo notebook — which
is also the data-recovery procedure. All shipped; details below.
The lint item was already fixed (record corrected)
The brief assigned A6 the studio lint failure from #28/#29, but PR
#35
(fix/studio-lint) had already landed it and main’s CI has been green
since (verified: bun run lint exits 0 on main; recent ci runs conclude
success). Instead of re-fixing, this session annotated the A7 handover’s
open item — correct the record, not just the code.
What was done
- Empty states (icon + copy + CTA, shadcn/Tailwind only): library
(dashed-border block with its own New-notebook button), sources panel
(Add sources CTA), chat (guidance + one-click example-question chips
when sources are selected), studio (CTA opens the Audio Overview
dialog), notes (designed empty + skeleton rows for the pre-load
null), note dialog (empty-content state with Edit CTA). Plus the A5-deferred viewer state: a citation landing on a still-processing source shows a spinner + explanation, and the existing poll swaps the content in when ready. - Error surfaces:
error.tsx(reset + back-to-library, digest shown),global-error.tsx(self-contained html/body),not-found.tsx(styled 404 —notebooks/[id]’snotFound()lands here now),loading.tsxskeletons for library and workspace. Source/artifact rows print their failure reason as visible text instead of tooltip-only. The inline save-as-note error the brief lists was already shipped by A5 (AssistantActionserror span) — verified, kept. - Per-user quotas (SF-11 / NF-15 minimum) — constants in the owning
services, enforced via repository count queries, no schema changes; the
day window is the current UTC calendar day (
startOfUtcDay,src/server/services/quota.ts):Quota Value Enforcement point Notebooks per user 20 notebook-service.createNotebook→notebookRepository.countByOwnerChat messages per notebook per day (user role only) 50 chat-service.assertChatMessageQuota, called by the chat route before retrieval →conversationRepository.countUserMessagesForNotebookSinceAudio overviews per user per day 10 audio-overview-service.createAudioOverview→artifactRepository.countByOwnerSinceSurfacing: notebook rejection inline under the New-notebook button (client component + ActionResult), chat as HTTP 429 whose body text the AI-SDK transport delivers into the existing dismissible banner (verified E2E), audio through the config dialog’s existing error slot. Limitation, documented in code: regenerations are not counted against the daily audio cap (no generation-event log without a schema change); they stay bounded by the 1-concurrent and 20-per-notebook caps. - Seed script: see below.
- Tests:
src/server/services/quota.test.ts— 11 DB-backed tests (caps hit/free/per-user/per-notebook scoping, role filtering,sinceboundaries). Full suite 154 pass, 0 fail, exit 0 (baseline 143).
Seed = demo opening state = data recovery
apps/webapp/scripts/seed-demo.ts, invoked as
# local stack
SEED_DEMO_USER_EMAIL=<email> bunx varlock run -- bun run seed:demo
# hosted project (remaps the TF_VAR_* values onto the runtime vars)
SEED_TARGET=hosted SEED_DEMO_USER_EMAIL=<email> bunx varlock run -- bun run seed:demo
(from apps/webapp; in a worktree, varlock run --path ../.. --).
- Why runtime-through-services, not
supabase/seed.sql: chunks and embeddings must be real for retrieval and citations to work, and embeddings cannot be generated from SQL. The script goes create →ingestSource(real parse/chunk/embed) →prepareGrounding+generateText(the route’s service path minus streaming) →persistUserMessage/persistAssistantMessagewithbuildCitationInputs→saveMessageAsNote. - Content: three curated excerpts of this repo’s own product docs (scope, architecture, security register) — self-owned, demo-safe.
- Idempotency: the notebook title (“Marginalia — Product Tour”) is the marker; every step re-checks before acting (existing ready source by title is kept, an existing note ends the run). Re-runs are no-ops; half-finished runs resume.
- New env (declared schema-only in
.env.schema):SEED_DEMO_USER_EMAIL(account must exist — sign it up first),SEED_TARGET(localdefault |hosted). - Recovery procedure (Free tier has no backups — this is the plan):
if the hosted project is lost, recreate it (B3/B5 infra path), sign the
demo account up again, run the hosted command above. Verified against
the hosted project on
b3-e2e-user1@example.com: first run seeded the notebook + 3 ready sources then hit a transient provider timeout at the chat step; the re-run resumed (skipped ready sources) and completed — 3 citations, note saved. The resumability story proved itself on a real failure. - NOT yet run for the actual demo account — the foreman/owner should run it against the demo-day account before the demo (the credential is in Proton Pass; the account email choice is the owner’s).
Verified locally
bun testfrom root: 154 pass, 0 fail, exit 0 ·bun run lintexit 0 ·next typegen && tsc --noEmitclean ·bun run buildgreen.- E2E on
supabase start+ real Scaleway (fresh usera6-demo@example.com, headless chromium/puppeteer-core): signup → empty library → empty workspace (all four column states) → pasted-text source → chat empty state with example chips → chip click → streamed answer with citation → chip opens viewer with highlighted passage → SSRF-blocked URL source shows visible row error →seed:demorun + idempotent re-run → demo notebook in library → seeded note’s citation chip resolves to a highlighted passage → notebook quota rejection inline (20/20) → chat quota 429 in the dismissible banner (50 messages seeded via SQL as test data). Screenshots:handovers/assets/2026-08-18-a6-*.jpeg(1–11).
Gotchas / notes for future sessions
- In a worktree,
bunx varlock runneeds--path ../..fromapps/webapp(the.env.localsymlink lives at the worktree root). - The AI-SDK
DefaultChatTransportsurfaces a non-OK response’s body text aserror.message— that is what makes the 429 copy appear in the chat banner verbatim. If the transport is ever swapped, re-verify. - The seed script keeps its content as constants (not file reads) so it survives doc refactors; update the excerpts if the product story changes materially.
- E2E: shadcn Tabs unmount inactive tab content — switch tab first, then
wait for the tab’s inputs. Citation chips are
[data-chunk-id]elements; programmatic.click()avoids the A5 tooltip hit-test quirk. - Local E2E leftovers on the local stack:
a6-demo@example.comwith 20 notebooks (one chat-capped until midnight UTC). Harmless; delete at will.
Hot files / boundary notes
apps/webapp/package.json: added theseed:demoscript only (not a hot file; no dependencies added — the E2E puppeteer-core lives in the session scratchpad, not the repo)..env.schema: SEED_* declarations only, schema-only.product/scope.md(SF-11/NF-15 status + SF-11 table row),product/security.md(SEC-7 mitigation column only), A7 handover annotation — all correct-the-record edits required by the brief.- No toast library added: the existing inline-banner/ActionResult idiom covered every case.
Open questions / next sessions
- Request-RATE limiting (SEC-7) remains open — quotas bound daily volume, not burst rate.
- The audio daily cap counts creations, not regenerations (schema-free compromise; a generation-event log would need a migration).
- URL-addressable source viewer (A3/A5) still open; unchanged here.