Properties
category: reference
tags: [design, frontend, prd]
last_updated: 2026-03-14
confidence: medium

Frontend Design

Superseded. This page describes the frontend for wikibot.io (S3 + CloudFront, WorkOS auth, dual CloudFront distributions). See Design/VPS_Architecture for the current plan (Caddy file_server, ATProto auth). The SPA screens, framework choice, cross-subdomain cookie auth pattern, and UX decisions carry forward; the AWS hosting and WorkOS-specific flows do not.

Status: Draft — needs review Relates to: Design/Platform_Overview, Design/Auth, Design/Implementation_Phases, Design/CDN_Read_Path, Design/Landing_Page

What the frontend actually is

There are two distinct web experiences that need to coexist:

  1. The management app — dashboard, wiki CRUD, collaborator management, MCP connection instructions, eventually billing. This lives at wikibot.io.
  2. The wiki — Otterwiki's Flask-rendered pages. This lives at {slug}.wikibot.io.

They're served by different backends on different subdomains, but the user needs to move between them without friction. The management app links to your wiki; the wiki links back to management for platform-level settings. Auth needs to work across both.

URL Scheme

Every wiki gets a subdomain: {slug}.wikibot.io. No path nesting, no username prefix. The slug is the wiki's globally unique identifier.

third-gulf-war.wikibot.io/              → wiki web UI (Otterwiki)
third-gulf-war.wikibot.io/api/v1/       → wiki REST API
third-gulf-war.wikibot.io/mcp           → wiki MCP endpoint
third-gulf-war.wikibot.io/repo.git/*    → git smart HTTP

For free-tier users, the wiki slug is the username. You sign up as sderle, your wiki lives at sderle.wikibot.io. Paid users can create additional wikis with slugs of their choosing — same namespace, same validation rules.

The management app and auth live on the root domain:

wikibot.io/                             → landing page (unauthenticated)
wikibot.io/app/                         → management SPA (authenticated)
wikibot.io/app/new                      → create wiki
wikibot.io/app/{slug}                   → wiki settings
wikibot.io/app/{slug}/collaborators     → collaborator management
wikibot.io/app/{slug}/connect           → MCP connection instructions
wikibot.io/app/account                  → account settings, eventually billing
wikibot.io/auth/callback                → OAuth callback (server-side, mints JWT)
wikibot.io/auth/logout                  → clear auth state
wikibot.io/api/wikis                    → management API
wikibot.io/api/wikis/{slug}             → wiki details
wikibot.io/api/wikis/{slug}/acl         → collaborator management
wikibot.io/api/wikis/{slug}/token       → token regeneration
wikibot.io/api/account                  → account info

The /app/ prefix separates the SPA from the landing page (static HTML at /) and the API (/api/). CloudFront routes /app/* to the S3-hosted SPA (all paths serve index.html), /api/* and /auth/* to Lambda, and / to the static landing page.

Namespace implications

Slugs and usernames share one global namespace. This simplifies routing (every subdomain is a wiki lookup — no "is this a username or a slug?" disambiguation) but requires careful validation:

  • Slugs must be unique across all wikis and all usernames.
  • When a user signs up and picks a username, it's reserved as a slug for their free wiki.
  • Paid wiki slugs are chosen at creation time and checked against the same namespace.
  • Reserved names (api, auth, app, www, admin, mcp, docs, status, blog, help, support, billing, static, assets, null, undefined, wiki, robot) are blocked for both usernames and wiki slugs.

The Data Model's username-index GSI and a wiki slug-index GSI both need to draw from the same blocklist, and uniqueness checks at wiki creation need to verify the slug isn't taken as a username either. A single slug_reservations table (or a single DynamoDB attribute with a GSI) that holds both usernames and wiki slugs would eliminate the dual-lookup.

Impact on existing design documents

This URL scheme replaces the {username}.wikibot.io/{wiki}/ pattern described in Design/Data_Model, Design/Implementation_Phases, and Design/Platform_Overview. The key changes:

  • The Wiki.custom_slug field becomes unnecessary — every wiki has a slug that IS its subdomain. Remove custom_slug, replace with just slug.
  • The subdomain resolution logic simplifies: parse subdomain → look up wiki by slug → done. No fallback to username resolution.
  • The user's "dashboard" URL is no longer {username}.wikibot.io/ — it's wikibot.io/app/.
  • Git remote URLs simplify: https://{slug}.wikibot.io/repo.git instead of https://{user}.wikibot.io/{wiki}.git.
  • MCP endpoint simplifies: https://{slug}.wikibot.io/mcp.

Authentication

The platform JWT is stored as an HttpOnly, Secure, SameSite=Lax cookie on .wikibot.io (note the leading dot — this is the parent domain, visible to all subdomains). Every request to any *.wikibot.io subdomain includes the cookie automatically. The middleware on each subdomain validates the JWT independently using the same RS256 public key.

Why this works:

  • The management SPA at wikibot.io/app/ needs auth → cookie is present.
  • Otterwiki at {slug}.wikibot.io needs auth → same cookie is present.
  • Public wiki reads don't need auth → middleware ignores the cookie and serves as anonymous.
  • MCP bearer token auth is a separate path (Authorization header) → unaffected.

Why not localStorage or a subdomain-specific cookie: localStorage can't be shared across subdomains. Subdomain-specific cookies would require a separate auth flow for each wiki you visit. The parent-domain cookie gives you SSO for free.

Security consideration: the cookie is sent on every request to every subdomain, including public wiki reads. This is fine — the middleware already distinguishes authenticated from anonymous access. The cookie contains a signed JWT, not session state, so replaying it to a public wiki endpoint doesn't grant any access the user doesn't already have.

OAuth flow

The SPA cannot set HttpOnly cookies (browser security restriction). So the auth flow is server-side:

1. User clicks "Sign in" on the SPA
2. SPA redirects to WorkOS AuthKit (hosted UI)
3. User authenticates with Google/GitHub/Microsoft/Apple
4. WorkOS redirects to wikibot.io/auth/callback with auth code
5. Callback Lambda exchanges code for user info via WorkOS API
6. Lambda looks up or creates User record in DynamoDB
7. Lambda mints platform JWT (RS256, signed with our key from Secrets Manager)
8. Lambda sets HttpOnly cookie on .wikibot.io
9. Lambda redirects to wikibot.io/app/ (the SPA dashboard)

The SPA never sees or handles the JWT directly. It just knows whether the cookie exists (via a non-HttpOnly companion cookie or a /api/me endpoint that returns 200 or 401).

JWT details

These need pinning down:

  • Algorithm: RS256 (asymmetric — the signing key stays in Secrets Manager, the public key can be distributed to any service that needs to validate).
  • Lifetime: 24 hours. Short enough that revocation is eventually consistent, long enough that users aren't constantly re-authenticating.
  • Refresh: Silent refresh via a /auth/refresh endpoint. When the SPA detects a 401, it hits /auth/refresh — if the user has a valid session with WorkOS (cookie on .authkit.app), WorkOS re-issues credentials without user interaction, and the callback mints a fresh JWT. If the WorkOS session has expired, the user gets redirected to login.
  • Claims: sub (our User.id), email, username, iat, exp. No role claims — roles are per-wiki and live in DynamoDB, not the token.
  • Key rotation: Support two valid keys simultaneously (JWKS with kid header). Rotate by: generate new key, add to JWKS, start signing with new key, retire old key after 48 hours (2× the token lifetime). This is zero-downtime rotation. The JWKS endpoint (wikibot.io/.well-known/jwks.json) serves both keys during the transition window.

How the SPA knows if you're logged in

The SPA can't read the HttpOnly cookie. Two options:

Option A: companion cookie. The callback Lambda sets a second, non-HttpOnly cookie (logged_in=1, same domain/expiry) that the SPA can read. This cookie carries no auth weight — it's just a hint. The SPA uses it to decide whether to show the dashboard or the login page. If it's stale (the JWT expired but the hint cookie hasn't), the first API call returns 401 and the SPA redirects to login.

Option B: /api/me probe. On SPA load, fetch /api/me. If 200, you're logged in (and you get the user's display name, email, username for the UI). If 401, redirect to login. This adds a network round-trip to every SPA cold load.

Option A is snappier (no network round-trip to decide what to render). Option B is simpler (no second cookie to manage). For an app where the SPA loads are infrequent (users visit the dashboard, click through to their wiki, and spend most of their time in Otterwiki or MCP), the round-trip cost of Option B is negligible.

Recommendation: Option B. /api/me returns user info needed by the SPA anyway (username, display name, wiki list). One request, no companion cookie to manage, no stale-hint edge cases.

Framework

Recommendation: Svelte (not SvelteKit)

The management app has six screens, no server-side rendering needs, and no complex state. It's a thin CRUD layer over the management API. SvelteKit's filesystem routing and SSR machinery are overkill — the app is a static SPA deployed to S3 + CloudFront. A plain Svelte project with svelte-spa-router (or Svelte 5's built-in routing if stable) and Vite is the right level of tool for the job.

Why Svelte over React: smaller bundle (the management app should be under 100KB gzipped, total), less boilerplate for forms and lists, reactive declarations handle the minimal state without a state management library. The audience is developers — they won't be surprised by Svelte.

Why not SvelteKit: SvelteKit wants to own the server. Its adapter model (adapter-static, adapter-node, etc.) adds complexity for what is ultimately a static SPA. The SSR and form actions features are solutions to problems this app doesn't have. A plain Vite + Svelte project builds to static files, drops into S3, and is done.

Why not plain HTML: seriously considered. The management UI is mostly forms and lists — server-rendered HTML from Lambda would work. But the token show-once UX (display in a modal, copy to clipboard, dismiss and never show again) and the collaborator management flow (add, change role, remove without full page reloads) benefit from client-side interactivity. The incremental complexity of Svelte over plain HTML is small, and the UX payoff is worth it.

Build tooling

  • Vite for dev server and production build.
  • TypeScript — the app is small but types help with API response shapes.
  • Tailwind CSS — utility classes for layout. The landing page is hand-written CSS (per Design/Landing_Page), but the management SPA is a separate build and Tailwind is the fastest way to ship a clean, responsive UI without writing much CSS.
  • No state management library. Svelte's reactive $state (Svelte 5 runes) or writable stores (Svelte 4) are sufficient. The state is: current user, list of wikis, current wiki details. Nothing more.

Bundle budget

Target: < 80KB gzipped for the initial load (JS + CSS). Svelte compiles away the framework runtime, so this is achievable. The main risk is Tailwind — use the JIT compiler to tree-shake unused classes.

Screens

Dashboard (/app/)

The first thing you see after login. Shows your wikis as a list (not cards, not a grid — a list). Each row shows:

  • Wiki slug (linked to {slug}.wikibot.io)
  • Display name
  • Page count
  • Last activity timestamp
  • Status indicator (active, lapsed) — only relevant once billing exists

Empty state for new users: a welcome message and a "Create your wiki" button. Since free users get exactly one wiki, the empty state could skip the list entirely and go straight to the creation flow.

Create wiki (/app/new)

For free users (first wiki): a form with just the wiki display name. The slug is their username, pre-filled and non-editable. Submit creates the wiki and redirects to the connection instructions screen, which shows the MCP bearer token (this is the show-once moment).

For paid users (additional wikis): same form but with an editable slug field. Slug validation: lowercase alphanumeric + hyphens, 3–30 characters, no leading/trailing hyphens, not in the reserved list, not already taken (check via API on blur).

Wiki settings (/app/{slug})

Tabs or sections:

General: Display name (editable), slug (read-only after creation), public/private toggle, link to wiki web UI ({slug}.wikibot.io), link to Otterwiki admin panel ({slug}.wikibot.io/-/admin) for wiki-level preferences (site name, sidebar, editing conventions).

Collaborators: covered below.

Connection: covered below.

Danger zone: Delete wiki. Requires typing the slug to confirm. Red button, scary text, the usual.

Collaborators (/app/{slug}/collaborators)

List of users with access to the wiki. Each row: email, display name, role badge (owner/editor/viewer), and a role dropdown or revoke button (except for the owner).

Invite flow: Email input + role selector + invite button.

What happens when you invite an email that isn't registered: this is a product decision that isn't resolved elsewhere in the design. Two options:

Option A: Reject unregistered emails. Simple. "This email doesn't have a WikiBot account. Ask them to sign up first, then try again." No pending state, no email sending, no invite tokens. The downside is friction — the collaborator has to sign up before the invite can happen.

Option B: Pending invites. Create an ACL record in "pending" state, keyed on email instead of user ID. When the invitee signs up, match their email to pending invites and activate them. Optionally send an email notification ("You've been invited to collaborate on {wiki}"). The upside is smoother onboarding; the downside is email sending infrastructure (SES), pending state management, and stale invite cleanup.

Recommendation: Option A for launch. The audience at launch is small and technical. "Have them sign up first" is fine. Pending invites can be added later when the friction matters. This avoids building email sending infrastructure, which adds cost and complexity disproportionate to its value at this stage.

MCP connection instructions (/app/{slug}/connect)

This is the most important screen in the management app. It should be accessible from the dashboard (after wiki creation) and from wiki settings. It shows:

Bearer token (for Claude Code and API clients): Displayed once at wiki creation time. If the user navigates away and comes back, the token is gone — they see a "Regenerate token" button with a warning that existing connections will break. The token display uses a monospace font, a copy-to-clipboard button, and a yellow "save this now" callout.

Claude Code setup:

claude mcp add {slug} --transport streamable-http --url https://{slug}.wikibot.io/mcp --header "Authorization: Bearer YOUR_TOKEN"

This should be a pre-filled, copyable code block with the user's actual slug and (on first display) their actual token substituted in.

Claude.ai setup: For OAuth-based MCP connections, the user just adds the MCP URL in Claude.ai's settings. The instructions should show the URL (https://{slug}.wikibot.io/mcp) and walk through the Claude.ai UI for adding an MCP server. Screenshots would help here, but can wait until the UI is stable.

Other MCP clients: A generic "any Streamable HTTP MCP client" section with the endpoint URL and auth header format.

Account settings (/app/account)

Username (read-only for MVP), email (from OAuth provider, read-only), connected OAuth provider, delete account button.

Eventually: billing management (Stripe customer portal link), subscription status.

Otterwiki Admin Panel Boundary

The management SPA handles platform concerns: wiki CRUD, ACLs, tokens, billing. Otterwiki's admin panel handles wiki-level preferences: site name, description, sidebar layout, editing conventions (commit message style, page name casing, WikiLink syntax). These are different concerns and shouldn't be duplicated.

The SPA links to the Otterwiki admin panel ({slug}.wikibot.io/-/admin) from the wiki settings page. The admin panel links back to the SPA (a "Platform settings" link in the Otterwiki nav, pointing to wikibot.io/app/{slug}). This cross-linking is a small upstream-friendly addition: a template variable for an external settings URL, rendered as a nav link when set.

The Phase 2 decision to hide Repository Management, Permissions/Registration, User Management, and Mail Preferences from the Otterwiki admin panel stands. Those sections conflict with platform-managed settings and are correctly disabled.

Error Handling

Patterns to define upfront so the UI is consistent:

Loading states: Skeleton placeholders for the wiki list on dashboard load. Not spinners — the layout should be visible immediately with placeholder content that resolves to real data. For individual actions (create wiki, invite collaborator), a disabled button with a loading indicator.

API errors: Inline error messages below the relevant form field or action button. Not toasts — toasts are easy to miss and hard to act on. If creating a wiki fails because the slug is taken, the error appears next to the slug field. If inviting a collaborator fails because the email isn't registered, the error appears next to the email field.

Global errors: If the management API is unreachable (network error, 5xx), a banner at the top of the page: "Something went wrong. Try refreshing." No retry loops, no exponential backoff in the UI — the user can refresh.

Stale session: If /api/me or any API call returns 401, redirect to login. If the WorkOS session is still valid, the user is silently re-authenticated and redirected back. If not, they see the login screen.

Static Hosting and Routing

The SPA is a set of static files deployed to S3, served via CloudFront. The routing setup:

CloudFront behaviors (order matters — first match wins):

  1. /api/* and /auth/* → API Gateway origin (management Lambda)
  2. /app/* → S3 origin, with custom error response: 403 and 404 → /app/index.html with 200 status. This is standard SPA hosting — all routes resolve to the SPA shell, client-side routing handles the rest.
  3. / → S3 origin, serves the static landing page (index.html at the S3 root).
  4. Static assets (CSS, JS, images) → S3 origin with long TTL (1 year, content-hashed filenames).

The SPA's index.html gets Cache-Control: no-cache (or short TTL) so deploys take effect immediately. JS and CSS bundles get content-hashed filenames and Cache-Control: public, max-age=31536000.

Wildcard subdomain setup:

  • ACM certificate: *.wikibot.io + wikibot.io (SAN certificate, or two certificates)
  • Route 53: *.wikibot.io ALIAS to CloudFront distribution (wiki subdomains) + wikibot.io ALIAS to a separate CloudFront distribution (management app + landing page), OR a single CloudFront distribution with behaviors that route based on the Host header.
  • One distribution is simpler to manage. CloudFront can route by Host header using cache behaviors with origin request policies.

Single vs. dual distribution: A single CloudFront distribution handling both wikibot.io and *.wikibot.io is possible with a Lambda@Edge or CloudFront Function on origin-request that inspects the Host header and routes to the appropriate origin (S3 for the management app, API Gateway for wiki subdomains). This avoids managing two distributions but adds a routing function at the edge.

Two distributions is operationally simpler: one for the root domain (S3 + API Gateway origins), one for wildcards (API Gateway origin for wiki subdomains). The tradeoff is two sets of cache behaviors and two certificates to manage.

Recommendation: two distributions. The routing logic is different enough that combining them adds more complexity than it saves. The root-domain distribution handles static files and the management API. The wildcard distribution handles wiki traffic (Otterwiki, REST API, MCP, git). Each has its own cache behaviors, origins, and error handling.

Build and Deploy

The SPA builds to static files via Vite, uploaded to S3, served via CloudFront.

app/frontend/
  src/
    routes/           # page components
    lib/              # API client, auth helpers, shared components
    app.svelte        # root layout
    main.ts           # entry point
  static/             # favicon, etc.
  vite.config.ts
  package.json
  tsconfig.json

CI/CD (GitHub Actions):

  1. npm run build → produces dist/ with index.html, hashed JS/CSS bundles
  2. Upload dist/ to S3 management bucket
  3. Invalidate CloudFront paths: /app/*, /app/index.html
  4. Smoke test: curl -s https://wikibot.io/app/ | grep -q '<div id="app">'

Source maps are generated but not uploaded to S3. Upload them to an error tracking service (Sentry) if/when that's set up.

Environment variables baked in at build time via Vite's import.meta.env:

  • VITE_API_BASE_URL (e.g., https://wikibot.io)
  • VITE_WORKOS_CLIENT_ID (for the login redirect)

No runtime config fetching. The SPA doesn't need to discover anything at load time — the API is always at the same origin.

Mobile

The Phase Gate says "usable on phone." This is a management dashboard with lists and forms — responsive CSS handles it. No mobile-specific design needed. Tailwind's responsive utilities (sm:, md:, lg: prefixes) keep this simple.

The one screen that doesn't work well on mobile is the MCP connection instructions — those are terminal commands. That's fine. Users connecting MCP aren't doing it from their phone. The screen should render correctly (no horizontal overflow on the code blocks), but we're not optimizing for that use case.

Upstream Contributions

The following changes to Otterwiki would be submitted upstream:

  1. External settings link in nav. A config variable (e.g., EXTERNAL_SETTINGS_URL) that, when set, renders a link in the admin sidebar pointing to the platform management UI. Useful for any Otterwiki deployment that wraps the wiki in a larger platform.

  2. Any template changes needed for CDN fragment rendering (per Design/CDN_Read_Path). These would be structured as pluggable hooks — not wikibot-specific modifications.

Open Questions

  1. Single vs. dual CloudFront distribution. Recommendation above is two, but single-distribution with a CloudFront Function router might be simpler in practice. Needs Pulumi prototyping to see which is less painful to configure.

  2. SPA framework version. Svelte 5 (with runes) is the current stable release. Verify that the ecosystem (svelte-spa-router or equivalent, Tailwind integration) is solid before committing.

  3. Token storage for MCP OAuth flow. The management SPA uses an HttpOnly cookie. The MCP OAuth flow (Claude.ai connecting to a wiki) uses WorkOS-issued tokens validated against WorkOS JWKS. These are separate token types on separate paths — but the user experience of "I logged into wikibot.io and now my MCP connection works" depends on both paths being configured correctly. The relationship between the platform JWT and the MCP OAuth token needs to be documented clearly in the connection instructions.

  4. Landing page → SPA transition. The landing page is static HTML at /. The SPA is at /app/. When a logged-in user visits /, should they be redirected to /app/? Probably not — the landing page is useful even for logged-in users (docs, FAQ). But there should be a "Go to dashboard" link in the header that replaces "Sign in" when the user has an active session. This requires either a small JS snippet on the landing page that checks for the companion cookie / hits /api/me, or a CloudFront Function that detects the auth cookie and adds a header that the static page can use. The simplest approach: a small inline script on the landing page that checks for a logged_in cookie (non-HttpOnly, set alongside the JWT) and swaps the "Sign in" link for "Dashboard." This is the one case where a companion cookie (not /api/me) makes sense — the landing page is static HTML and can't make fetch calls elegantly without a framework.