# SLICE-3 — Storefront Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement SLICE-3 of SD-0001 v0.3.0 — the `storefronts` domain (create + INV-4 guard + membership), the `POST /api/storefronts` endpoint wired into the `_storefront_for` seam, the Create-storefront + Admin-shell screens per the Claude Design export, complete entry routing, the whole-flow INV-1 bootstrap test, and re-skin the SLICE-1/2 screens (Landing, Sign-in) to the same design. **Anchor:** Solution Design `wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md` §7.2 (SLICE-3), §6.5 (PUC-4/5/6/7/8), §6.4 (API), §5.1–5.4 (UX). Design export: `wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project/` (hf-kit/landing/signin/storefront/admin + `_ds` tokens). **Architecture:** Backend adds a second domain package `app/domains/storefronts` (sibling of `accounts`, never imports it — the BFF passes `account_id`/`email` down). The INV-4 guard + insert are serialized per-account with a transaction-scoped advisory lock so two concurrent creates cannot both pass the guard. Frontend gains a design-system foundation (Wiggleverse tokens CSS + fonts + brand SVGs + an `app.css` implementing the hi-fi primitives) and a small `src/ui/` component kit; the four §5 screens render from it. No new migration: `storefront` + `storefront_membership` shipped in `0001_init.sql`. **Tech Stack:** FastAPI + psycopg (no ORM), pytest against per-test Postgres databases; React 18 + Vite + vitest; plain CSS with Wiggleverse design tokens (no CSS framework). **E2E browser tests:** deliberately deferred per SD-0001 §6.8 ("component/E2E browser tests deferred until there is UI beyond forms") — this slice's UI is still forms + text. Logged in the session transcript's Deferred decisions. The localhost walkthrough (PUC-10) is verified manually at Task 10. --- ## File structure Backend (create): - `backend/app/domains/storefronts/__init__.py` — package surface (`__all__`) - `backend/app/domains/storefronts/errors.py` — `StorefrontsError`, `AlreadyOwnsStorefront` - `backend/app/domains/storefronts/models.py` — `Storefront` - `backend/app/domains/storefronts/service.py` — `create_storefront`, `storefront_for` (INV-4 guard) - `backend/tests/test_storefronts_create.py` — service + endpoint scenario tests - `backend/tests/test_storefronts_invariants.py` — INV-4 concurrency + many-capable schema shape - `backend/tests/test_bootstrap.py` — INV-1 whole-flow Backend (modify): - `backend/app/main.py` — `_storefront_for` seam → storefronts domain; `POST /api/storefronts`; version 0.3 Frontend (create): - `frontend/public/fonts/*.woff2` + `frontend/src/styles/fonts.css` — Space Grotesk 500/700, Inter 400/500/600 (copied from the design export `_ds`) - `frontend/public/brand/mark-tile.svg`, `frontend/public/brand/mark-mono-gold.svg` — brand marks (copied from the design export `assets/`) - `frontend/src/styles/tokens-colors.css`, `tokens-typography.css`, `tokens-spacing.css` — verbatim Wiggleverse token files - `frontend/src/styles/app.css` — the hf-kit primitives as CSS classes (ground/starfield, topbar, card, field, button, banner, footer) - `frontend/src/styles/index.css` — import manifest - `frontend/src/ui/kit.tsx` — Screen, TopBar, Wordmark, Footer, AuthCard, Eyebrow, Banner, PrimaryButton, Field, AccountChip - `frontend/src/ui/CodeInput.tsx` — 6-cell one-time-code input - `frontend/src/screens/CreateStorefront.tsx` — PUC-4/5/7 - `frontend/src/screens/Admin.tsx` — PUC-8/9 Frontend (modify): - `frontend/src/api.ts` — `createStorefront()` - `frontend/src/App.tsx` — full entry routing, welcome banner state, verify result feeds session directly - `frontend/src/screens/Landing.tsx` — rebuild per hf-landing - `frontend/src/screens/SignIn.tsx` — rebuild per hf-signin (cooldown countdown, code cells, state banners) - `frontend/src/main.tsx` — import styles - `frontend/index.html` — theme color / background to avoid white flash - `frontend/package.json` — version 0.3.0 Frontend (delete): - `frontend/src/screens/SignedIn.tsx` — SLICE-2 placeholder, superseded by CreateStorefront/Admin Docs (modify): - `README.md` — status: storefront slice in place Design-fidelity divergences (deliberate, honest-UI rule INV-9): the design's dead `#` links ("How it works", "Open Core", Privacy/Terms footer links) are NOT shipped — nothing links nowhere; the footer keeps the identity line only. The design's post-verify welcome banner renders on the destination screen (create-storefront/admin) since the SPA routes immediately after verify (PUC-3 honest copy preserved). --- ### Task 1: Branch + storefronts domain service (TDD) **Files:** - Create: `backend/app/domains/storefronts/{__init__,errors,models,service}.py` - Test: `backend/tests/test_storefronts_create.py` (service-level tests first) - [x] **Step 1: Branch** ```bash git checkout -b slice-3-storefront ``` - [x] **Step 2: Write failing service-level tests** `backend/tests/test_storefronts_create.py`: ```python """SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7).""" import re import psycopg import pytest from fastapi.testclient import TestClient from app.main import create_app from app.domains import storefronts from app.platform import db @pytest.fixture() def migrated_conn(fresh_db_url): with psycopg.connect(fresh_db_url) as conn: db.migrate(conn) yield conn def _account_id(conn, email="merchant@example.com"): return conn.execute( "INSERT INTO account (email) VALUES (%s) RETURNING id", (email,) ).fetchone()[0] def test_create_storefront_with_name(migrated_conn): acct = _account_id(migrated_conn) sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets") assert sf.name == "Ben's Bets" assert storefronts.storefront_for(migrated_conn, acct) == sf def test_14_01_0026_blank_name_gets_generated_default(migrated_conn): acct = _account_id(migrated_conn) sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None) assert sf.name == "merchant's storefront" def test_whitespace_name_treated_as_blank(migrated_conn): acct = _account_id(migrated_conn) sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ") assert sf.name == "ben's storefront" def test_second_create_refused_inv_4(migrated_conn): acct = _account_id(migrated_conn) storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First") with pytest.raises(storefronts.AlreadyOwnsStorefront): storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second") def test_membership_row_is_owner(migrated_conn): acct = _account_id(migrated_conn) sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None) role = migrated_conn.execute( "SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s", (acct, sf.id), ).fetchone()[0] assert role == "owner" def test_storefront_for_none_when_absent(migrated_conn): acct = _account_id(migrated_conn) assert storefronts.storefront_for(migrated_conn, acct) is None ``` - [x] **Step 3: Run tests to verify they fail** ```bash cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q ``` Expected: FAIL/ERROR — `app.domains.storefronts` does not exist. - [x] **Step 4: Implement the domain** `backend/app/domains/storefronts/models.py`: ```python """Storefront record — the §6.3 entity as the domain returns it.""" from __future__ import annotations from dataclasses import dataclass @dataclass(frozen=True) class Storefront: id: int name: str ``` `backend/app/domains/storefronts/errors.py`: ```python """storefronts domain errors.""" from __future__ import annotations class StorefrontsError(Exception): """Base for storefronts-domain errors.""" class AlreadyOwnsStorefront(StorefrontsError): """INV-4: the account already has its one storefront (PUC-7).""" ``` `backend/app/domains/storefronts/service.py`: ```python """storefronts — the storefront + membership service (SD-0001 §6.5). Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes account_id and email down. Imports only app.platform-level concerns (none needed yet). """ from __future__ import annotations import psycopg from .errors import AlreadyOwnsStorefront from .models import Storefront def _default_name(email: str) -> str: """§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026).""" return f"{email.split('@', 1)[0]}'s storefront" def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None: """The entry-routing answer (§6.5): which storefront, if any, this account has.""" row = conn.execute( "SELECT s.id, s.name FROM storefront s" " JOIN storefront_membership m ON m.storefront_id = s.id" " WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1", (account_id,), ).fetchone() return Storefront(id=row[0], name=row[1]) if row else None def create_storefront( conn: psycopg.Connection, account_id: int, email: str, name: str | None ) -> Storefront: """Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5). Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by account_id serializes concurrent creates for the same account, so the second of two racing requests sees the first's committed membership and is refused (§6.5). """ conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,)) existing = storefront_for(conn, account_id) if existing is not None: conn.rollback() # release the advisory lock; nothing was written raise AlreadyOwnsStorefront() final_name = (name or "").strip() or _default_name(email) sf_id = conn.execute( "INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,) ).fetchone()[0] conn.execute( "INSERT INTO storefront_membership (account_id, storefront_id, role)" " VALUES (%s, %s, 'owner')", (account_id, sf_id), ) conn.commit() return Storefront(id=sf_id, name=final_name) ``` `backend/app/domains/storefronts/__init__.py`: ```python """storefronts domain — storefront entity, membership, the one-storefront guard. Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported via this package surface only (§6.2). """ from __future__ import annotations from .errors import AlreadyOwnsStorefront, StorefrontsError from .models import Storefront from .service import create_storefront, storefront_for __all__ = [ "Storefront", "StorefrontsError", "AlreadyOwnsStorefront", "create_storefront", "storefront_for", ] ``` - [x] **Step 5: Run tests to verify they pass** ```bash cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q ``` Expected: all PASS. - [x] **Step 6: Layering contract still green** ```bash cd backend && .venv/bin/lint-imports ``` Expected: Contracts: 1 kept, 0 broken. - [x] **Step 7: Commit** ```bash git add backend/app/domains/storefronts backend/tests/test_storefronts_create.py git commit -m "feat(slice-3): storefronts domain — create + membership + INV-4 guard (§6.5)" ``` ### Task 2: INV-4 invariant tests — concurrency + many-capable schema **Files:** - Test: `backend/tests/test_storefronts_invariants.py` - [x] **Step 1: Write the tests** ```python """INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the membership schema staying many-capable (the rule is a deletable guard, not a schema law).""" import threading import psycopg import pytest from app.domains import storefronts from app.platform import db @pytest.fixture() def migrated_url(fresh_db_url): with psycopg.connect(fresh_db_url) as conn: db.migrate(conn) return fresh_db_url def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url): with psycopg.connect(migrated_url) as setup: acct = setup.execute( "INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id" ).fetchone()[0] setup.commit() barrier = threading.Barrier(2) results: list[str] = [] def racer(): with psycopg.connect(migrated_url) as conn: barrier.wait() try: storefronts.create_storefront(conn, acct, "racer@example.com", None) results.append("created") except storefronts.AlreadyOwnsStorefront: results.append("refused") threads = [threading.Thread(target=racer) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() assert sorted(results) == ["created", "refused"] with psycopg.connect(migrated_url) as check: count = check.execute( "SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,) ).fetchone()[0] assert count == 1 def test_inv_4_schema_stays_many_capable(migrated_url): # No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on # storefront_membership is its composite PK, so many-per-account stays a deletable # service rule (R-5 mitigation). with psycopg.connect(migrated_url) as conn: uniques = conn.execute( "SELECT i.indkey::text FROM pg_index i" " JOIN pg_class c ON c.oid = i.indrelid" " WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)" ).fetchall() # exactly one unique index (the composite PK over two columns) assert len(uniques) == 1 assert len(uniques[0][0].split()) == 2 ``` - [x] **Step 2: Run them** ```bash cd backend && .venv/bin/python -m pytest tests/test_storefronts_invariants.py -q ``` Expected: both PASS (Task 1 implementation already serializes via the advisory lock; if the concurrency test hangs or both create, the guard is broken — fix before proceeding). - [x] **Step 3: Commit** ```bash git add backend/tests/test_storefronts_invariants.py git commit -m "test(slice-3): INV-4 invariants — concurrent-create refusal + many-capable membership schema" ``` ### Task 3: BFF — POST /api/storefronts + the _storefront_for seam (TDD) **Files:** - Modify: `backend/app/main.py` - Test: `backend/tests/test_storefronts_create.py` (append endpoint scenario tests) - [x] **Step 1: Write failing endpoint scenario tests** (append to `backend/tests/test_storefronts_create.py`) ```python # ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ────────── # (executed form: a @contextmanager wrapping `with TestClient(...)` — manually calling # __enter__ then re-entering via `with` closes the lifespan pool early) @contextmanager def _signed_in_client(fresh_db_url, email="merchant@example.com"): with TestClient(create_app(database_url=fresh_db_url)) as client: client.post("/api/auth/request-code", json={"email": email}) code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) client.post("/api/auth/verify", json={"email": email, "code": code}) yield client def test_14_01_0013_create_storefront_with_name(fresh_db_url): with _signed_in_client(fresh_db_url) as client: resp = client.post("/api/storefronts", json={"name": "Ben's Bets"}) assert resp.status_code == 201 assert resp.json()["name"] == "Ben's Bets" assert isinstance(resp.json()["id"], int) def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url): # No payment method, plan, or commitment: the request body needs nothing at all and # the response carries only the storefront — no plan/trial/billing fields. with _signed_in_client(fresh_db_url) as client: resp = client.post("/api/storefronts", json={}) assert resp.status_code == 201 assert set(resp.json().keys()) == {"id", "name"} def test_14_01_0025_create_lands_on_admin(fresh_db_url): # After create, the entry-routing answer carries the storefront -> admin (PUC-6). with _signed_in_client(fresh_db_url) as client: created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json() me = client.get("/api/auth/me").json() assert me["storefront"] == created def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url): with _signed_in_client(fresh_db_url) as client: me = client.get("/api/auth/me").json() assert me["storefront"] is None def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url): # PUC-6: a returning merchant's verify response already carries the storefront. import psycopg from datetime import datetime, timedelta, timezone email = "returning@example.com" with _signed_in_client(fresh_db_url, email) as client: client.post("/api/storefronts", json={"name": "Ben's Bets"}) # fresh login: backdate the consumed code to clear the 60s resend cooldown with psycopg.connect(fresh_db_url) as c: c.execute( "UPDATE auth_code SET created_at = %s WHERE email = %s", (datetime.now(timezone.utc) - timedelta(minutes=2), email), ) c.commit() client.post("/api/auth/request-code", json={"email": email}) code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) resp = client.post("/api/auth/verify", json={"email": email, "code": code}) assert resp.status_code == 200 assert resp.json()["storefront"]["name"] == "Ben's Bets" assert resp.json()["created"] is False def test_puc_07_second_create_refused_409(fresh_db_url): with _signed_in_client(fresh_db_url) as client: client.post("/api/storefronts", json={"name": "First"}) resp = client.post("/api/storefronts", json={"name": "Second"}) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "already_owns_storefront" def test_puc_08_admin_shell_answer(fresh_db_url): # The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8). with _signed_in_client(fresh_db_url) as client: client.post("/api/storefronts", json={"name": "Ben's Bets"}) me = client.get("/api/auth/me").json() assert me["account"]["email"] == "merchant@example.com" assert me["storefront"]["name"] == "Ben's Bets" def test_create_storefront_requires_session(fresh_db_url): with TestClient(create_app(database_url=fresh_db_url)) as client: resp = client.post("/api/storefronts", json={"name": "Nope"}) assert resp.status_code == 401 assert resp.json()["error"]["code"] == "unauthenticated" ``` - [x] **Step 2: Run to verify the new tests fail** ```bash cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q ``` Expected: the endpoint tests FAIL with 404/405 (`POST /api/storefronts` absent); Task-1 service tests still pass. - [x] **Step 3: Wire the BFF** In `backend/app/main.py`: 1. Module docstring: replace the last sentence (`storefront is hard-wired null this slice; the storefronts domain (SLICE-3) replaces the _storefront_for seam.`) with `SLICE-3 adds POST /api/storefronts and feeds the _storefront_for seam from the storefronts domain.` 2. Import the domain: `from app.domains import accounts, storefronts` (replacing the accounts-only import). 3. Add the request model next to `VerifyBody`: ```python class CreateStorefrontBody(BaseModel): name: str | None = None ``` 4. Replace `_storefront_for`'s body (keep the signature): ```python def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None: """The entry-routing answer: which storefront, if any, this account has (§6.5).""" sf = storefronts.storefront_for(conn, account.id) return {"id": sf.id, "name": sf.name} if sf else None ``` 5. Bump `FastAPI(title="ecomm", version="0.2", ...)` to `version="0.3"`. 6. Add the endpoint after `logout`: ```python @app.post("/api/storefronts") def create_storefront( body: CreateStorefrontBody, conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session), ): """Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal).""" if sess is None: return _error(401, "unauthenticated", "You are not signed in.") account = accounts.get_account(conn, sess["account_id"]) if account is None: return _error(401, "unauthenticated", "You are not signed in.") try: sf = storefronts.create_storefront(conn, account.id, account.email, body.name) except storefronts.AlreadyOwnsStorefront: return _error( 409, "already_owns_storefront", "Your account already has its storefront — ecomm is one storefront per account today.", ) return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name}) ``` - [x] **Step 4: Run the whole backend suite + layering** ```bash cd backend && .venv/bin/python -m pytest -q && .venv/bin/lint-imports ``` Expected: all tests PASS (incl. the SLICE-2 suites — the seam change keeps `storefront: null` for storefront-less accounts), contract kept. - [x] **Step 5: Commit** ```bash git add backend/app/main.py backend/tests/test_storefronts_create.py git commit -m "feat(slice-3): POST /api/storefronts + storefronts-fed entry routing (§6.4/§6.5)" ``` ### Task 4: INV-1 whole-flow bootstrap test **Files:** - Test: `backend/tests/test_bootstrap.py` - [x] **Step 1: Write the test** ```python """INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole flow — request-code -> verify -> create-storefront -> /me — asserting no step needed seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py.""" import re from fastapi.testclient import TestClient from app.main import create_app def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url): # fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7). with TestClient(create_app(database_url=fresh_db_url)) as client: # a fresh deployment serves healthz green before any row exists assert client.get("/healthz").json() == {"status": "ok"} # PUC-2: first visitor requests a code; it reaches them via the dev channel assert client.post( "/api/auth/request-code", json={"email": "first@example.com"} ).status_code == 204 code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) # verify creates account #1 — the first row, through the product alone verified = client.post( "/api/auth/verify", json={"email": "first@example.com", "code": code} ) assert verified.status_code == 200 assert verified.json()["created"] is True assert verified.json()["storefront"] is None # -> create-storefront (PUC-5) # PUC-4: create the storefront (blank name -> generated default) created = client.post("/api/storefronts", json={}) assert created.status_code == 201 assert created.json()["name"] == "first's storefront" # PUC-6/PUC-8: the admin answer — storefront + email from /me alone me = client.get("/api/auth/me") assert me.status_code == 200 assert me.json() == { "account": {"email": "first@example.com"}, "storefront": created.json(), } ``` - [x] **Step 2: Run it** ```bash cd backend && .venv/bin/python -m pytest tests/test_bootstrap.py -q ``` Expected: PASS. - [x] **Step 3: Commit** ```bash git add backend/tests/test_bootstrap.py git commit -m "test(slice-3): INV-1 whole-flow bootstrap test — empty DB to admin answer (§6.8)" ``` ### Task 5: Frontend design foundation — tokens, fonts, brand assets, app.css, UI kit **Files:** - Create: `frontend/public/fonts/` (5 woff2 + nothing else), `frontend/public/brand/` (2 svg), `frontend/src/styles/{fonts,tokens-colors,tokens-typography,tokens-spacing,app,index}.css`, `frontend/src/ui/kit.tsx`, `frontend/src/ui/CodeInput.tsx` - Modify: `frontend/src/main.tsx`, `frontend/index.html` - [x] **Step 1: Copy the design-system artifacts** ```bash DS="/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8" PROJ="/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project" mkdir -p frontend/public/fonts frontend/public/brand frontend/src/styles frontend/src/ui cp "$DS"/assets/fonts/space-grotesk-v22-latin-500.woff2 "$DS"/assets/fonts/space-grotesk-v22-latin-700.woff2 \ "$DS"/assets/fonts/inter-v20-latin-regular.woff2 "$DS"/assets/fonts/inter-v20-latin-500.woff2 \ "$DS"/assets/fonts/inter-v20-latin-600.woff2 frontend/public/fonts/ cp "$PROJ"/assets/mark-tile.svg "$PROJ"/assets/mark-mono-gold.svg frontend/public/brand/ cp "$DS"/tokens/colors.css frontend/src/styles/tokens-colors.css cp "$DS"/tokens/typography.css frontend/src/styles/tokens-typography.css cp "$DS"/tokens/spacing.css frontend/src/styles/tokens-spacing.css ``` (Fraunces is not copied — no screen in this slice uses the human register; add it when one does, YAGNI.) - [x] **Step 2: Write `frontend/src/styles/fonts.css`** (paths point at `/fonts/`; weights per the DS manifest) ```css /* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) + Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */ @font-face { font-family: "Space Grotesk"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2"); } @font-face { font-family: "Space Grotesk"; font-style: normal; font-weight: 700; font-display: swap; src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2"); } @font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2"); } @font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/inter-v20-latin-500.woff2") format("woff2"); } @font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/inter-v20-latin-600.woff2") format("woff2"); } ``` - [x] **Step 3: Write `frontend/src/styles/app.css`** — the hf-kit primitives as classes (key values from `hf-kit.jsx`: starfield/horizon ground, 68px glass topbar, raised auth card, 52px fields with gold focus ring, gold pill button, gold/lilac banners, hairline footer, 6-cell code input) ```css /* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS. Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */ * { box-sizing: border-box; } html, body, #root { height: 100%; } body { margin: 0; background: var(--wv-midnight); color: var(--wv-starlight); font-family: var(--wv-font-body); -webkit-font-smoothing: antialiased; } /* ── screen ground: barely-there starfield + optional gold horizon ─────────── */ .screen { min-height: 100%; display: flex; flex-direction: column; position: relative; background: radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent), radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent), radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent), radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent), radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent), radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent), var(--wv-midnight); } .screen--horizon { background: radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%), radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent), radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent), radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent), radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent), radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent), radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent), var(--wv-midnight); } .screen--plain { background: var(--wv-midnight); } .screen__main { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 24px; } /* ── top bar: glass chrome ──────────────────────────────────────────────────── */ .topbar { flex: 0 0 auto; height: 68px; padding: 0 36px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border-soft); background: var(--glass-sky); backdrop-filter: blur(var(--glass-blur)); position: relative; z-index: 5; } .topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; } /* ── wordmark ───────────────────────────────────────────────────────────────── */ .wordmark { display: inline-flex; align-items: center; gap: 10px; } .wordmark img { display: block; border-radius: 6px; } .wordmark__name { font-family: var(--wv-font-display); font-weight: var(--weight-bold); font-size: 22px; letter-spacing: var(--tracking-display); color: var(--wv-starlight); } /* ── links & text buttons ───────────────────────────────────────────────────── */ .lk { color: var(--wv-lilac); text-decoration: none; background: none; border: none; padding: 0; font: inherit; cursor: pointer; transition: color var(--dur-fast) var(--ease); } .lk:hover { color: var(--wv-gold); } .lk--mute { color: var(--text-on-dark-mute); } .lk--nav { font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: 14px; color: var(--wv-starlight); } .signout { font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: 14px; color: var(--wv-starlight); background: transparent; border: none; cursor: pointer; padding: 0; transition: color var(--dur-fast) var(--ease); } .signout:hover { color: var(--wv-gold); } /* ── account chip (top-bar right, signed in) ────────────────────────────────── */ .chip { display: flex; align-items: center; gap: 14px; min-width: 0; } .chip__avatar { width: 30px; height: 30px; border-radius: 15px; background: var(--wv-lilac-16); border: 1px solid var(--border-card); display: flex; align-items: center; justify-content: center; font-family: var(--wv-font-display); font-weight: var(--weight-bold); font-size: 13px; flex: 0 0 auto; } .chip__email { font-size: 14px; color: var(--text-on-dark-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; } /* ── eyebrow ────────────────────────────────────────────────────────────────── */ .eyebrow { font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: var(--text-eyebrow); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; color: var(--wv-lilac); margin: 0; } /* ── auth card — the one surface with a real shadow ─────────────────────────── */ .card { width: 440px; max-width: 100%; background: var(--surface-raised); border: 1px solid var(--border-card); border-radius: var(--radius-card); padding: 40px 40px 34px; display: flex; flex-direction: column; gap: 22px; box-shadow: var(--shadow-soft); position: relative; z-index: 2; } .card h1 { font-family: var(--wv-font-display); font-weight: var(--weight-bold); letter-spacing: var(--tracking-display); font-size: 27px; line-height: 1.08; margin: 0 0 9px; } .card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; } /* ── fields ─────────────────────────────────────────────────────────────────── */ .field { display: flex; flex-direction: column; gap: 8px; } .field__label { font-weight: var(--weight-medium); font-size: 13.5px; color: var(--text-on-dark-soft); display: flex; justify-content: space-between; align-items: baseline; } .field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); } .field__input { height: 52px; border-radius: 10px; padding: 0 16px; background: rgba(237, 234, 255, .035); border: 1.5px solid var(--border-soft); font-family: var(--wv-font-body); font-size: 16px; color: var(--wv-starlight); width: 100%; transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease); } .field__input::placeholder { color: var(--text-on-dark-mute); } .field__input:hover { border-color: var(--border-strong); } .field__input:focus { outline: none; border-color: var(--wv-gold); box-shadow: 0 0 0 3px var(--wv-gold-28); background: rgba(237, 234, 255, .06); } .field__input--error { border-color: var(--wv-gold); } .note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; } .note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; } .note--attn::before { content: "◆"; font-size: 11px; } /* ── primary button: gold pill ──────────────────────────────────────────────── */ .btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: .4em; width: 100%; font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: 15.5px; line-height: 1; padding: .85rem 1.4rem; border-radius: var(--radius-pill); border: var(--btn-border-w) solid transparent; background: var(--cta); color: var(--cta-text); cursor: pointer; transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); } .btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); } .btn-primary:disabled { opacity: .5; cursor: not-allowed; } .btn-primary--auto { width: auto; } .btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } /* ── banner: gold attention / lilac info (no red in the palette) ────────────── */ .banner { display: flex; gap: 12px; padding: 14px 16px; border-radius: 12px; align-items: flex-start; font-size: 14px; line-height: 1.5; color: var(--text-on-dark-soft); } .banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); } .banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); } .banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; } .banner--attn .banner__icon { color: var(--wv-gold); } .banner--info .banner__icon { color: var(--wv-lilac); } .banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; } /* ── code input: 6 cells over one invisible input ───────────────────────────── */ .code { position: relative; display: flex; gap: 11px; justify-content: center; } .code__hidden { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; border: none; font-size: 16px; cursor: pointer; } .code__cell { width: 52px; height: 64px; border-radius: 11px; display: flex; align-items: center; justify-content: center; background: rgba(237, 234, 255, .035); border: 1.5px solid var(--border-soft); font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: 26px; transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease); } .code--focus .code__cell--active { border-color: var(--wv-gold); box-shadow: 0 0 0 3px var(--wv-gold-28); } .code--error .code__cell { border-color: var(--wv-gold); } /* ── footer ─────────────────────────────────────────────────────────────────── */ .footer { flex: 0 0 auto; padding: 20px 36px; border-top: 1px solid var(--border-soft); display: flex; justify-content: space-between; align-items: center; gap: 16px; font-size: 12.5px; color: var(--text-on-dark-mute); background: rgba(9, 12, 34, .4); } .footer__id { display: inline-flex; align-items: center; gap: 9px; } .footer__id img { opacity: .85; } /* ── landing ────────────────────────────────────────────────────────────────── */ .hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; } .hero h1 { font-family: var(--wv-font-display); font-weight: var(--weight-bold); letter-spacing: -0.02em; line-height: 1.02; font-size: clamp(36px, 6.5vw, 62px); margin: 20px 0 22px; } .hero h1 em { font-style: normal; color: var(--wv-gold); } .hero__lead { font-size: clamp(15.5px, 1.8vw, 18.5px); line-height: 1.6; color: var(--text-on-dark-soft); max-width: 520px; margin: 0 0 32px; text-wrap: pretty; } .hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; } .promises { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; margin-top: 44px; border-top: 1px solid var(--border-soft); padding-top: 24px; text-align: left; } .promises > div { padding: 0 22px; } .promises > div + div { border-left: 1px solid var(--border-soft); } .promises__title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; font-family: var(--wv-font-display); font-weight: var(--weight-medium); font-size: 14.5px; } .promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; } .promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; } /* ── admin shell ────────────────────────────────────────────────────────────── */ .storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; } .storeid img { display: block; border-radius: 7px; flex: 0 0 auto; } .storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; } .storeid__name { font-family: var(--wv-font-display); font-weight: var(--weight-bold); font-size: 17px; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); } .empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; } .empty__seal { width: 76px; height: 76px; border-radius: 50%; border: 1px solid var(--border-card); display: flex; align-items: center; justify-content: center; margin-bottom: 26px; background: var(--wv-lilac-08); } .empty h1 { font-family: var(--wv-font-display); font-weight: var(--weight-bold); letter-spacing: var(--tracking-display); line-height: 1.04; font-size: clamp(30px, 4.5vw, 42px); margin: 12px 0 16px; } .empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; } /* ── small screens ──────────────────────────────────────────────────────────── */ @media (max-width: 720px) { .topbar { padding: 0 20px; } .footer { padding: 16px 20px; flex-direction: column; } .card { padding: 28px 22px 24px; } .promises { grid-template-columns: 1fr; gap: 14px; } .promises > div { padding: 0; } .promises > div + div { border-left: none; } .code__cell { width: 44px; height: 56px; font-size: 22px; } } ``` - [x] **Step 4: Write `frontend/src/styles/index.css`** (the one file the app imports) ```css /* Global style entry point — Wiggleverse design-system tokens (copied from the ui/designs export) + the ecomm app chrome built on them. */ @import "./fonts.css"; @import "./tokens-colors.css"; @import "./tokens-typography.css"; @import "./tokens-spacing.css"; @import "./app.css"; ``` - [x] **Step 5: Import styles in `frontend/src/main.tsx`** (add as the first import) ```tsx import "./styles/index.css"; ``` - [x] **Step 6: Set the ground color in `frontend/index.html`** (avoid a white flash before CSS loads; head additions) ```html ``` - [x] **Step 7: Write `frontend/src/ui/kit.tsx`** — the shared primitives ```tsx // The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design // export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation // only: no business rules live here (§6.2 — the SPA renders what the BFF returns). import type { ReactNode } from "react"; export function Screen({ children, horizon = false, plain = false, }: { children: ReactNode; horizon?: boolean; plain?: boolean; }) { const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen"; return
{children}
; } export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) { return (
{left}
{right}
); } export function Wordmark({ size = 26 }: { size?: number }) { return ( ecomm ); } export function Footer() { return ( ); } export function AuthCard({ children }: { children: ReactNode }) { return
{children}
; } export function Eyebrow({ children }: { children: ReactNode }) { return

{children}

; } export function Banner({ tone = "attn", title, children, }: { tone?: "attn" | "info"; title?: string; children: ReactNode; }) { return (
{title &&
{title}
} {children}
); } export function PrimaryButton({ children, busy = false, disabled = false, type = "submit", onClick, }: { children: ReactNode; busy?: boolean; disabled?: boolean; type?: "submit" | "button"; onClick?: () => void; }) { return ( ); } export function Field({ label, optional = false, error, helper, inputProps, }: { label: string; optional?: boolean; error?: string | null; helper?: string; inputProps: React.InputHTMLAttributes; }) { return (
{error && (

{error}

)} {helper && !error &&

{helper}

}
); } export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) { return (
{email}
); } ``` - [x] **Step 8: Write `frontend/src/ui/CodeInput.tsx`** — 6 cells over one invisible input (one-time-code semantics so password managers/OS autofill cooperate, §6.6) ```tsx // Six-cell one-time-code input: one real (invisible, full-bleed) carries the value // and the OTC autofill semantics; the cells are presentation. Click anywhere to focus; // paste works because the input is real. import { useState } from "react"; const LENGTH = 6; export default function CodeInput({ value, onChange, error = false, }: { value: string; onChange: (code: string) => void; error?: boolean; }) { const [focused, setFocused] = useState(false); const digits = value.slice(0, LENGTH).split(""); const active = Math.min(digits.length, LENGTH - 1); return (
setFocused(true)} onBlur={() => setFocused(false)} onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))} /> {Array.from({ length: LENGTH }, (_, i) => ( ))}
); } ``` - [x] **Step 9: Typecheck + build to verify the foundation stands** ```bash cd frontend && npm run build ``` Expected: clean build (the kit is not yet imported anywhere — that's fine; tsc checks it). - [x] **Step 10: Commit** ```bash git add frontend/public frontend/src/styles frontend/src/ui frontend/src/main.tsx frontend/index.html git commit -m "feat(slice-3): design-system foundation — Wiggleverse tokens, fonts, brand marks, UI kit (per ui/designs export)" ``` ### Task 6: Landing rebuild (per hf-landing) **Files:** - Modify: `frontend/src/screens/Landing.tsx` - [x] **Step 1: Rebuild the screen** ```tsx // Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice, // no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2); // they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A). import { Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit"; interface Props { onGetStarted: () => void; onLogIn: () => void; } const PROMISES: Array<[string, string]> = [ ["No trial clock", "Your storefront never expires or locks."], ["No plan wall", "We take only what it takes to run."], ["Your data, yours", "Nothing collected you didn't agree to give."], ]; export default function Landing({ onGetStarted, onLogIn }: Props) { return ( } right={ } />
One storefront, fully yours

Sell online,
honestly.

Claim the one storefront that's yours on a platform that takes only what it takes to run — no trial countdown, no plan wall, no data you didn't agree to give.

Create your storefront →
{PROMISES.map(([title, copy]) => (
{title}

{copy}

))}