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 ( +
+
{left}
+
{right}
+
+ ); +} + +export function Wordmark({ size = 26 }: { size?: number }) { + return ( + + + + ecomm + + + ); +} + +export function Footer() { + return ( + + ); +} + +export function AuthCard({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function Eyebrow({ children }: { children: ReactNode }) { + return

{children}

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

+ {error} +

+ )} + {helper && !error &&

{helper}

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

+ Sell online, +
+ honestly. +

+

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

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

{copy}

+
+ ))} +
+
+
+