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

71 KiB
Raw Blame History

SLICE-3 — Storefront Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement SLICE-3 of SD-0001 v0.3.0 — the storefronts domain (create + INV-4 guard + membership), the POST /api/storefronts endpoint wired into the _storefront_for seam, the Create-storefront + Admin-shell screens per the Claude Design export, complete entry routing, the whole-flow INV-1 bootstrap test, and re-skin the SLICE-1/2 screens (Landing, Sign-in) to the same design.

Anchor: Solution Design wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md §7.2 (SLICE-3), §6.5 (PUC-4/5/6/7/8), §6.4 (API), §5.15.4 (UX). Design export: wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project/ (hf-kit/landing/signin/storefront/admin + _ds tokens).

Architecture: Backend adds a second domain package app/domains/storefronts (sibling of accounts, never imports it — the BFF passes account_id/email down). The INV-4 guard + insert are serialized per-account with a transaction-scoped advisory lock so two concurrent creates cannot both pass the guard. Frontend gains a design-system foundation (Wiggleverse tokens CSS + fonts + brand SVGs + an app.css implementing the hi-fi primitives) and a small src/ui/ component kit; the four §5 screens render from it. No new migration: storefront + storefront_membership shipped in 0001_init.sql.

Tech Stack: FastAPI + psycopg (no ORM), pytest against per-test Postgres databases; React 18 + Vite + vitest; plain CSS with Wiggleverse design tokens (no CSS framework).

E2E browser tests: deliberately deferred per SD-0001 §6.8 ("component/E2E browser tests deferred until there is UI beyond forms") — this slice's UI is still forms + text. Logged in the session transcript's Deferred decisions. The localhost walkthrough (PUC-10) is verified manually at Task 10.


File structure

Backend (create):

  • backend/app/domains/storefronts/__init__.py — package surface (__all__)
  • backend/app/domains/storefronts/errors.pyStorefrontsError, AlreadyOwnsStorefront
  • backend/app/domains/storefronts/models.pyStorefront
  • backend/app/domains/storefronts/service.pycreate_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.tscreateStorefront()
  • 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)

  • Step 1: Branch

git checkout -b slice-3-storefront
  • Step 2: Write failing service-level tests

backend/tests/test_storefronts_create.py:

"""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
  • Step 3: Run tests to verify they fail
cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q

Expected: FAIL/ERROR — app.domains.storefronts does not exist.

  • Step 4: Implement the domain

backend/app/domains/storefronts/models.py:

"""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:

"""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:

"""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:

"""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",
]
  • Step 5: Run tests to verify they pass
cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q

Expected: all PASS.

  • Step 6: Layering contract still green
cd backend && .venv/bin/lint-imports

Expected: Contracts: 1 kept, 0 broken.

  • Step 7: Commit
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

  • Step 1: Write the tests

"""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
  • Step 2: Run them
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).

  • Step 3: Commit
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)

  • Step 1: Write failing endpoint scenario tests (append to backend/tests/test_storefronts_create.py)

# ── 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"
  • Step 2: Run to verify the new tests fail
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.

  • 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:
class CreateStorefrontBody(BaseModel):
    name: str | None = None
  1. Replace _storefront_for's body (keep the signature):
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
  1. Bump FastAPI(title="ecomm", version="0.2", ...) to version="0.3".
  2. Add the endpoint after logout:
    @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})
  • Step 4: Run the whole backend suite + layering
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.

  • Step 5: Commit
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

  • Step 1: Write the test

"""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(),
        }
  • Step 2: Run it
cd backend && .venv/bin/python -m pytest tests/test_bootstrap.py -q

Expected: PASS.

  • Step 3: Commit
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

  • Step 1: Copy the design-system artifacts

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.)

  • Step 2: Write frontend/src/styles/fonts.css (paths point at /fonts/; weights per the DS manifest)
/* 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");
}
  • 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)
/* 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; }
}
  • Step 4: Write frontend/src/styles/index.css (the one file the app imports)
/* 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";
  • Step 5: Import styles in frontend/src/main.tsx (add as the first import)
import "./styles/index.css";
  • Step 6: Set the ground color in frontend/index.html (avoid a white flash before CSS loads; head additions)
    <meta name="theme-color" content="#0E1230" />
    <style>html { background: #0E1230; }</style>
    <link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
  • Step 7: Write frontend/src/ui/kit.tsx — the shared primitives
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
import type { ReactNode } from "react";

export function Screen({
  children,
  horizon = false,
  plain = false,
}: {
  children: ReactNode;
  horizon?: boolean;
  plain?: boolean;
}) {
  const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
  return <div className={cls}>{children}</div>;
}

export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
  return (
    <header className="topbar">
      <div className="topbar__side">{left}</div>
      <div className="topbar__side">{right}</div>
    </header>
  );
}

export function Wordmark({ size = 26 }: { size?: number }) {
  return (
    <span className="wordmark">
      <img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
      <span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
        ecomm
      </span>
    </span>
  );
}

export function Footer() {
  return (
    <footer className="footer">
      <span className="footer__id">
        <img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
        ecomm · a Wiggleverse line
      </span>
    </footer>
  );
}

export function AuthCard({ children }: { children: ReactNode }) {
  return <div className="card">{children}</div>;
}

export function Eyebrow({ children }: { children: ReactNode }) {
  return <p className="eyebrow">{children}</p>;
}

export function Banner({
  tone = "attn",
  title,
  children,
}: {
  tone?: "attn" | "info";
  title?: string;
  children: ReactNode;
}) {
  return (
    <div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
      <span className="banner__icon" aria-hidden="true">
        {tone === "attn" ? "◆" : "◇"}
      </span>
      <div>
        {title && <div className="banner__title">{title}</div>}
        {children}
      </div>
    </div>
  );
}

export function PrimaryButton({
  children,
  busy = false,
  disabled = false,
  type = "submit",
  onClick,
}: {
  children: ReactNode;
  busy?: boolean;
  disabled?: boolean;
  type?: "submit" | "button";
  onClick?: () => void;
}) {
  return (
    <button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
      {children}
    </button>
  );
}

export function Field({
  label,
  optional = false,
  error,
  helper,
  inputProps,
}: {
  label: string;
  optional?: boolean;
  error?: string | null;
  helper?: string;
  inputProps: React.InputHTMLAttributes<HTMLInputElement>;
}) {
  return (
    <div className="field">
      <label className="field__label">
        <span>{label}</span>
        {optional && <span className="field__optional">optional</span>}
      </label>
      <input
        className={`field__input${error ? " field__input--error" : ""}`}
        aria-invalid={!!error}
        {...inputProps}
      />
      {error && (
        <p className="note note--attn" role="alert">
          {error}
        </p>
      )}
      {helper && !error && <p className="note">{helper}</p>}
    </div>
  );
}

export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
  return (
    <div className="chip">
      <span className="chip__avatar" aria-hidden="true">
        {email[0]?.toUpperCase()}
      </span>
      <span className="chip__email">{email}</span>
      <span className="chip__divider" aria-hidden="true" />
      <button type="button" className="signout" onClick={onSignOut}>
        Sign out
      </button>
    </div>
  );
}
  • 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)
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
// paste works because the input is real.
import { useState } from "react";

const LENGTH = 6;

export default function CodeInput({
  value,
  onChange,
  error = false,
}: {
  value: string;
  onChange: (code: string) => void;
  error?: boolean;
}) {
  const [focused, setFocused] = useState(false);
  const digits = value.slice(0, LENGTH).split("");
  const active = Math.min(digits.length, LENGTH - 1);

  return (
    <div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
      <input
        className="code__hidden"
        value={value}
        inputMode="numeric"
        autoComplete="one-time-code"
        aria-label="One-time code"
        autoFocus
        maxLength={LENGTH}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
      />
      {Array.from({ length: LENGTH }, (_, i) => (
        <span
          key={i}
          aria-hidden="true"
          className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
        >
          {digits[i] ?? ""}
        </span>
      ))}
    </div>
  );
}
  • Step 9: Typecheck + build to verify the foundation stands
cd frontend && npm run build

Expected: clean build (the kit is not yet imported anywhere — that's fine; tsc checks it).

  • Step 10: Commit
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

  • Step 1: Rebuild the screen

// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
import { Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";

interface Props {
  onGetStarted: () => void;
  onLogIn: () => void;
}

const PROMISES: Array<[string, string]> = [
  ["No trial clock", "Your storefront never expires or locks."],
  ["No plan wall", "We take only what it takes to run."],
  ["Your data, yours", "Nothing collected you didn't agree to give."],
];

export default function Landing({ onGetStarted, onLogIn }: Props) {
  return (
    <Screen horizon>
      <TopBar
        left={<Wordmark />}
        right={
          <button type="button" className="lk lk--nav" onClick={onLogIn}>
            Log in
          </button>
        }
      />
      <main className="screen__main">
        <div className="hero">
          <Eyebrow>One storefront, fully yours</Eyebrow>
          <h1>
            Sell online,
            <br />
            <em>honestly.</em>
          </h1>
          <p className="hero__lead">
            Claim the one storefront that's yours on a platform that takes only what it takes
            to run — no trial countdown, no plan wall, no data you didn't agree to give.
          </p>
          <div className="hero__actions">
            <PrimaryButton type="button" onClick={onGetStarted}>
              Create your storefront 
            </PrimaryButton>
            <button type="button" className="lk lk--mute" onClick={onLogIn}>
              Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
            </button>
          </div>
          <div className="promises">
            {PROMISES.map(([title, copy]) => (
              <div key={title}>
                <div className="promises__title">{title}</div>
                <p>{copy}</p>
              </div>
            ))}
          </div>
        </div>
      </main>
      <Footer />
    </Screen>
  );
}

(add Eyebrow to the kit import list: import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";. The CTA renders auto-width inside .hero__actions — add btn-primary--auto via a wrapper if it stretches; check visually at Task 10.)

  • Step 2: Build + tests
cd frontend && npm run build && npm test

Expected: clean build, routing tests still green.

  • Step 3: Commit
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

  • 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.

// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
import { useEffect, useState } from "react";
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import CodeInput from "../ui/CodeInput";
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";

type Door = "signup" | "login";

interface Props {
  door: Door;
  onAuthed: (result: VerifyResult) => void;
  onBack: () => void;
}

function useCooldown(): [number, (s: number) => void] {
  const [left, setLeft] = useState(0);
  useEffect(() => {
    if (left <= 0) return;
    const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
    return () => clearInterval(t);
  }, [left > 0]);
  return [left, setLeft];
}

export default function SignIn({ door, onAuthed, onBack }: Props) {
  const [step, setStep] = useState<"email" | "code">("email");
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [busy, setBusy] = useState(false);
  const [fieldError, setFieldError] = useState<string | null>(null);
  const [codeError, setCodeError] = useState<string | null>(null);
  const [bannerError, setBannerError] = useState<ApiError | null>(null);
  const [cooldown, setCooldown] = useCooldown();

  function applyError(err: ApiError) {
    setFieldError(null);
    setCodeError(null);
    setBannerError(null);
    if (err.code === "invalid_email") setFieldError(err.message);
    else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
    else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
    else setBannerError(err); // delivery_failed, code_exhausted, unexpected
  }

  async function sendCode(): Promise<boolean> {
    setBusy(true);
    setFieldError(null);
    setBannerError(null);
    const err = await requestCode(email);
    setBusy(false);
    if (err) {
      applyError(err);
      return err.code === "resend_cooldown"; // a cooldown still means a code is out there
    }
    setCooldown(60);
    return true;
  }

  async function submitEmail(e: React.FormEvent) {
    e.preventDefault();
    if (await sendCode()) {
      setCode("");
      setCodeError(null);
      setStep("code");
    }
  }

  async function resend() {
    setCodeError(null);
    setCode("");
    await sendCode();
  }

  async function submitCode(e: React.FormEvent) {
    e.preventDefault();
    setBusy(true);
    setCodeError(null);
    setBannerError(null);
    const res = await verifyCode(email, code);
    setBusy(false);
    if (!res.ok) {
      applyError(res.error);
      return;
    }
    onAuthed(res.result);
  }

  const heading = door === "signup" ? "Create your storefront" : "Log in";
  const sub =
    door === "signup"
      ? "Enter your email to begin. There's no password to create."
      : "Enter your email and we'll send a one-time code to sign in.";

  return (
    <Screen horizon>
      <TopBar
        left={<Wordmark size={22} />}
        right={
          <button type="button" className="lk lk--mute" onClick={onBack}>
             Back
          </button>
        }
      />
      <main className="screen__main">
        <AuthCard>
          {step === "email" ? (
            <form onSubmit={submitEmail} style={{ display: "contents" }}>
              <div>
                <h1>{heading}</h1>
                <p className="card__sub">{sub}</p>
              </div>
              {bannerError && (
                <Banner title="We couldn't send the code">
                  The email didn't go out. Try again in a moment — nothing was lost.
                </Banner>
              )}
              <Field
                label="Email"
                error={fieldError}
                inputProps={{
                  type: "email",
                  value: email,
                  placeholder: "you@example.com",
                  autoFocus: true,
                  required: true,
                  autoComplete: "email",
                  onChange: (ev) => setEmail(ev.target.value),
                }}
              />
              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                <PrimaryButton busy={busy} disabled={cooldown > 0}>
                  {busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
                </PrimaryButton>
                <p className="note">
                  We'll email you a one-time code. That's all we need  no password, ever.
                </p>
              </div>
            </form>
          ) : (
            <form onSubmit={submitCode} style={{ display: "contents" }}>
              <div>
                <h1>Check your email</h1>
                <p className="card__sub">
                  We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
                  <button type="button" className="lk" onClick={() => setStep("email")}>
                    Wrong address?
                  </button>
                </p>
              </div>
              {bannerError && (
                <Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
                  {bannerError.code === "code_exhausted"
                    ? "That code is no longer valid. Request a fresh one to keep going."
                    : bannerError.message}
                </Banner>
              )}
              <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
                <CodeInput value={code} onChange={setCode} error={!!codeError} />
                {codeError && (
                  <p className="note note--attn" role="alert">
                    {codeError}
                  </p>
                )}
              </div>
              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                <PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                  <p className="note">Good for 10 minutes.</p>
                  {cooldown > 0 ? (
                    <span className="note">Resend in {cooldown}s</span>
                  ) : (
                    <button type="button" className="lk" onClick={resend}>
                      Resend code
                    </button>
                  )}
                </div>
              </div>
            </form>
          )}
        </AuthCard>
      </main>
      <Footer />
    </Screen>
  );
}
  • Step 2: Build + tests
cd frontend && npm run build && npm test

Expected: green.

  • Step 3: Commit
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

  • Step 1: Add the API helper (append to frontend/src/api.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) };
}
  • 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)
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
import { useState } from "react";
import { createStorefront, logout, type StorefrontResult } from "../api";
import { AuthCard, AccountChip, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";

interface Props {
  email: string;
  welcome: "new" | "back" | null;
  onCreated: (storefront: StorefrontResult) => void;
  onAlreadyOwns: () => void;
  onSignedOut: () => void;
}

export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [alreadyOwns, setAlreadyOwns] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function signOut() {
    await logout();
    onSignedOut();
  }

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    setBusy(true);
    setError(null);
    const res = await createStorefront(name);
    setBusy(false);
    if (res.ok) {
      onCreated(res.storefront);
      return;
    }
    if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
    else setError(res.error.message);
  }

  return (
    <Screen horizon>
      <TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
      <main className="screen__main">
        <AuthCard>
          <form onSubmit={submit} style={{ display: "contents" }}>
            <div>
              <Eyebrow>One storefront, fully yours</Eyebrow>
              <h1 style={{ marginTop: 12 }}>Create your storefront</h1>
              <p className="card__sub">
                This is the one thing to do right now. It costs nothing and commits you to nothing.
              </p>
            </div>
            {welcome && (
              <Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
                {welcome === "new"
                  ? "A new account was created for this email."
                  : "Signed in to your existing account."}
              </Banner>
            )}
            {alreadyOwns && (
              <Banner title="Your account already has its storefront">
                ecomm is one storefront per account today.{" "}
                <button type="button" className="lk" onClick={onAlreadyOwns}>
                  Go to your admin 
                </button>
              </Banner>
            )}
            {error && <Banner title="That didn't work">{error} Try again.</Banner>}
            <Field
              label="Storefront name"
              optional
              helper="You can leave this blank — we'll pick a placeholder name you can change any time."
              inputProps={{
                type: "text",
                value: name,
                placeholder: "e.g. Ben's Bets",
                autoFocus: true,
                onChange: (ev) => setName(ev.target.value),
              }}
            />
            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              <PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
              <p className="note">You can rename, configure, or close your storefront whenever you like.</p>
            </div>
          </form>
        </AuthCard>
      </main>
      <Footer />
    </Screen>
  );
}
  • Step 3: Build
cd frontend && npm run build

Expected: clean (screen not yet routed; tsc checks it).

  • Step 4: Commit
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

  • 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)

// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";

interface Props {
  storefrontName: string;
  email: string;
  welcome: "new" | "back" | null;
  onSignedOut: () => void;
}

export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
  async function signOut() {
    await logout();
    onSignedOut();
  }

  return (
    <Screen plain>
      <TopBar
        left={
          <span className="storeid">
            <img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
            <span className="storeid__col">
              <span className="storeid__name">{storefrontName}</span>
              <span className="storeid__sub">ecomm storefront</span>
            </span>
          </span>
        }
        right={<AccountChip email={email} onSignOut={signOut} />}
      />
      <main className="screen__main">
        <div className="empty">
          {welcome && (
            <div style={{ marginBottom: 24, width: "100%" }}>
              <Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
                {welcome === "new"
                  ? "A new account was created for this email."
                  : "Signed in to your existing account."}
              </Banner>
            </div>
          )}
          <div className="empty__seal" aria-hidden="true">
            <img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
          </div>
          <Eyebrow>Your storefront</Eyebrow>
          <h1>{storefrontName}</h1>
          <p className="empty__copy">
            There's nothing to manage yet — and that's a finished state, not a missing one.
            Catalog, orders, and settings will appear here as ecomm grows.
          </p>
        </div>
      </main>
    </Screen>
  );
}
  • 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)
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
// response carries the same shape as /me, so onAuthed feeds the session directly.
import { useEffect, useState } from "react";
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Admin from "./screens/Admin";
import CreateStorefront from "./screens/CreateStorefront";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";

type Door = "signup" | "login";
type Welcome = "new" | "back" | null;

export default function App() {
  const [session, setSession] = useState<SessionState | null>(null);
  const [loading, setLoading] = useState(true);
  const [door, setDoor] = useState<Door | null>(null);
  const [welcome, setWelcome] = useState<Welcome>(null);

  async function reload() {
    setLoading(true);
    setSession(await getMe());
    setLoading(false);
  }

  useEffect(() => {
    void reload();
  }, []);

  if (loading) {
    return (
      <div className="screen screen--plain">
        <main className="screen__main">
          <p className="note">Loading</p>
        </main>
      </div>
    );
  }

  const route = routeFor(session ?? { account: null, storefront: null });

  if (route === "landing") {
    if (door) {
      return (
        <SignIn
          door={door}
          onBack={() => setDoor(null)}
          onAuthed={(result: VerifyResult) => {
            setWelcome(result.created ? "new" : "back");
            setDoor(null);
            setSession({ account: result.account, storefront: result.storefront });
          }}
        />
      );
    }
    return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
  }

  const email = session!.account!.email;

  function signedOut() {
    setWelcome(null);
    setDoor(null);
    setSession(null);
  }

  if (route === "create-storefront") {
    return (
      <CreateStorefront
        email={email}
        welcome={welcome}
        onCreated={(sf: StorefrontResult) => {
          setWelcome(null);
          setSession({ account: { email }, storefront: sf });
        }}
        onAlreadyOwns={() => void reload()}
        onSignedOut={signedOut}
      />
    );
  }

  return (
    <Admin
      storefrontName={session!.storefront!.name}
      email={email}
      welcome={welcome}
      onSignedOut={signedOut}
    />
  );
}
  • Step 3: Delete the placeholder
git rm frontend/src/screens/SignedIn.tsx
  • Step 4: Build + tests
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).

  • Step 5: Commit
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

  • Step 1: Version bumpsfrontend/package.json "version": "0.2.0""0.3.0" (backend FastAPI version already bumped in Task 3).

  • 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.

  • Step 3: Run the whole gate

./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.

  • 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:
./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.

  • Step 5: Commit + push + PR
git add README.md frontend/package.json docs/superpowers/plans/2026-06-10-slice-3-storefront.md
git commit -m "chore(slice-3): version 0.3, README status — storefront slice in place"
git push -u origin slice-3-storefront

Open the PR against main titled SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2), body citing SD-0001 §7.2 SLICE-3 definition of done; merge per autonomous posture once green.


Self-review notes

  • Spec coverage: PUC-4 (Task 1/3/8), PUC-5 (Task 3 test + routing), PUC-6 (Task 3 test 14_01_0027 + Task 9), PUC-7 (Tasks 13 guard/409 + no second-create affordance anywhere in the UI), PUC-8 (Task 3 puc_08 test + Task 9 Admin), corpus 0013/0015/0016/0025/0026/0027 (Task 3), INV-4 concurrent refusal (Task 2), INV-1 whole-flow (Task 4), §5.15.4 visuals incl. slices 12 re-skin (Tasks 59), localhost PUC-10 walkable (Task 10).
  • Deliberate divergences (logged to Deferred decisions): no dead # links from the design; welcome banner moves to the destination screen; Fraunces not shipped (unused); E2E browser tests deferred per §6.8.
  • Type consistency: StorefrontResult {id, name} (api.ts) = the 201 body = SessionState.storefront shape; VerifyResult already carries storefront | null; welcome: "new" | "back" | null threaded App → CreateStorefront/Admin.