Files
Parsons/docs/reference/client-demo-hosting-plan.md
Richie 45d73759c1 Scaffold arrangement demo slice with CompareBar wiring
Add a self-contained demo build target for the Providers → Packages →
Comparison flow, deployable as a static SPA at /arrangement/.

- vite.demo.config.ts: per-slice build via --mode, base path flips for
  dev vs prod, output to dist-demo/<slice>/
- src/demo/: shared fixtures (7 providers across verified/tier3/tier2
  with real venue photography from brandassets) + Zustand basket store
  with ?compare= URL persistence
- Verified-provider packages now share the nine canonical Essentials
  line items per FA convention; only Optionals/Extras vary
- App-level CompareBar surfaces "Already added" / "Maximum 3" feedback
  via transient store error
- ProviderCard logo objectFit cover→contain so wide logos don't crop
- npm scripts demo:dev / demo:build, deps zustand + react-router-dom
2026-04-20 14:55:21 +10:00

9.2 KiB

Client demo hosting plan

Status: scoped, not implemented. Target: share self-contained, interactive demos of the FA design system with clients via parsons.tensordesign.com.au/<slice>, gated behind basic auth, independently buildable per slice.


Why not Storybook or Chromatic alone

  • Storybook — great for isolated components and per-story state, but cross-page flows with persistent state (comparison basket, map selections, route navigation) are outside its shape. You can fake flows with story parameters, but it's brittle and doesn't feel like a product.
  • Chromatic — built for visual regression diffs + internal review. UX is Storybook-shaped, which is noisy for non-technical clients. Keep Chromatic for the internal review workflow; use self-hosted demos for client-facing previews.

The demo-hosting solution is additive — it doesn't replace either of those.


Goals

  1. Multiple demo "slices" per project — e.g. /arrangement, /home, /compare — each independently buildable and deployable.
  2. Each slice behaves like a real product: real URLs, real navigation, real state (comparison basket persists across pages, selections survive drill-in, etc.).
  3. Dummy data only — no CMS, no real users, no auth beyond a single shared htpasswd for the whole demo host.
  4. Zero disruption to the component library — demos consume the existing page components as-is.
  5. Demos live alongside the codebase but build into their own output tree (dist-demo/<slice>/) so neither the component library nor Storybook is affected.

URL shape

Single subdomain, subpath per slice:

  • parsons.tensordesign.com.au/ → tiny index page listing available demos (optional, but handy once there are 3+)
  • parsons.tensordesign.com.au/arrangement → Providers → Packages → Comparison flow
  • parsons.tensordesign.com.au/home → homepage exploration
  • parsons.tensordesign.com.au/<future slice>

One SSL cert (Let's Encrypt), one nginx server block, one htpasswd covering the whole host.

Why subpath-per-slice over subdomain-per-slice: subdomain-per-slice needs wildcard DNS + wildcard cert + per-demo nginx blocks. More moving parts for no client-facing benefit. Subpath works cleanly when each Vite build declares its own base at build time.

Why this host over a wildcard demo subdomain: parsons.tensordesign.com.au reads clearly to clients ("this is the Parsons project"). If you later run demos for other clients, add siblings (acme.tensordesign.com.au) rather than splitting Parsons across multiple hosts.


Project structure

src/demo/
  shared/
    fixtures/          # cross-slice mock data (providers, packages, venues)
    state/             # common stores — comparison basket, nav shell
    theme/             # ThemeProvider wrapper (mirrors Storybook decorators)
  apps/
    arrangement/
      main.tsx         # Vite entry
      App.tsx          # Router shell
      routes/
        providers.tsx
        packages.tsx
        comparison.tsx
      fixtures/        # arrangement-specific overrides
    home/
      main.tsx
      App.tsx
      routes/
        landing.tsx
index-demo.html        # template — slot in slice-specific title/base

scripts/
  build-demo.sh <name>     # vite build -c vite.demo.config.ts --mode <name>
  deploy-demo.sh <name>    # rsync dist-demo/<name>/ to server path

vite.demo.config.ts        # reads slice name from --mode, sets base + outDir

docs/reference/
  client-demo-hosting-plan.md       # this file
  client-demo-deploy.md             # once implemented — ops runbook

Fixture-sharing rule: anything that could plausibly appear in two slices lives in src/demo/shared/fixtures/. Slice-specific overrides live in apps/<slice>/fixtures/. Stories continue to use their own fixtures unchanged — no cross-contamination.


State shape (the one thing worth being deliberate about)

The comparison basket is cross-page state. Whatever we pick here will likely inform the real app's state layer later, so treat it as the prototype for production, not throwaway glue.

Recommended: Zustand with URL-persistence for package IDs.

// src/demo/shared/state/useComparisonBasket.ts
interface ComparisonBasket {
  packageIds: string[];                    // ordered — insertion order = display order
  add: (id: string) => void;
  remove: (id: string) => void;
  clear: () => void;
  isFull: () => boolean;                   // 4 max per FA convention
}
  • Zustand over Context: less boilerplate, better Devtools story, selector-based subscriptions avoid re-render cascades.
  • URL-persistence (?compare=a,b,c) so a client can bookmark a specific comparison and reload. Easy useEffect hook that syncs store ↔ URL search param.
  • Insertion order preserved — matches ComparisonPage columns left-to-right.
  • No localStorage — keeps demo stateless between sessions unless client explicitly shares a URL. Makes client demos predictable ("click here, you'll see exactly what I saw").

Other state that might need a shared store: selected provider (persists across routes), map viewport (so map doesn't reset on drill-in). Start with just the basket; add others when a route actually needs them.


Vite build

One config file, slice name passed via --mode:

// vite.demo.config.ts
export default defineConfig(({ mode }) => ({
  root: `src/demo/apps/${mode}`,
  base: `/${mode}/`,
  build: {
    outDir: `../../../../dist-demo/${mode}`,
    emptyOutDir: true,
  },
  // ... shared plugins, resolve aliases identical to main vite.config
}));

npm run build:demo -- --mode arrangement produces dist-demo/arrangement/ ready to ship.

Shared scripts:

"scripts": {
  "demo:dev": "vite -c vite.demo.config.ts",
  "demo:build": "vite build -c vite.demo.config.ts",
  "demo:deploy": "./scripts/deploy-demo.sh"
}

Deploy

# scripts/deploy-demo.sh <slice>
SLICE=$1
rsync -az --delete dist-demo/$SLICE/ richie@tensordesign.com.au:/var/www/parsons-demos/$SLICE/

Slice-by-slice deploy means polishing one demo doesn't require rebuilding or redeploying others. Add the index page as a trivial static HTML (no build) committed to scripts/demo-index.html and rsynced to the host root.

Consider a tiny pre-flight check in the deploy script — abort if the build output is missing or empty, to avoid rsync-ing a half-built bundle over a working demo.


nginx + basic auth

server {
  listen 443 ssl http2;
  server_name parsons.tensordesign.com.au;

  ssl_certificate     /etc/letsencrypt/live/parsons.tensordesign.com.au/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/parsons.tensordesign.com.au/privkey.pem;

  root /var/www/parsons-demos;
  index index.html;

  auth_basic "Parsons demos";
  auth_basic_user_file /etc/nginx/htpasswd/parsons;

  # SPA fallback per slice — each demo has its own index.html
  location ~ ^/(?<slice>[^/]+)/ {
    try_files $uri $uri/ /$slice/index.html;
  }

  # Root lists available demos
  location = / {
    try_files /index.html =404;
  }
}

htpasswd setup:

sudo htpasswd -c /etc/nginx/htpasswd/parsons client

Single credential shared across all slices. If a client project ever needs isolated access, split at the location block with per-slice auth_basic_user_file.


Rollout order (next session)

  1. Scaffold src/demo/shared/ (fixtures extracted from PackagesStep.stories.tsx + ComparisonPage.stories.tsx) and the Zustand basket store.
  2. First slice — arrangement with three routes (Providers → Packages → Comparison). Prove the basket persists across navigation, the back button works, drill-in still fires on mobile.
  3. vite.demo.config.ts + build-demo.sh — confirm dist-demo/arrangement/ builds and serves standalone via npx serve dist-demo/arrangement.
  4. nginx + htpasswd + Let's Encrypt on the server. One-time ops setup.
  5. Deploy script — rsync wired to a single command.
  6. Test end-to-end — visit parsons.tensordesign.com.au/arrangement, click through the flow, verify basket state, verify URL bookmark restores state.
  7. Optional: index page + second slice once the first is proven.

Estimated effort: half-day scaffold + ~day for the arrangement slice + half-day ops setup. Total ~2 days before the first client-shareable link.


Non-goals (for this iteration)

  • No real backend, no CMS, no user accounts, no persistence beyond URL params.
  • No analytics or telemetry (demos are short-lived; instrumenting them is noise).
  • No E2E tests for demo routes (fixtures + trusted component library = low ROI).
  • No automated deploy on merge — manual npm run demo:deploy arrangement is fine at this volume. Revisit if demos are updated daily.
  • No custom domain per client — stick with subpath-per-slice under parsons.tensordesign.com.au.

Open questions for next session

  • Exact path for the server-side document root (/var/www/parsons-demos/ is a placeholder — confirm what tensordesign.com.au's existing nginx expects).
  • Whether to seed the home slice or a different one as the second demo after arrangement is proven.
  • Whether the comparison basket should persist across browser tabs (probably no — URL-only is cleaner for demos) or across reloads without a URL (probably no for the same reason).