For Agents

The new txarchive-frontend — a fresh modern React SPA replacing the stock-shadcn one, talking Supabase directly (see txArchive Supabase Contract). Source: §7–§8 of txarchive_2025/docs/txarchive-2025-unified-design.md; the as-built reference + parity checklist is the frontend spec (txarchive-frontend/docs/frontend-modernization-spec.md) — but that spec’s §8 “backend API contract” is OBSOLETE (superseded by the contract note). Parent: txArchive 2025 Rewrite.

Stack

ConcernChoiceReplaces
BuildVite 5the old Vite + the manual index.html SW registration
FrameworkReact 18 + TypeScript (strict)React (loose TS)
RoutingTanStack Router — typed routes, nested layouts, type-safe params/search, route guards, modal routeswouter (+ history.pushState, the ?reload=1 dance, no real 404)
Server stateTanStack Query v5 — cache, useInfiniteQuery, optimistic mutationsSWR + the Recoil async selectors + all three fetch wrappers (TxArchiveApiClient, useAuthFetcher, noAuthFetcher)
Data client@supabase/supabase-js v2 — the one typed clientTxArchiveApiClient + the fetchers + react-use-websocket
UI kitTailwind CSS 3 + shadcn/ui (kept — modern; cn(), Radix primitives)the stock shadcn “default” + slate scaffold (now with a real design language)
UI-only stateZustand — theme, mobile-drawer open, active modalthe Recoil atoms (Recoil was a DI hack — the REST client lived in an atom)
PWAvite-plugin-pwa (registerType:'autoUpdate') — manifest + SWthe old plugin + the manual SW registration
Drag-dropreact-dropzone (kept, for /upload)
Iconslucide-react (one set)react-icons (dropped)
Skeletonsshadcn <Skeleton> / react-loading-skeleton (actually used this time)the imported-but-unused skeletons
ToolingPrettier + ESLint + prettier-plugin-tailwindcss; Vitest (unit) + Playwright (E2E)
Package managerpnpm or yarn (yarn-classic today; pnpm is fine)

Gone from the frontend: Recoil + the Recoil-as-DI pattern · wouter · SWR + the three fetch wrappers · react-use-websocket + the custom WS interest protocol · the two comment subsystems (one dead) · the per-page <Nav> · the dangling /users link (now a real /members) · the ?reload=1 dance · setAuth-during-render · the JS-readable auth cookie · RecoilizeDebugger in prod builds · the 150-day JWT · the JSON-blob-in-redirect-query handshake · the hard-coded 800 KB react-dropzone cap (→ the env) · the conflicting brand-colour metadata (#bbf7d0 theme-color vs #ffffff PWA vs #da532c tile vs dark-slate --primary → one brand colour) · the ~21 MB of uncompressed PNGs (re-export optimized).

Directory structure (new src/)

src/
  main.tsx                  # createRoot → QueryClientProvider > ThemeProvider > RouterProvider; <Toaster/>
  routes/                   # TanStack Router route tree
    __root.tsx              # the doc-level shell (head, Toaster)
    _public/                # chrome-less: index (Landing), auth.callback, error, $404
    _app/                   # the authenticated layout (Nav + main + MobileDrawer)
      timeline.tsx · post.$id.tsx · upload.tsx · my-uploads.tsx · approvals.tsx
      members.tsx · enroll.tsx · search.tsx
    yorick.tsx              # its own dark shell, outside _app
  lib/
    supabase.ts             # the client singleton + generated Database types
    auth.ts                 # useSession(), useScopes(), requireScope() guard, the complete-login flow
    cdn.ts                  # fileUrl(file_key), avatarUrl(profile)
    queries/                # useTimeline, usePost, useComments, useMyUploads, useApprovals, useApprovalCount, useMembers, useEnrollments, useSearch
    mutations/              # useToggleLike, usePostComment, useDeleteComment, useUpload, useDeleteUpload, useApprove, useDeny, useEnroll, useSetScopes, useCancelEnrollment
    realtime/               # useRealtimeTimeline, useRealtimePostDetail, useRealtimeApprovals (subscribe a channel; patch the query cache)
    format.ts               # relative/absolute dates (user locale), formatBytes (binary)
  components/
    layout/                 # AppShell, Nav, MobileDrawer, UserMenu, ThemeToggle, NavSearch
    post/                   # PostCard, PostDetail, Lightbox, ShareButton
    comments/               # CommentList, CommentItem, CommentComposer
    upload/                 # UploadDropzone, FilePreview, UploadGrid, UploadCard, StatusPill
    common/                 # EmptyState, ErrorState, PageSkeleton, ScopeGate, ConfirmDialog
    ui/                     # shadcn primitives (only the ones used)
  features/yorick/          # YorickPage, VideoModal, yorick-catalog.json
  styles/index.css          # Tailwind + the design tokens (:root light, .dark dark)
  pwa/                      # manifest source, icons (incl. the SVG favicon)
  types/                    # re-exports of Database row types + the view-row types from the contract

Routing

/ (logged-out → Landing; logged-in → redirect /timeline) · /auth/callback (runs complete-login, then → /timeline or → /error for not_in_guild) · /error (the friendly no_access/not_in_guild screen — the cdn.txarchive.org/no_access.png meme optional) · /timeline · /post/:id (post detail — a modal over the timeline when navigated from the feed, a full page on direct load / refresh; deep-linkable; shareable; replaces /timeline?postId=) · /upload (gate ADD) · /my-uploads (the user’s gallery) · /approvals (gate APPROVE) · /members (gate ENROLLDELETE — NEW, implements the dangling /users link) · /enroll (gate ENROLL) · /search (gate VIEW — NEW) · /yorick (any logged-in user; its own dark shell) · * → a real 404 page (not a silent redirect). Auth gating = TanStack Router beforeLoad guards: no session → redirect /; missing required scope → a “you don’t have access to this page” screen (not a redirect). The Nav also shows/hides links by scope (cosmetic) — but the routes are guarded regardless.

App shell

ONE <AppShell> rendered by the _app layout route: <Nav> (rendered once — its state lives in TanStack Query / Zustand, so it survives navigation; fixes today’s state-resets-on-navigation) + <main> + <Toaster> + <MobileDrawer>. Chrome-less routes (/, /auth/callback, /error, the 404) use a bare layout; /yorick uses its own dark layout. The <Nav>: the SVG logo/wordmark (→ /timeline); the scope-gated links (active-route highlighted properly — an underline/pill, not just font-bold); the Approvals link with a live count <Badge> when ≠ 0; a <NavSearch> (input → /search?q=); a <ThemeToggle> (light/dark/system); a <UserMenu> (Radix DropdownMenu — avatar + username → “Members” (if scoped) · “Theme” · “Log out”; click-outside + Esc close — fixing the old hand-rolled dropdown). Below lg the links collapse to a hamburger → <MobileDrawer> (Radix Dialog/Sheeta scrim; close on backdrop / Esc / navigate; body-scroll-lock; the same links + the user-menu items inside).

Data layer

Query keys: ['timeline', { excludeOwn }] · ['post', id] · ['comments', uploadId] · ['my-uploads'] · ['approvals'] · ['approval-count'] · ['members'] · ['enrollments'] · ['search', q]. useInfiniteQuery for timeline / comments / search (page param = the .range() offset; page sizes: timeline 25, comments 50, my-uploads 10, approvals 20). Mutations are optimistic where it matters: useToggleLike (flip liked_by_me and ±1 like_count in every cached page that has the post; roll back on error); usePostComment (prepend a temp CommentRow with a client id to ['comments', uploadId] page 0; on success replace it with the returned row; the real row also arrives via Realtime — dedupe by id); useApprove/useDeny/useDeleteUpload (optimistically remove from the list; invalidate ['approval-count']; useApprove also invalidates ['timeline']). Errors → a toast (+ inline for forms). Session lost (onAuthStateChangeSIGNED_OUT, or a query error indicating an expired/invalid JWT) → clear the query cache + route to /.

Realtime integration

A hook per surface (lib/realtime/): on mount, supabase.channel(...).on('postgres_changes', <filters>, handler); the handler patches the TanStack Query cache — timeline: APPROVED INSERT → prepend to ['timeline'] page 0; DELETE or UPDATE away-from-APPROVED → remove; comments: INSERT → add to ['comments', id] with dedupe-by-id, DELETE → remove; likes: INSERT/DELETE → ±1 like_count, set liked_by_me if it’s the current user. On unmount, supabase.removeChannel(...). This replaces addInterest / clearInterest / the interest map entirely — Realtime authenticates via the user’s JWT and RLS-filters automatically, so the old “no token on the WS” bug is gone.

Theming

CSS variables (:root light + .dark) for the shadcn token set, with a real brand colour (decided in Phase 0) on --primary. <ThemeProvider> sets document.documentElement.classList to light/dark from the Zustand theme state (systemmatchMedia('(prefers-color-scheme: dark)') + a listener); persisted in localStorage. One <meta name="theme-color"> updated to match the active theme. Typography: a web font picked in Phase 0 (e.g. Inter via @fontsource) — or the system stack; Phase 0 decides.

Error / loading / empty conventions

Every async surface: (a) a skeleton while loading (real shimmer, never <p>Loading…</p>); (b) an empty state (“No posts yet” / “No comments yet” / “Nothing awaiting approval 🎉” / “No results for ‘{q}’”) — icon + one-liner + (where relevant) a CTA; (c) an error state — a card with a friendly message + a Retry button (never a raw JSON.stringify). A top-level error boundary catches the rest. Document titles: useDocumentTitle(t)"{t} · txArchive", reactive, on every route. OG/meta: sensible defaults in index.html; per-route overrides where useful.

PWA

vite-plugin-pwa (registerType:'autoUpdate'): manifest name/short_name txArchive, the tagline as description, display:standalone, start_url:/, scope:/, one theme_color/background_color (the brand colour), the icon set (android-chrome-192 maskable + android-chrome-512 + apple-touch-icon + favicon.ico + favicon.svg (finally provide it — everything references it) + mstile-150x150 (provide it or drop browserconfig.xml)). Drop the manual index.html SW registration (the plugin owns it). Precache the app shell + the logo SVG; runtime-cache cdn.txarchive.org images (CacheFirst or StaleWhileRevalidate + an entry/age cap); do not precache the Yorick posters (re-export them as optimized WebP/AVIF, loading="lazy").

Rebuilt feature set (§8 of the design)

Each: (parity) what must still be true · (rebuild) how it’s done now · (drops) what debt disappears.

  • 8.1 Auth & onboarding/ Landing: the SVG logo, one “Continue with Discord” button → signInWithOAuth({provider:'discord', options:{redirectTo:'<origin>/auth/callback', scopes:'identify guilds guilds.join'}}), the legal disclaimer (verbatim), the tagline. → Discord → /auth/callback: a spinner → call complete-login (bearer = the new session JWT; body {provider_token}) → on success briefly show “Welcome, {username}” + the granted-scopes list (icons: VIEW→eye · ADD→upload-cloud · DELETE/APPROVE→shield-check · ENROLL→user-plus · ESCALATE→shield-alert) → auto-route /timeline; on 403 not_in_guildsignOut()/error. (drops) the ?reload=1 dance; setAuth-during-render; the JS-readable cookie; the 150-day JWT; the JSON-blob-in-query handshake.
  • 8.2 Timeline (/timeline) — an infinite-scrolling responsive card grid of <PostCard> (page 25, useInfiniteQuery over from('timeline').range()); a small toolbar with an “Exclude my own posts” <Switch> (toggles .neq('uploader', uid) → its own query key); useRealtimeTimeline() patches the cache; click a card → navigate /post/:id (modal-over-timeline from here; full page on direct load); each card lazy-loads its image (loading="lazy" + a skeleton/blur placeholder + an alt of the filename or OCR description). Default post-login page.
  • 8.3 PostCard — image (lazy, object-contain, theme-aware bg, click → /post/:id); a metadata row (relative date · size · uploader avatar+name); an action row — Like (optimistic heart, aria-pressed, count) · Comments (count → /post/:id) · Share (copies https://txarchive.org/post/<id> + a toast). (drops) the inline CommentForm on the card — composing moves into /post/:id.
  • 8.4 Post detail (/post/:id) (merges the old CommentsViewer) — a route, modal over the timeline from the feed / full page on direct load: the big image (a <Lightbox>-style zoom/pan view; alt text), a comments panel — author header + Like + Share; a <CommentComposer> (textarea, 1–2000 chars, char counter, Enter to send / Shift+Enter newline, optimistic prepend, error toast, “Sending…” state); the comment list (useInfiniteQuery, page 50, newest-first, “Load more”; each <CommentItem>: avatar + name + relative timestamp + body + a Delete button when author===me || hasScope('DELETE')useDeleteComment with a confirm); a “No comments yet” empty state; useRealtimePostDetail(id) subscribes comments + likes and patches the cache (dedupe the optimistic comment by id). Modal mode: focus trap + Esc + focus-restore + body-scroll-lock. Works for owner / APPROVE / DELETE on PENDING/DENIED (the post_detail view).
  • 8.5 Image lightbox — a <Lightbox> (Radix Dialog-based) for My Uploads / Approvals / the upload preview / (zoom in) post detail: full-screen; object-contain; the original filename in a corner; X + Esc + backdrop to close; optional Prev/Next across the current dataset (arrow keys too; bounds-checked, no wrap, disabled at ends); zoom/pan (scroll/pinch, drag, double-click resets); focus-trapped; body-scroll-locked.
  • 8.6 Upload (/upload, gate ADD) — a centered card: a react-dropzone area (idle / drag-accept / drag-reject states) plus a window paste listener (⌘/Ctrl-V a screenshot → selected); on select → a blob-URL preview (click → <Lightbox>); constraints from the env (mirrored: ALLOWED_MIME_TYPES, UPLOAD_SIZE_LIMIT — display them); UploaduseUploadPOST create-upload (multipart, field file) → on success: clear + a “Queued for approval” toast + invalidate ['my-uploads'] + ['approval-count']; on error map the code → a friendly message. A Reset button. (parity) keep clipboard paste. (drops) the quad-state uploadState; the hard-coded 800 KB.
  • 8.7 My uploads (/my-uploads) — a shared <UploadGrid mode="mine"> (responsive card grid, page 10, “Load more”): each <UploadCard> = the image (click → <Lightbox> over the dataset) · a <StatusPill> (Approved=green / Denied=red / Pending=amber — semantic tokens) · filename + relative date + size · a Delete button (useDeleteUploadPOST delete-upload?id= → optimistic removal + a toast + invalidate ['approval-count'] if it was pending; a <ConfirmDialog> first); per-row mutation state. A real skeleton; a “You haven’t uploaded anything yet” empty state + a “Go to Upload” CTA. (drops) the Images.tsx/Approvals.tsx ~80% dup (one <UploadGrid>/<UploadCard> parameterized by mode); the shared-loading-spins-all-buttons bug.
  • 8.8 Approvals (/approvals, gate APPROVE)<UploadGrid mode="approvals"> (page 20, oldest-first, “Load more”): each card adds “Uploaded by {avatar + username}” · an Approve button (from('uploads').update({approved:'APPROVED'}).eq('id',id)) and a Deny button — per-row state; on success → optimistic removal + invalidate ['approval-count'] + ['timeline'] (Realtime also delivers the approved post); useRealtimeApprovals() keeps the queue live. The count = useApprovalCount (from('pending_uploads').select('*',{count:'exact',head:true})), shown as the nav badge, also kept live. A “Nothing awaiting approval 🎉” empty state. (drops) the dup; the approveRefetchAtom integer-bump hack.
  • 8.9 Invite (/enroll, gate ENROLL) — a card: a Discord-ID <Input> (snowflake-validated ^\d{17,19}$; button disabled until valid) plus a scope picker (checkboxes for the scopes the inviter may grant — their own scopes, minus ESCALATE unless they hold it — this is the real improvement: the old flow silently “inherited the inviter’s scopes”; now it’s explicit); Inviterpc('enroll_user', {p_discord_id, p_scopes})'ENROLLED' → “Scopes granted to {id}”; 'ENROLLED_PENDING' → “Invite saved — they’ll get these scopes when they first log in”. (drops) the overloaded enrollState; the silent scope-inheritance.
  • 8.10 Members / Admin (/members, gate ENROLLDELETE) — NEW (implements the dangling /users link) — a table of members (from('profiles').select('*, scopes:user_scopes(scope)').order('username')): avatar · username · discord_id · scopes (badges) · (with ENROLL) Edit scopes → a dialog with scope checkboxes → rpc('set_user_scopes', {p_user_id, p_scopes}) (can’t grant ESCALATE unless you hold it) → optimistic update · (with DELETE) View uploads → a drawer showing that member’s uploads (from('uploads').eq('uploader', targetId)) with an admin-delete per row (delete-upload?id=notify-discord {post_deleted_admin} fires). A Pending invites section (from('enrollments').select('*, inviter:profiles(username)')ENROLL-visible): target discord_id · scopes · invited by · a Cancel (rpc('cancel_enrollment', {p_invited_discord_id})).
  • 8.11 Search (/search, gate VIEW) — NEW (the payoff of doing OCR for real) — a search input (also in the nav) → /search?q=...useSearch(q) (rpc('search_archive', {q}), debounced) → a responsive grid of matching approved uploads (timeline-shaped) with the matched-text headline snippet under each; click → /post/:id. Empty q → a hint (“Search the archive — every screenshot is OCR’d”); no results → “No matches for ‘{q}’“.
  • 8.12 Yorick & Wowjesus stories (/yorick) — a deliberately-distinct dark “streaming service” page, dark regardless of the app theme, not using the shared <Nav>: its own dark header (a film icon + “Yorick And Wowjesus Stories” + a “Back to txArchive” link → /timeline); a hero (/hero.png re-exported optimized; the overlaid “Insane stories” / “Brought to you by wowjesus.”); a responsive poster grid driven by src/features/yorick/yorick-catalog.json ([{title, description, comingSoon, poster, video}]) — click an entry → if not comingSoon, play video (a cdn.txarchive.org/*.mp4 URL) in an in-app <video> modal (fall back to a new tab). Posters re-exported as optimized WebP, loading="lazy", with React keys. Migrate the 7 current entries verbatim (verify the ...wh... MP4 filename in R2 — keep if real, else fix). Adding an episode = append one JSON object + drop a poster. (drops) the missing keys; the hard-coded array; window.open.
  • 8.13 App shell — Nav, mobile drawer, user menu, theme toggle — see “App shell” above. (drops) the per-page Nav; the bg-white literal; the no-click-outside dropdown; the no-scrim drawer; the /users vs /enroll route mismatch; the hard-coded ”© 2023”.
  • 8.14 Cross-cutting UX — toasts (used for real); skeletons everywhere; empty states everywhere; visible error states + a top-level error boundary; per-route document titles ("{Page} · txArchive", reactive); date formatting (relative, absolute on hover, user locale); size formatting (binary units KiB/MiB); deep-links (/post/:id); image handling (lazy-load, alt, blur placeholder, optimized Yorick posters); responsiveness (Tailwind + the mobile affordances); a11y (labels on all controls, focus traps + Esc + focus-restore in modals/drawers, full keyboard nav, alt text, prefers-reduced-motion, visible focus rings, AA contrast). Identity preserved: txArchive (lowercase tx), the tagline, the login disclaimer, txarchive.org.