Files
wiggleverse-ecomm-content/plans/2026-06-10-slice-3-storefront.md

1985 lines
71 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.15.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
<meta name="theme-color" content="#0E1230" />
<style>html { background: #0E1230; }</style>
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
```
- [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 <div className={cls}>{children}</div>;
}
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
return (
<header className="topbar">
<div className="topbar__side">{left}</div>
<div className="topbar__side">{right}</div>
</header>
);
}
export function Wordmark({ size = 26 }: { size?: number }) {
return (
<span className="wordmark">
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
ecomm
</span>
</span>
);
}
export function Footer() {
return (
<footer className="footer">
<span className="footer__id">
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
ecomm · a Wiggleverse line
</span>
</footer>
);
}
export function AuthCard({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
export function Eyebrow({ children }: { children: ReactNode }) {
return <p className="eyebrow">{children}</p>;
}
export function Banner({
tone = "attn",
title,
children,
}: {
tone?: "attn" | "info";
title?: string;
children: ReactNode;
}) {
return (
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
<span className="banner__icon" aria-hidden="true">
{tone === "attn" ? "◆" : "◇"}
</span>
<div>
{title && <div className="banner__title">{title}</div>}
{children}
</div>
</div>
);
}
export function PrimaryButton({
children,
busy = false,
disabled = false,
type = "submit",
onClick,
}: {
children: ReactNode;
busy?: boolean;
disabled?: boolean;
type?: "submit" | "button";
onClick?: () => void;
}) {
return (
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
{children}
</button>
);
}
export function Field({
label,
optional = false,
error,
helper,
inputProps,
}: {
label: string;
optional?: boolean;
error?: string | null;
helper?: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
}) {
return (
<div className="field">
<label className="field__label">
<span>{label}</span>
{optional && <span className="field__optional">optional</span>}
</label>
<input
className={`field__input${error ? " field__input--error" : ""}`}
aria-invalid={!!error}
{...inputProps}
/>
{error && (
<p className="note note--attn" role="alert">
{error}
</p>
)}
{helper && !error && <p className="note">{helper}</p>}
</div>
);
}
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
return (
<div className="chip">
<span className="chip__avatar" aria-hidden="true">
{email[0]?.toUpperCase()}
</span>
<span className="chip__email">{email}</span>
<span className="chip__divider" aria-hidden="true" />
<button type="button" className="signout" onClick={onSignOut}>
Sign out
</button>
</div>
);
}
```
- [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 <input> (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 (
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
<input
className="code__hidden"
value={value}
inputMode="numeric"
autoComplete="one-time-code"
aria-label="One-time code"
autoFocus
maxLength={LENGTH}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
/>
{Array.from({ length: LENGTH }, (_, i) => (
<span
key={i}
aria-hidden="true"
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
>
{digits[i] ?? ""}
</span>
))}
</div>
);
}
```
- [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 (
<Screen horizon>
<TopBar
left={<Wordmark />}
right={
<button type="button" className="lk lk--nav" onClick={onLogIn}>
Log in
</button>
}
/>
<main className="screen__main">
<div className="hero">
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1>
Sell online,
<br />
<em>honestly.</em>
</h1>
<p className="hero__lead">
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.
</p>
<div className="hero__actions">
<PrimaryButton type="button" onClick={onGetStarted}>
Create your storefront
</PrimaryButton>
<button type="button" className="lk lk--mute" onClick={onLogIn}>
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
</button>
</div>
<div className="promises">
{PROMISES.map(([title, copy]) => (
<div key={title}>
<div className="promises__title">{title}</div>
<p>{copy}</p>
</div>
))}
</div>
</div>
</main>
<Footer />
</Screen>
);
}
```
(add `Eyebrow` to the kit import list: `import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";`. The CTA renders auto-width inside `.hero__actions` — add `btn-primary--auto` via a wrapper if it stretches; check visually at Task 10.)
- [x] **Step 2: Build + tests**
```bash
cd frontend && npm run build && npm test
```
Expected: clean build, routing tests still green.
- [x] **Step 3: Commit**
```bash
git add frontend/src/screens/Landing.tsx
git commit -m "feat(slice-3): Landing re-skinned to the Claude Design export (§5.1)"
```
### Task 7: Sign-in rebuild (per hf-signin) — cooldown countdown, code cells, honest states
**Files:**
- Modify: `frontend/src/screens/SignIn.tsx`
- [x] **Step 1: Rebuild the screen**
States per §5.2 + the design's states stack: invalid email → inline field error; sending → busy button; resend cooldown (PUC-2c) → countdown from `retry_after_s`; delivery failure (INV-9) → attn banner; wrong code (PUC-2a) → inline error with attempts remaining; expired (PUC-2b) → inline + resend offer; exhausted → attn banner; resend link cooldown-aware.
```tsx
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
import { useEffect, useState } from "react";
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import CodeInput from "../ui/CodeInput";
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
type Door = "signup" | "login";
interface Props {
door: Door;
onAuthed: (result: VerifyResult) => void;
onBack: () => void;
}
function useCooldown(): [number, (s: number) => void] {
const [left, setLeft] = useState(0);
useEffect(() => {
if (left <= 0) return;
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
return () => clearInterval(t);
}, [left > 0]);
return [left, setLeft];
}
export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [fieldError, setFieldError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [bannerError, setBannerError] = useState<ApiError | null>(null);
const [cooldown, setCooldown] = useCooldown();
function applyError(err: ApiError) {
setFieldError(null);
setCodeError(null);
setBannerError(null);
if (err.code === "invalid_email") setFieldError(err.message);
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
}
async function sendCode(): Promise<boolean> {
setBusy(true);
setFieldError(null);
setBannerError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
applyError(err);
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
}
setCooldown(60);
return true;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code");
}
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
applyError(res.error);
return;
}
onAuthed(res.result);
}
const heading = door === "signup" ? "Create your storefront" : "Log in";
const sub =
door === "signup"
? "Enter your email to begin. There's no password to create."
: "Enter your email and we'll send a one-time code to sign in.";
return (
<Screen horizon>
<TopBar
left={<Wordmark size={22} />}
right={
<button type="button" className="lk lk--mute" onClick={onBack}>
Back
</button>
}
/>
<main className="screen__main">
<AuthCard>
{step === "email" ? (
<form onSubmit={submitEmail} style={{ display: "contents" }}>
<div>
<h1>{heading}</h1>
<p className="card__sub">{sub}</p>
</div>
{bannerError && (
<Banner title="We couldn't send the code">
The email didn't go out. Try again in a moment — nothing was lost.
</Banner>
)}
<Field
label="Email"
error={fieldError}
inputProps={{
type: "email",
value: email,
placeholder: "you@example.com",
autoFocus: true,
required: true,
autoComplete: "email",
onChange: (ev) => setEmail(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldown > 0}>
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
</PrimaryButton>
<p className="note">
We'll email you a one-time code. That's all we need no password, ever.
</p>
</div>
</form>
) : (
<form onSubmit={submitCode} style={{ display: "contents" }}>
<div>
<h1>Check your email</h1>
<p className="card__sub">
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
<button type="button" className="lk" onClick={() => setStep("email")}>
Wrong address?
</button>
</p>
</div>
{bannerError && (
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
{bannerError.code === "code_exhausted"
? "That code is no longer valid. Request a fresh one to keep going."
: bannerError.message}
</Banner>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CodeInput value={code} onChange={setCode} error={!!codeError} />
{codeError && (
<p className="note note--attn" role="alert">
{codeError}
</p>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="note">Good for 10 minutes.</p>
{cooldown > 0 ? (
<span className="note">Resend in {cooldown}s</span>
) : (
<button type="button" className="lk" onClick={resend}>
Resend code
</button>
)}
</div>
</div>
</form>
)}
</AuthCard>
</main>
<Footer />
</Screen>
);
}
```
- [x] **Step 2: Build + tests**
```bash
cd frontend && npm run build && npm test
```
Expected: green.
- [x] **Step 3: Commit**
```bash
git add frontend/src/screens/SignIn.tsx
git commit -m "feat(slice-3): Sign-in re-skinned — code cells, cooldown countdown, honest §5.2 states"
```
### Task 8: Create-storefront screen + API helper
**Files:**
- Modify: `frontend/src/api.ts`
- Create: `frontend/src/screens/CreateStorefront.tsx`
- [x] **Step 1: Add the API helper** (append to `frontend/src/api.ts`)
```ts
export interface StorefrontResult {
id: number;
name: string;
}
export async function createStorefront(
name: string,
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
const resp = await fetch("/api/storefronts", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
});
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
return { ok: false, error: await errorOf(resp) };
}
```
- [x] **Step 2: Write the screen** (`frontend/src/screens/CreateStorefront.tsx`; per §5.3 + hf-storefront — name optional with helper, busy state, already-owns banner with admin link, honest error banner with retry; welcome banner carries PUC-3's honest copy after verify)
```tsx
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
import { useState } from "react";
import { createStorefront, logout, type StorefrontResult } from "../api";
import { AuthCard, AccountChip, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
email: string;
welcome: "new" | "back" | null;
onCreated: (storefront: StorefrontResult) => void;
onAlreadyOwns: () => void;
onSignedOut: () => void;
}
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [alreadyOwns, setAlreadyOwns] = useState(false);
const [error, setError] = useState<string | null>(null);
async function signOut() {
await logout();
onSignedOut();
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await createStorefront(name);
setBusy(false);
if (res.ok) {
onCreated(res.storefront);
return;
}
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
else setError(res.error.message);
}
return (
<Screen horizon>
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
<main className="screen__main">
<AuthCard>
<form onSubmit={submit} style={{ display: "contents" }}>
<div>
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
<p className="card__sub">
This is the one thing to do right now. It costs nothing and commits you to nothing.
</p>
</div>
{welcome && (
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
)}
{alreadyOwns && (
<Banner title="Your account already has its storefront">
ecomm is one storefront per account today.{" "}
<button type="button" className="lk" onClick={onAlreadyOwns}>
Go to your admin
</button>
</Banner>
)}
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
<Field
label="Storefront name"
optional
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
inputProps={{
type: "text",
value: name,
placeholder: "e.g. Ben's Bets",
autoFocus: true,
onChange: (ev) => setName(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
</div>
</form>
</AuthCard>
</main>
<Footer />
</Screen>
);
}
```
- [x] **Step 3: Build**
```bash
cd frontend && npm run build
```
Expected: clean (screen not yet routed; tsc checks it).
- [x] **Step 4: Commit**
```bash
git add frontend/src/api.ts frontend/src/screens/CreateStorefront.tsx
git commit -m "feat(slice-3): Create-storefront screen + API helper (§5.3, PUC-4/5/7)"
```
### Task 9: Admin shell + full entry routing in App
**Files:**
- Create: `frontend/src/screens/Admin.tsx`
- Modify: `frontend/src/App.tsx`
- Delete: `frontend/src/screens/SignedIn.tsx`
- [x] **Step 1: Write the Admin screen** (per §5.4 + hf-admin — storefront name anchors the header, account chip + sign out right, honestly-empty centerpiece, no fabricated anything)
```tsx
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
interface Props {
storefrontName: string;
email: string;
welcome: "new" | "back" | null;
onSignedOut: () => void;
}
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<Screen plain>
<TopBar
left={
<span className="storeid">
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
<span className="storeid__col">
<span className="storeid__name">{storefrontName}</span>
<span className="storeid__sub">ecomm storefront</span>
</span>
</span>
}
right={<AccountChip email={email} onSignOut={signOut} />}
/>
<main className="screen__main">
<div className="empty">
{welcome && (
<div style={{ marginBottom: 24, width: "100%" }}>
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
</div>
)}
<div className="empty__seal" aria-hidden="true">
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
</div>
<Eyebrow>Your storefront</Eyebrow>
<h1>{storefrontName}</h1>
<p className="empty__copy">
There's nothing to manage yet — and that's a finished state, not a missing one.
Catalog, orders, and settings will appear here as ecomm grows.
</p>
</div>
</main>
</Screen>
);
}
```
- [x] **Step 2: Complete the routing in `frontend/src/App.tsx`** (full §6.5 table: the verify response feeds the session directly — it carries the same shape as /me — and `welcome` carries the honest copy to the destination screen)
```tsx
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
// response carries the same shape as /me, so onAuthed feeds the session directly.
import { useEffect, useState } from "react";
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Admin from "./screens/Admin";
import CreateStorefront from "./screens/CreateStorefront";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";
type Door = "signup" | "login";
type Welcome = "new" | "back" | null;
export default function App() {
const [session, setSession] = useState<SessionState | null>(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState<Door | null>(null);
const [welcome, setWelcome] = useState<Welcome>(null);
async function reload() {
setLoading(true);
setSession(await getMe());
setLoading(false);
}
useEffect(() => {
void reload();
}, []);
if (loading) {
return (
<div className="screen screen--plain">
<main className="screen__main">
<p className="note">Loading</p>
</main>
</div>
);
}
const route = routeFor(session ?? { account: null, storefront: null });
if (route === "landing") {
if (door) {
return (
<SignIn
door={door}
onBack={() => setDoor(null)}
onAuthed={(result: VerifyResult) => {
setWelcome(result.created ? "new" : "back");
setDoor(null);
setSession({ account: result.account, storefront: result.storefront });
}}
/>
);
}
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
const email = session!.account!.email;
function signedOut() {
setWelcome(null);
setDoor(null);
setSession(null);
}
if (route === "create-storefront") {
return (
<CreateStorefront
email={email}
welcome={welcome}
onCreated={(sf: StorefrontResult) => {
setWelcome(null);
setSession({ account: { email }, storefront: sf });
}}
onAlreadyOwns={() => void reload()}
onSignedOut={signedOut}
/>
);
}
return (
<Admin
storefrontName={session!.storefront!.name}
email={email}
welcome={welcome}
onSignedOut={signedOut}
/>
);
}
```
- [x] **Step 3: Delete the placeholder**
```bash
git rm frontend/src/screens/SignedIn.tsx
```
- [x] **Step 4: Build + tests**
```bash
cd frontend && npm run build && npm test
```
Expected: green — routing tests already assert all three routes (§6.5 table is unchanged; "admin" merely became reachable).
- [x] **Step 5: Commit**
```bash
git add frontend/src/App.tsx frontend/src/screens/Admin.tsx
git commit -m "feat(slice-3): Admin shell + complete entry routing (§5.4/§6.5, PUC-6/8/9)"
```
### Task 10: Full gate + localhost walkthrough (PUC-10) + housekeeping
**Files:**
- Modify: `README.md`, `frontend/package.json`
- [x] **Step 1: Version bumps**`frontend/package.json` `"version": "0.2.0"``"0.3.0"` (backend FastAPI version already bumped in Task 3).
- [x] **Step 2: README status** — update the status line/section that says the identity slice is in place to say SLICE-3 (storefront) is in place: storefronts domain, create + admin screens, full entry routing, INV-1 whole-flow green; SLICE-4 (deployed environments) is next.
- [x] **Step 3: Run the whole gate**
```bash
./scripts/check.sh
```
Expected: import contract kept; full backend suite green (SLICE-2's 43+3 plus the new storefronts/bootstrap tests); frontend typecheck + build + vitest green.
- [x] **Step 4: Localhost walkthrough (PUC-10, §7.2 definition of done)** — bring the app up and walk sign-up → create storefront → admin end to end against the real stack:
```bash
./scripts/dev.sh
```
Then drive the flow (browser or curl): request a code for a fresh email, read the 6-digit code from the backend log (`ecomm.mailer` INFO line), verify, create a storefront, confirm the admin answer. Screenshot or curl-transcript the result into the session transcript. Stop the dev stack after.
- [x] **Step 5: Commit + push + PR**
```bash
git add README.md frontend/package.json docs/superpowers/plans/2026-06-10-slice-3-storefront.md
git commit -m "chore(slice-3): version 0.3, README status — storefront slice in place"
git push -u origin slice-3-storefront
```
Open the PR against `main` titled `SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2)`, body citing SD-0001 §7.2 SLICE-3 definition of done; merge per autonomous posture once green.
---
## Self-review notes
- **Spec coverage:** PUC-4 (Task 1/3/8), PUC-5 (Task 3 test + routing), PUC-6 (Task 3 test `14_01_0027` + Task 9), PUC-7 (Tasks 13 guard/409 + no second-create affordance anywhere in the UI), PUC-8 (Task 3 `puc_08` test + Task 9 Admin), corpus 0013/0015/0016/0025/0026/0027 (Task 3), INV-4 concurrent refusal (Task 2), INV-1 whole-flow (Task 4), §5.15.4 visuals incl. slices 12 re-skin (Tasks 59), localhost PUC-10 walkable (Task 10).
- **Deliberate divergences** (logged to Deferred decisions): no dead `#` links from the design; welcome banner moves to the destination screen; Fraunces not shipped (unused); E2E browser tests deferred per §6.8.
- **Type consistency:** `StorefrontResult {id, name}` (api.ts) = the 201 body = `SessionState.storefront` shape; `VerifyResult` already carries `storefront | null`; `welcome: "new" | "back" | null` threaded App → CreateStorefront/Admin.