diff --git a/README.md b/README.md
index 7fd0807..1d24904 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,12 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
## Status
-SLICE-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open,
-passwordless sign-up/log-in by email + one-time code (`accounts` domain, INV-2/INV-3),
-signed-cookie sessions, the `/api/auth/*` endpoints (`request-code`, `verify`, `me`,
-`logout`), the `platform/mailer` port with `LogMailer` (the dev code channel), and the
-Landing + Sign-in screens with the entry-routing rule (storefront hard-wired `null` until
-SLICE-3). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. The storefront
-(SLICE-3) and deployed environments (SLICE-4) are next.
+SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
+domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
+refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
+complete entry-routing rule (landing / create-storefront / admin), and the INV-1
+whole-flow bootstrap test — all skinned to the Claude Design export
+(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
+which also re-skinned the SLICE-2 Landing + Sign-in screens. See
+[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. Deployed environments
+(SLICE-4) are next.
diff --git a/backend/app/domains/storefronts/__init__.py b/backend/app/domains/storefronts/__init__.py
new file mode 100644
index 0000000..d2db7f1
--- /dev/null
+++ b/backend/app/domains/storefronts/__init__.py
@@ -0,0 +1,19 @@
+"""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",
+]
diff --git a/backend/app/domains/storefronts/errors.py b/backend/app/domains/storefronts/errors.py
new file mode 100644
index 0000000..699f1a7
--- /dev/null
+++ b/backend/app/domains/storefronts/errors.py
@@ -0,0 +1,10 @@
+"""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)."""
diff --git a/backend/app/domains/storefronts/models.py b/backend/app/domains/storefronts/models.py
new file mode 100644
index 0000000..4bedb2f
--- /dev/null
+++ b/backend/app/domains/storefronts/models.py
@@ -0,0 +1,10 @@
+"""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
diff --git a/backend/app/domains/storefronts/service.py b/backend/app/domains/storefronts/service.py
new file mode 100644
index 0000000..4249dc2
--- /dev/null
+++ b/backend/app/domains/storefronts/service.py
@@ -0,0 +1,56 @@
+"""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.
+"""
+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)
diff --git a/backend/app/main.py b/backend/app/main.py
index 3f89071..84a43aa 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,8 +3,8 @@
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
-the configured mailer (INV-8) at startup. storefront is hard-wired null this slice; the
-storefronts domain (SLICE-3) replaces the _storefront_for seam.
+the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
+_storefront_for seam from the storefronts domain.
"""
from __future__ import annotations
@@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
-from app.domains import accounts
+from app.domains import accounts, storefronts
from app.platform import config, db
from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
@@ -35,18 +35,19 @@ class VerifyBody(BaseModel):
code: str
+class CreateStorefrontBody(BaseModel):
+ name: str | None = None
+
+
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
- """The entry-routing answer: which storefront, if any, this account has (§6.5).
-
- Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
- domain call; the verify/me responses already carry the field.
- """
- return 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
def _ensure_app_logging() -> None:
@@ -93,7 +94,7 @@ def create_app(database_url: str | None = None) -> FastAPI:
finally:
app.state.pool.close()
- app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan)
+ app = FastAPI(title="ecomm", version="0.3", lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
@@ -170,6 +171,27 @@ def create_app(database_url: str | None = None) -> FastAPI:
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
+ @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})
+
return app
diff --git a/backend/tests/test_bootstrap.py b/backend/tests/test_bootstrap.py
new file mode 100644
index 0000000..19ffac6
--- /dev/null
+++ b/backend/tests/test_bootstrap.py
@@ -0,0 +1,42 @@
+"""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(),
+ }
diff --git a/backend/tests/test_storefronts_create.py b/backend/tests/test_storefronts_create.py
new file mode 100644
index 0000000..1160a69
--- /dev/null
+++ b/backend/tests/test_storefronts_create.py
@@ -0,0 +1,154 @@
+"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
+import re
+from contextlib import contextmanager
+
+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
+
+
+# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
+
+
+@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.
+ 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"
diff --git a/backend/tests/test_storefronts_invariants.py b/backend/tests/test_storefronts_invariants.py
new file mode 100644
index 0000000..6ce0e60
--- /dev/null
+++ b/backend/tests/test_storefronts_invariants.py
@@ -0,0 +1,64 @@
+"""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
diff --git a/docs/superpowers/plans/2026-06-10-slice-3-storefront.md b/docs/superpowers/plans/2026-06-10-slice-3-storefront.md
new file mode 100644
index 0000000..2e04dfc
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-10-slice-3-storefront.md
@@ -0,0 +1,1984 @@
+# 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 (
+
+ );
+}
+
+export function Wordmark({ size = 26 }: { size?: number }) {
+ return (
+
+
+
+ ecomm
+
+
+ );
+}
+
+export function Footer() {
+ return (
+
+
+
+ ecomm · a Wiggleverse line
+
+
+ );
+}
+
+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 (
+
+
+ {tone === "attn" ? "◆" : "◇"}
+
+
+ {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 (
+
+ {children}
+
+ );
+}
+
+export function Field({
+ label,
+ optional = false,
+ error,
+ helper,
+ inputProps,
+}: {
+ label: string;
+ optional?: boolean;
+ error?: string | null;
+ helper?: string;
+ inputProps: React.InputHTMLAttributes;
+}) {
+ return (
+
+
+ {label}
+ {optional && optional }
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {helper && !error &&
{helper}
}
+
+ );
+}
+
+export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
+ return (
+
+
+ {email[0]?.toUpperCase()}
+
+ {email}
+
+
+ Sign out
+
+
+ );
+}
+```
+
+- [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) => (
+
+ {digits[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={
+
+ Log in
+
+ }
+ />
+
+
+
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 →
+
+
+ Already selling with us? Log in
+
+
+
+ {PROMISES.map(([title, copy]) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+```
+
+(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(null);
+ const [codeError, setCodeError] = useState(null);
+ const [bannerError, setBannerError] = useState(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 {
+ 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 (
+
+ }
+ right={
+
+ ← Back
+
+ }
+ />
+
+
+ {step === "email" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+```
+
+- [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(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 (
+
+ } right={ } />
+
+
+
+
+
+
+
+ );
+}
+```
+
+- [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 (
+
+
+
+
+ {storefrontName}
+ ecomm storefront
+
+
+ }
+ right={ }
+ />
+
+
+ {welcome && (
+
+
+ {welcome === "new"
+ ? "A new account was created for this email."
+ : "Signed in to your existing account."}
+
+
+ )}
+
+
+
+
Your storefront
+
{storefrontName}
+
+ 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.
+
+
+
+
+ );
+}
+```
+
+- [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(null);
+ const [loading, setLoading] = useState(true);
+ const [door, setDoor] = useState(null);
+ const [welcome, setWelcome] = useState(null);
+
+ async function reload() {
+ setLoading(true);
+ setSession(await getMe());
+ setLoading(false);
+ }
+
+ useEffect(() => {
+ void reload();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ const route = routeFor(session ?? { account: null, storefront: null });
+
+ if (route === "landing") {
+ if (door) {
+ return (
+ setDoor(null)}
+ onAuthed={(result: VerifyResult) => {
+ setWelcome(result.created ? "new" : "back");
+ setDoor(null);
+ setSession({ account: result.account, storefront: result.storefront });
+ }}
+ />
+ );
+ }
+ return setDoor("signup")} onLogIn={() => setDoor("login")} />;
+ }
+
+ const email = session!.account!.email;
+
+ function signedOut() {
+ setWelcome(null);
+ setDoor(null);
+ setSession(null);
+ }
+
+ if (route === "create-storefront") {
+ return (
+ {
+ setWelcome(null);
+ setSession({ account: { email }, storefront: sf });
+ }}
+ onAlreadyOwns={() => void reload()}
+ onSignedOut={signedOut}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
+```
+
+- [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 1–3 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.1–5.4 visuals incl. slices 1–2 re-skin (Tasks 5–9), 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.
diff --git a/frontend/index.html b/frontend/index.html
index 1a13c4f..567778d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,6 +3,9 @@
+
+
+
ecomm
diff --git a/frontend/package.json b/frontend/package.json
index 8fc2c37..11eda0d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
- "version": "0.2.0",
+ "version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/public/brand/mark-mono-gold.svg b/frontend/public/brand/mark-mono-gold.svg
new file mode 100644
index 0000000..cc7fde2
--- /dev/null
+++ b/frontend/public/brand/mark-mono-gold.svg
@@ -0,0 +1,8 @@
+
+ Wiggleverse — mono gold
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/brand/mark-tile.svg b/frontend/public/brand/mark-tile.svg
new file mode 100644
index 0000000..51d6c69
--- /dev/null
+++ b/frontend/public/brand/mark-tile.svg
@@ -0,0 +1,9 @@
+
+ Wiggleverse
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/fonts/inter-v20-latin-500.woff2 b/frontend/public/fonts/inter-v20-latin-500.woff2
new file mode 100644
index 0000000..54f0a59
Binary files /dev/null and b/frontend/public/fonts/inter-v20-latin-500.woff2 differ
diff --git a/frontend/public/fonts/inter-v20-latin-600.woff2 b/frontend/public/fonts/inter-v20-latin-600.woff2
new file mode 100644
index 0000000..d189794
Binary files /dev/null and b/frontend/public/fonts/inter-v20-latin-600.woff2 differ
diff --git a/frontend/public/fonts/inter-v20-latin-regular.woff2 b/frontend/public/fonts/inter-v20-latin-regular.woff2
new file mode 100644
index 0000000..f15b025
Binary files /dev/null and b/frontend/public/fonts/inter-v20-latin-regular.woff2 differ
diff --git a/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-500.woff2
new file mode 100644
index 0000000..0db251f
Binary files /dev/null and b/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 differ
diff --git a/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-700.woff2
new file mode 100644
index 0000000..44604a0
Binary files /dev/null and b/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5bc0631..6ddc626 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,20 +1,22 @@
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
-// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a
-// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3.
+// 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 VerifyResult } from "./api";
+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";
-import SignedIn from "./screens/SignedIn";
type Door = "signup" | "login";
+type Welcome = "new" | "back" | null;
export default function App() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState(null);
- const [justCreated, setJustCreated] = useState(false);
+ const [welcome, setWelcome] = useState(null);
async function reload() {
setLoading(true);
@@ -26,7 +28,15 @@ export default function App() {
void reload();
}, []);
- if (loading) return Loading… ;
+ if (loading) {
+ return (
+
+ );
+ }
const route = routeFor(session ?? { account: null, storefront: null });
@@ -35,13 +45,11 @@ export default function App() {
return (
{
- setDoor(null);
- }}
+ onBack={() => setDoor(null)}
onAuthed={(result: VerifyResult) => {
- setJustCreated(result.created);
+ setWelcome(result.created ? "new" : "back");
setDoor(null);
- void reload();
+ setSession({ account: result.account, storefront: result.storefront });
}}
/>
);
@@ -49,16 +57,35 @@ export default function App() {
return setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
- // route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the
- // signed-in placeholder so sign-out (PUC-9) is reachable.
+ const email = session!.account!.email;
+
+ function signedOut() {
+ setWelcome(null);
+ setDoor(null);
+ setSession(null);
+ }
+
+ if (route === "create-storefront") {
+ return (
+ {
+ setWelcome(null);
+ setSession({ account: { email }, storefront: sf });
+ }}
+ onAlreadyOwns={() => void reload()}
+ onSignedOut={signedOut}
+ />
+ );
+ }
+
return (
- {
- setJustCreated(false);
- void reload();
- }}
+
);
}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 04d7ccf..1e6c4f5 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -59,3 +59,21 @@ export async function verifyCode(
export async function logout(): Promise {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
}
+
+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) };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index f8fc6f5..0b18e9d 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,3 +1,4 @@
+import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
diff --git a/frontend/src/screens/Admin.tsx b/frontend/src/screens/Admin.tsx
new file mode 100644
index 0000000..aac9afb
--- /dev/null
+++ b/frontend/src/screens/Admin.tsx
@@ -0,0 +1,59 @@
+// 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 (
+
+
+
+
+ {storefrontName}
+ ecomm storefront
+
+
+ }
+ right={ }
+ />
+
+
+ {welcome && (
+
+
+ {welcome === "new"
+ ? "A new account was created for this email."
+ : "Signed in to your existing account."}
+
+
+ )}
+
+
+
+
Your storefront
+
{storefrontName}
+
+ 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.
+
+
+
+
+ );
+}
diff --git a/frontend/src/screens/CreateStorefront.tsx b/frontend/src/screens/CreateStorefront.tsx
new file mode 100644
index 0000000..4b521ef
--- /dev/null
+++ b/frontend/src/screens/CreateStorefront.tsx
@@ -0,0 +1,92 @@
+// 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 { AccountChip, AuthCard, 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(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 (
+
+ } right={ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/screens/Landing.tsx b/frontend/src/screens/Landing.tsx
index f1b54e5..7e5ca5d 100644
--- a/frontend/src/screens/Landing.tsx
+++ b/frontend/src/screens/Landing.tsx
@@ -1,27 +1,61 @@
// 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.
+// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
+import { Eyebrow, 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 (
-
-
-
- Honest commerce. Your storefront is yours.
- Sell online on a platform that treats you straight — no dark patterns, no lock-in.
-
- Create your storefront
-
-
-
+
+ }
+ right={
+
+ Log in
+
+ }
+ />
+
+
+
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 →
+
+
+ Already selling with us? Log in
+
+
+
+ {PROMISES.map(([title, copy]) => (
+
+ ))}
+
+
+
+
+
);
}
diff --git a/frontend/src/screens/SignIn.tsx b/frontend/src/screens/SignIn.tsx
index 88afd82..47712e0 100644
--- a/frontend/src/screens/SignIn.tsx
+++ b/frontend/src/screens/SignIn.tsx
@@ -1,9 +1,10 @@
// 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 states which door framing applies and,
-// after verify, whether the account was created or resumed (PUC-3). storefront routing is
-// handled by App on success.
-import { useState } from "react";
-import { requestCode, verifyCode, type VerifyResult } from "../api";
+// 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";
@@ -13,99 +14,175 @@ interface Props {
onBack: () => void;
}
+function useCooldown(): [number, (s: number) => void] {
+ const [left, setLeft] = useState(0);
+ const ticking = left > 0;
+ useEffect(() => {
+ if (!ticking) return;
+ const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
+ return () => clearInterval(t);
+ }, [ticking]);
+ 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 [error, setError] = useState(null);
+ const [fieldError, setFieldError] = useState(null);
+ const [codeError, setCodeError] = useState(null);
+ const [bannerError, setBannerError] = useState(null);
+ const [cooldown, setCooldown] = useCooldown();
- const heading = door === "signup" ? "Create your storefront" : "Log in";
+ 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 submitEmail(e: React.FormEvent) {
- e.preventDefault();
+ async function sendCode(): Promise {
setBusy(true);
- setError(null);
+ setFieldError(null);
+ setBannerError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
- setError(err.message);
- return;
+ applyError(err);
+ return err.code === "resend_cooldown"; // a cooldown still means a code is out there
}
- setStep("code");
+ 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);
- setError(null);
+ setCodeError(null);
+ setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
- setError(res.error.message);
+ applyError(res.error);
return;
}
onAuthed(res.result);
}
- if (step === "email") {
- return (
-
-
-
-
- );
- }
+ 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 (
-
-
- ecomm
- setStep("email")}>
- Wrong address?
-
-
-
-
+
+ }
+ right={
+
+ ← Back
+
+ }
+ />
+
+
+ {step === "email" ? (
+
+ ) : (
+
+ )}
+
+
+
+
);
}
diff --git a/frontend/src/screens/SignedIn.tsx b/frontend/src/screens/SignedIn.tsx
deleted file mode 100644
index af3b8bd..0000000
--- a/frontend/src/screens/SignedIn.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in
-// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No
-// fabricated capability (INV-9).
-import { logout } from "../api";
-
-interface Props {
- email: string;
- created: boolean;
- onSignedOut: () => void;
-}
-
-export default function SignedIn({ email, created, onSignedOut }: Props) {
- async function signOut() {
- await logout();
- onSignedOut();
- }
-
- return (
-
-
- ecomm
- {email}
-
- Sign out
-
-
-
- {created ? "Welcome to ecomm" : "Welcome back"}
- You're signed in as {email}.
- Creating your storefront arrives in the next slice. Nothing else is needed yet.
-
-
- );
-}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
new file mode 100644
index 0000000..683dd53
--- /dev/null
+++ b/frontend/src/styles/app.css
@@ -0,0 +1,394 @@
+/* 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);
+ text-align: left;
+}
+.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; }
+.hero__actions .btn-primary { width: auto; }
+
+.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; }
+}
diff --git a/frontend/src/styles/fonts.css b/frontend/src/styles/fonts.css
new file mode 100644
index 0000000..56abbf6
--- /dev/null
+++ b/frontend/src/styles/fonts.css
@@ -0,0 +1,37 @@
+/* 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");
+}
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
new file mode 100644
index 0000000..d924144
--- /dev/null
+++ b/frontend/src/styles/index.css
@@ -0,0 +1,7 @@
+/* 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";
diff --git a/frontend/src/styles/tokens-colors.css b/frontend/src/styles/tokens-colors.css
new file mode 100644
index 0000000..57edf8e
--- /dev/null
+++ b/frontend/src/styles/tokens-colors.css
@@ -0,0 +1,60 @@
+/* Wiggleverse — Color tokens
+ Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
+ Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
+
+:root {
+ /* ---- Brand palette (base values) ---- */
+ --wv-midnight: #0E1230; /* sky / ground — primary dark background */
+ --wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
+ --wv-indigo-2: #232A63; /* hover state for raised surfaces */
+ --wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
+ --wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
+ --wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
+ --wv-gold-hi: #F7D488; /* gold hover */
+ --wv-starlight:#EDEAFF; /* nodes / text on dark */
+ --wv-paper: #F6F4FB; /* light-mode background */
+ --wv-ink: #3B2F7A; /* text on light */
+ --wv-night: #090C22; /* footer / deepest ground */
+
+ /* CTA text-on-gold (very dark gold-brown, not pure black) */
+ --wv-gold-ink: #2A2003;
+ --wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
+
+ /* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
+ --wv-lilac-08: rgba(155, 140, 255, .08);
+ --wv-lilac-12: rgba(155, 140, 255, .12);
+ --wv-lilac-16: rgba(155, 140, 255, .16);
+ --wv-lilac-18: rgba(155, 140, 255, .18);
+ --wv-lilac-32: rgba(155, 140, 255, .32);
+ --wv-gold-28: rgba(244, 199, 107, .28);
+ --wv-gold-40: rgba(244, 199, 107, .40);
+ --wv-starlight-85: rgba(237, 234, 255, .85);
+ --wv-starlight-78: rgba(237, 234, 255, .78);
+ --wv-starlight-60: rgba(237, 234, 255, .60);
+ --wv-starlight-55: rgba(237, 234, 255, .55);
+
+ /* ---- Semantic aliases ---- */
+ --surface-sky: var(--wv-midnight); /* page ground (dark) */
+ --surface-raised: var(--wv-indigo); /* cards / panels on dark */
+ --surface-raised-hi: var(--wv-indigo-2); /* raised hover */
+ --surface-paper: var(--wv-paper); /* long-reading light sections */
+ --surface-card-light:#FFFFFF; /* build-cards on paper */
+ --surface-footer: var(--wv-night);
+
+ --text-on-dark: var(--wv-starlight);
+ --text-on-dark-soft: var(--wv-starlight-78);
+ --text-on-dark-mute: var(--wv-starlight-60);
+ --text-on-light: var(--wv-ink);
+ --text-on-light-soft:#4B4170;
+
+ --accent: var(--wv-lilac); /* links + nodes on dark */
+ --accent-on-light: var(--wv-violet); /* links + strokes on light */
+ --cta: var(--wv-gold); /* primary action / horizon */
+ --cta-hover: var(--wv-gold-hi);
+ --cta-text: var(--wv-gold-ink);
+
+ --border-soft: var(--wv-lilac-16); /* hairlines on dark */
+ --border-card: var(--wv-lilac-18);
+ --border-strong: var(--wv-lilac-32);
+ --focus-ring: var(--wv-gold);
+}
diff --git a/frontend/src/styles/tokens-spacing.css b/frontend/src/styles/tokens-spacing.css
new file mode 100644
index 0000000..04a084a
--- /dev/null
+++ b/frontend/src/styles/tokens-spacing.css
@@ -0,0 +1,55 @@
+/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
+ Derived from the marketing-site CSS. The brand has almost no shadow system —
+ depth is carried by surface color + hairline borders, not drop shadows. */
+
+:root {
+ /* ---- Spacing scale (rem) ---- */
+ --space-0: 0;
+ --space-1: .25rem;
+ --space-2: .5rem;
+ --space-3: .7rem;
+ --space-4: 1rem;
+ --space-5: 1.2rem;
+ --space-6: 1.6rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+
+ /* Section rhythm — fluid vertical padding for page bands */
+ --section-pad: clamp(3rem, 7vw, 5.5rem);
+ --page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
+
+ /* ---- Layout ---- */
+ --wrap-max: 1080px; /* content column */
+ --wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
+ --measure-prose: 68ch; /* reading measure for prose blocks */
+
+ /* ---- Radii ---- */
+ --radius-card: 14px; /* path-cards, build-cards */
+ --radius-panel: 12px; /* principle tiles */
+ --radius-sm: 4px; /* focus ring rounding */
+ --radius-pill: 999px; /* buttons, tags, notices */
+ --radius-mark: 26px; /* favicon tile rounding */
+
+ /* ---- Borders ---- */
+ --border-hair: 1px; /* default hairline */
+ --border-card-w: 1px;
+ --btn-border-w: 1.5px; /* ghost button / focus weight */
+
+ /* ---- Elevation ---- *
+ * The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
+ * not a shadow. These tokens exist for the rare card that needs real elevation. */
+ --shadow-none: none;
+ --shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
+ --lift-1: translateY(-1px); /* @kind other */ /* buttons */
+ --lift-3: translateY(-3px); /* @kind other */ /* cards */
+
+ /* ---- Glass (sticky header) ---- */
+ --glass-sky: rgba(14, 18, 48, .82);
+ --glass-blur: 10px;
+
+ /* ---- Motion ---- */
+ --ease: ease; /* @kind other */
+ --dur-fast: .15s; /* @kind other */ /* hover / press transitions */
+ --dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
+}
diff --git a/frontend/src/styles/tokens-typography.css b/frontend/src/styles/tokens-typography.css
new file mode 100644
index 0000000..900a7fe
--- /dev/null
+++ b/frontend/src/styles/tokens-typography.css
@@ -0,0 +1,53 @@
+/* Wiggleverse — Typography tokens
+ Two registers, one meaning (BRAND.md):
+ • Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
+ • Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
+ for the lines that carry conscience ("the soul").
+ @font-face rules live in assets/fonts/fonts.css. */
+
+:root {
+ /* ---- Families ---- */
+ --wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
+ --wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
+ --wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
+
+ /* semantic aliases */
+ --font-display: var(--wv-font-display);
+ --font-body: var(--wv-font-body);
+ --font-soul: var(--wv-font-human);
+
+ /* ---- Weights ---- */
+ --weight-regular: 400; /* Inter body */
+ --weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
+ --weight-semibold:600; /* Inter emphasis, ledger totals */
+ --weight-bold: 700; /* Space Grotesk headings, wordmark */
+ --weight-soul: 500; /* Fraunces italic pull-quotes */
+
+ /* ---- Fluid display sizes (clamp: min, vw, max) ---- */
+ --text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
+ --text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
+ --text-h3: 1.2rem;
+ --text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
+ --text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
+
+ /* ---- Body / UI scale ---- */
+ --text-body: 1rem; /* 16px base */
+ --text-small: .95rem;
+ --text-fine: .92rem;
+ --text-eyebrow: .8rem; /* uppercase label */
+ --text-tag: .72rem; /* pill tags */
+
+ /* ---- Line heights ---- */
+ --leading-tight: 1.12; /* headings */
+ --leading-body: 1.6; /* paragraphs */
+
+ /* ---- Letter spacing ---- */
+ --tracking-display: -0.015em; /* headings + wordmark draw in slightly */
+ --tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
+ --tracking-tag: 0.08em;
+ --tracking-notice: 0.06em;
+
+ /* ---- Measure ---- */
+ --measure-lead: 60ch;
+ --measure-prose:68ch;
+}
diff --git a/frontend/src/ui/CodeInput.tsx b/frontend/src/ui/CodeInput.tsx
new file mode 100644
index 0000000..32934e0
--- /dev/null
+++ b/frontend/src/ui/CodeInput.tsx
@@ -0,0 +1,46 @@
+// 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) => (
+
+ {digits[i] ?? ""}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/ui/kit.tsx b/frontend/src/ui/kit.tsx
new file mode 100644
index 0000000..bbda4ec
--- /dev/null
+++ b/frontend/src/ui/kit.tsx
@@ -0,0 +1,150 @@
+// 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 { useId } from "react";
+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 (
+
+ );
+}
+
+export function Wordmark({ size = 26 }: { size?: number }) {
+ return (
+
+
+
+ ecomm
+
+
+ );
+}
+
+export function Footer() {
+ return (
+
+
+
+ ecomm · a Wiggleverse line
+
+
+ );
+}
+
+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 (
+
+
+ {tone === "attn" ? "◆" : "◇"}
+
+
+ {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 (
+
+ {children}
+
+ );
+}
+
+export function Field({
+ label,
+ optional = false,
+ error,
+ helper,
+ inputProps,
+}: {
+ label: string;
+ optional?: boolean;
+ error?: string | null;
+ helper?: string;
+ inputProps: React.InputHTMLAttributes;
+}) {
+ const id = useId();
+ return (
+
+
+ {label}
+ {optional && optional }
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {helper && !error &&
{helper}
}
+
+ );
+}
+
+export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
+ return (
+
+
+ {email[0]?.toUpperCase()}
+
+ {email}
+
+
+ Sign out
+
+
+ );
+}