SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2) #9

Merged
ben.stull merged 10 commits from slice-3-storefront into main 2026-06-11 05:47:03 +00:00
35 changed files with 3619 additions and 160 deletions
+9 -7
View File
@@ -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.
@@ -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",
]
+10
View File
@@ -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)."""
+10
View File
@@ -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
@@ -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)
+32 -10
View File
@@ -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
+42
View File
@@ -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(),
}
+154
View File
@@ -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"
@@ -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
File diff suppressed because it is too large Load Diff
+3
View File
@@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0E1230" />
<style>html { background: #0E1230; }</style>
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
<title>ecomm</title>
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse — mono gold</title>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
<g fill="#F4C76B">
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 648 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse</title>
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
<g fill="#EDEAFF">
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+46 -19
View File
@@ -1,20 +1,22 @@
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a
// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3.
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
// response carries the same shape as /me, so onAuthed feeds the session directly.
import { useEffect, useState } from "react";
import { getMe, type VerifyResult } from "./api";
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Admin from "./screens/Admin";
import CreateStorefront from "./screens/CreateStorefront";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";
import SignedIn from "./screens/SignedIn";
type Door = "signup" | "login";
type Welcome = "new" | "back" | null;
export default function App() {
const [session, setSession] = useState<SessionState | null>(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState<Door | null>(null);
const [justCreated, setJustCreated] = useState(false);
const [welcome, setWelcome] = useState<Welcome>(null);
async function reload() {
setLoading(true);
@@ -26,7 +28,15 @@ export default function App() {
void reload();
}, []);
if (loading) return <main>Loading</main>;
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 });
@@ -35,13 +45,11 @@ export default function App() {
return (
<SignIn
door={door}
onBack={() => {
setDoor(null);
}}
onBack={() => setDoor(null)}
onAuthed={(result: VerifyResult) => {
setJustCreated(result.created);
setWelcome(result.created ? "new" : "back");
setDoor(null);
void reload();
setSession({ account: result.account, storefront: result.storefront });
}}
/>
);
@@ -49,16 +57,35 @@ export default function App() {
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
// route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the
// signed-in placeholder so sign-out (PUC-9) is reachable.
const email = session!.account!.email;
function signedOut() {
setWelcome(null);
setDoor(null);
setSession(null);
}
if (route === "create-storefront") {
return (
<SignedIn
email={session!.account!.email}
created={justCreated}
onSignedOut={() => {
setJustCreated(false);
void reload();
<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}
/>
);
}
+18
View File
@@ -59,3 +59,21 @@ export async function verifyCode(
export async function logout(): Promise<void> {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
}
export interface StorefrontResult {
id: number;
name: string;
}
export async function createStorefront(
name: string,
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
const resp = await fetch("/api/storefronts", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
});
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
return { ok: false, error: await errorOf(resp) };
}
+1
View File
@@ -1,3 +1,4 @@
import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
+59
View File
@@ -0,0 +1,59 @@
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
interface Props {
storefrontName: string;
email: string;
welcome: "new" | "back" | null;
onSignedOut: () => void;
}
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<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>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
import { useState } from "react";
import { createStorefront, logout, type StorefrontResult } from "../api";
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
email: string;
welcome: "new" | "back" | null;
onCreated: (storefront: StorefrontResult) => void;
onAlreadyOwns: () => void;
onSignedOut: () => void;
}
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [alreadyOwns, setAlreadyOwns] = useState(false);
const [error, setError] = useState<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>
);
}
+46 -12
View File
@@ -1,27 +1,61 @@
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
// they differ only in framing.
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
onGetStarted: () => void;
onLogIn: () => void;
}
const PROMISES: Array<[string, string]> = [
["No trial clock", "Your storefront never expires or locks."],
["No plan wall", "We take only what it takes to run."],
["Your data, yours", "Nothing collected you didn't agree to give."],
];
export default function Landing({ onGetStarted, onLogIn }: Props) {
return (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={onLogIn}>
<Screen horizon>
<TopBar
left={<Wordmark />}
right={
<button type="button" className="lk lk--nav" onClick={onLogIn}>
Log in
</button>
</header>
<section>
<h1>Honest commerce. Your storefront is yours.</h1>
<p>Sell online on a platform that treats you straight no dark patterns, no lock-in.</p>
<button type="button" onClick={onGetStarted}>
Create your storefront
}
/>
<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>
</section>
</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>
);
}
+143 -66
View File
@@ -1,9 +1,10 @@
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy states which door framing applies and,
// after verify, whether the account was created or resumed (PUC-3). storefront routing is
// handled by App on success.
import { useState } from "react";
import { requestCode, verifyCode, type VerifyResult } from "../api";
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
import { useEffect, useState } from "react";
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import CodeInput from "../ui/CodeInput";
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
type Door = "signup" | "login";
@@ -13,99 +14,175 @@ interface Props {
onBack: () => void;
}
function useCooldown(): [number, (s: number) => void] {
const [left, setLeft] = useState(0);
const ticking = left > 0;
useEffect(() => {
if (!ticking) return;
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
return () => clearInterval(t);
}, [ticking]);
return [left, setLeft];
}
export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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();
const heading = door === "signup" ? "Create your storefront" : "Log in";
function applyError(err: ApiError) {
setFieldError(null);
setCodeError(null);
setBannerError(null);
if (err.code === "invalid_email") setFieldError(err.message);
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
async function sendCode(): Promise<boolean> {
setBusy(true);
setError(null);
setFieldError(null);
setBannerError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
setError(err.message);
return;
applyError(err);
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
}
setCooldown(60);
return true;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code");
}
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
setError(res.error.message);
applyError(res.error);
return;
}
onAuthed(res.result);
}
if (step === "email") {
return (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={onBack}>
Back
</button>
</header>
<form onSubmit={submitEmail}>
<h1>{heading}</h1>
<label>
Email
<input
type="email"
value={email}
autoFocus
required
onChange={(ev) => setEmail(ev.target.value)}
/>
</label>
<p>We'll email you a one-time code. That's all we need.</p>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Sending…" : "Send code"}
</button>
</form>
</main>
);
}
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 (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={() => setStep("email")}>
<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>
</header>
<form onSubmit={submitCode}>
<h1>Enter your code</h1>
<p>We sent a code to {email}.</p>
<label>
Code
<input
inputMode="numeric"
autoComplete="one-time-code"
value={code}
autoFocus
required
onChange={(ev) => setCode(ev.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Checking…" : "Continue"}
</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>
);
}
-34
View File
@@ -1,34 +0,0 @@
// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in
// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No
// fabricated capability (INV-9).
import { logout } from "../api";
interface Props {
email: string;
created: boolean;
onSignedOut: () => void;
}
export default function SignedIn({ email, created, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<main>
<header>
<strong>ecomm</strong>
<span>{email}</span>
<button type="button" onClick={signOut}>
Sign out
</button>
</header>
<section>
<h1>{created ? "Welcome to ecomm" : "Welcome back"}</h1>
<p>You're signed in as {email}.</p>
<p>Creating your storefront arrives in the next slice. Nothing else is needed yet.</p>
</section>
</main>
);
}
+394
View File
@@ -0,0 +1,394 @@
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--wv-midnight);
color: var(--wv-starlight);
font-family: var(--wv-font-body);
-webkit-font-smoothing: antialiased;
}
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
.screen {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
background:
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--horizon {
background:
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--plain { background: var(--wv-midnight); }
.screen__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
.topbar {
flex: 0 0 auto;
height: 68px;
padding: 0 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-soft);
background: var(--glass-sky);
backdrop-filter: blur(var(--glass-blur));
position: relative;
z-index: 5;
}
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
/* ── wordmark ───────────────────────────────────────────────────────────────── */
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
.wordmark img { display: block; border-radius: 6px; }
.wordmark__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 22px;
letter-spacing: var(--tracking-display);
color: var(--wv-starlight);
}
/* ── links & text buttons ───────────────────────────────────────────────────── */
.lk {
color: var(--wv-lilac);
text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.lk:hover { color: var(--wv-gold); }
.lk--mute { color: var(--text-on-dark-mute); }
.lk--nav {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
}
.signout {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--dur-fast) var(--ease);
}
.signout:hover { color: var(--wv-gold); }
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
.chip__avatar {
width: 30px;
height: 30px;
border-radius: 15px;
background: var(--wv-lilac-16);
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 13px;
flex: 0 0 auto;
}
.chip__email {
font-size: 14px;
color: var(--text-on-dark-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
.eyebrow {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: var(--text-eyebrow);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--wv-lilac);
margin: 0;
}
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
.card {
width: 440px;
max-width: 100%;
background: var(--surface-raised);
border: 1px solid var(--border-card);
border-radius: var(--radius-card);
padding: 40px 40px 34px;
display: flex;
flex-direction: column;
gap: 22px;
box-shadow: var(--shadow-soft);
position: relative;
z-index: 2;
}
.card h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
font-size: 27px;
line-height: 1.08;
margin: 0 0 9px;
}
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
/* ── fields ─────────────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 8px; }
.field__label {
font-weight: var(--weight-medium);
font-size: 13.5px;
color: var(--text-on-dark-soft);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
.field__input {
height: 52px;
border-radius: 10px;
padding: 0 16px;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-body);
font-size: 16px;
color: var(--wv-starlight);
width: 100%;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.field__input::placeholder { color: var(--text-on-dark-mute); }
.field__input:hover { border-color: var(--border-strong); }
.field__input:focus {
outline: none;
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
background: rgba(237, 234, 255, .06);
}
.field__input--error { border-color: var(--wv-gold); }
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
.note--attn::before { content: "◆"; font-size: 11px; }
/* ── primary button: gold pill ──────────────────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4em;
width: 100%;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 15.5px;
line-height: 1;
padding: .85rem 1.4rem;
border-radius: var(--radius-pill);
border: var(--btn-border-w) solid transparent;
background: var(--cta);
color: var(--cta-text);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary--auto { width: auto; }
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
.banner {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
align-items: flex-start;
font-size: 14px;
line-height: 1.5;
color: var(--text-on-dark-soft);
text-align: left;
}
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
.banner--attn .banner__icon { color: var(--wv-gold); }
.banner--info .banner__icon { color: var(--wv-lilac); }
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
.code__hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
font-size: 16px;
cursor: pointer;
}
.code__cell {
width: 52px;
height: 64px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 26px;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.code--focus .code__cell--active {
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
}
.code--error .code__cell { border-color: var(--wv-gold); }
/* ── footer ─────────────────────────────────────────────────────────────────── */
.footer {
flex: 0 0 auto;
padding: 20px 36px;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 12.5px;
color: var(--text-on-dark-mute);
background: rgba(9, 12, 34, .4);
}
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
.footer__id img { opacity: .85; }
/* ── landing ────────────────────────────────────────────────────────────────── */
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.hero h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
line-height: 1.02;
font-size: clamp(36px, 6.5vw, 62px);
margin: 20px 0 22px;
}
.hero h1 em { font-style: normal; color: var(--wv-gold); }
.hero__lead {
font-size: clamp(15.5px, 1.8vw, 18.5px);
line-height: 1.6;
color: var(--text-on-dark-soft);
max-width: 520px;
margin: 0 0 32px;
text-wrap: pretty;
}
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
.hero__actions .btn-primary { width: auto; }
.promises {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
margin-top: 44px;
border-top: 1px solid var(--border-soft);
padding-top: 24px;
text-align: left;
}
.promises > div { padding: 0 22px; }
.promises > div + div { border-left: 1px solid var(--border-soft); }
.promises__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14.5px;
}
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
/* ── admin shell ────────────────────────────────────────────────────────────── */
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
.storeid__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 17px;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
.empty__seal {
width: 76px;
height: 76px;
border-radius: 50%;
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 26px;
background: var(--wv-lilac-08);
}
.empty h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
line-height: 1.04;
font-size: clamp(30px, 4.5vw, 42px);
margin: 12px 0 16px;
}
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.topbar { padding: 0 20px; }
.footer { padding: 16px 20px; flex-direction: column; }
.card { padding: 28px 22px 24px; }
.promises { grid-template-columns: 1fr; gap: 14px; }
.promises > div { padding: 0; }
.promises > div + div { border-left: none; }
.code__cell { width: 44px; height: 56px; font-size: 22px; }
}
+37
View File
@@ -0,0 +1,37 @@
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
}
+7
View File
@@ -0,0 +1,7 @@
/* Global style entry point — Wiggleverse design-system tokens (copied from the
ui/designs export) + the ecomm app chrome built on them. */
@import "./fonts.css";
@import "./tokens-colors.css";
@import "./tokens-typography.css";
@import "./tokens-spacing.css";
@import "./app.css";
+60
View File
@@ -0,0 +1,60 @@
/* Wiggleverse — Color tokens
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §89).
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
:root {
/* ---- Brand palette (base values) ---- */
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
--wv-gold-hi: #F7D488; /* gold hover */
--wv-starlight:#EDEAFF; /* nodes / text on dark */
--wv-paper: #F6F4FB; /* light-mode background */
--wv-ink: #3B2F7A; /* text on light */
--wv-night: #090C22; /* footer / deepest ground */
/* CTA text-on-gold (very dark gold-brown, not pure black) */
--wv-gold-ink: #2A2003;
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
--wv-lilac-08: rgba(155, 140, 255, .08);
--wv-lilac-12: rgba(155, 140, 255, .12);
--wv-lilac-16: rgba(155, 140, 255, .16);
--wv-lilac-18: rgba(155, 140, 255, .18);
--wv-lilac-32: rgba(155, 140, 255, .32);
--wv-gold-28: rgba(244, 199, 107, .28);
--wv-gold-40: rgba(244, 199, 107, .40);
--wv-starlight-85: rgba(237, 234, 255, .85);
--wv-starlight-78: rgba(237, 234, 255, .78);
--wv-starlight-60: rgba(237, 234, 255, .60);
--wv-starlight-55: rgba(237, 234, 255, .55);
/* ---- Semantic aliases ---- */
--surface-sky: var(--wv-midnight); /* page ground (dark) */
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
--surface-paper: var(--wv-paper); /* long-reading light sections */
--surface-card-light:#FFFFFF; /* build-cards on paper */
--surface-footer: var(--wv-night);
--text-on-dark: var(--wv-starlight);
--text-on-dark-soft: var(--wv-starlight-78);
--text-on-dark-mute: var(--wv-starlight-60);
--text-on-light: var(--wv-ink);
--text-on-light-soft:#4B4170;
--accent: var(--wv-lilac); /* links + nodes on dark */
--accent-on-light: var(--wv-violet); /* links + strokes on light */
--cta: var(--wv-gold); /* primary action / horizon */
--cta-hover: var(--wv-gold-hi);
--cta-text: var(--wv-gold-ink);
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
--border-card: var(--wv-lilac-18);
--border-strong: var(--wv-lilac-32);
--focus-ring: var(--wv-gold);
}
+55
View File
@@ -0,0 +1,55 @@
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
Derived from the marketing-site CSS. The brand has almost no shadow system —
depth is carried by surface color + hairline borders, not drop shadows. */
:root {
/* ---- Spacing scale (rem) ---- */
--space-0: 0;
--space-1: .25rem;
--space-2: .5rem;
--space-3: .7rem;
--space-4: 1rem;
--space-5: 1.2rem;
--space-6: 1.6rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Section rhythm — fluid vertical padding for page bands */
--section-pad: clamp(3rem, 7vw, 5.5rem);
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
/* ---- Layout ---- */
--wrap-max: 1080px; /* content column */
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
--measure-prose: 68ch; /* reading measure for prose blocks */
/* ---- Radii ---- */
--radius-card: 14px; /* path-cards, build-cards */
--radius-panel: 12px; /* principle tiles */
--radius-sm: 4px; /* focus ring rounding */
--radius-pill: 999px; /* buttons, tags, notices */
--radius-mark: 26px; /* favicon tile rounding */
/* ---- Borders ---- */
--border-hair: 1px; /* default hairline */
--border-card-w: 1px;
--btn-border-w: 1.5px; /* ghost button / focus weight */
/* ---- Elevation ---- *
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
* not a shadow. These tokens exist for the rare card that needs real elevation. */
--shadow-none: none;
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
--lift-3: translateY(-3px); /* @kind other */ /* cards */
/* ---- Glass (sticky header) ---- */
--glass-sky: rgba(14, 18, 48, .82);
--glass-blur: 10px;
/* ---- Motion ---- */
--ease: ease; /* @kind other */
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
}
+53
View File
@@ -0,0 +1,53 @@
/* Wiggleverse — Typography tokens
Two registers, one meaning (BRAND.md):
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
for the lines that carry conscience ("the soul").
@font-face rules live in assets/fonts/fonts.css. */
:root {
/* ---- Families ---- */
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
/* semantic aliases */
--font-display: var(--wv-font-display);
--font-body: var(--wv-font-body);
--font-soul: var(--wv-font-human);
/* ---- Weights ---- */
--weight-regular: 400; /* Inter body */
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
--weight-semibold:600; /* Inter emphasis, ledger totals */
--weight-bold: 700; /* Space Grotesk headings, wordmark */
--weight-soul: 500; /* Fraunces italic pull-quotes */
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
--text-h3: 1.2rem;
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
/* ---- Body / UI scale ---- */
--text-body: 1rem; /* 16px base */
--text-small: .95rem;
--text-fine: .92rem;
--text-eyebrow: .8rem; /* uppercase label */
--text-tag: .72rem; /* pill tags */
/* ---- Line heights ---- */
--leading-tight: 1.12; /* headings */
--leading-body: 1.6; /* paragraphs */
/* ---- Letter spacing ---- */
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
--tracking-tag: 0.08em;
--tracking-notice: 0.06em;
/* ---- Measure ---- */
--measure-lead: 60ch;
--measure-prose:68ch;
}
+46
View File
@@ -0,0 +1,46 @@
// 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>
);
}
+150
View File
@@ -0,0 +1,150 @@
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
import { useId } from "react";
import type { ReactNode } from "react";
export function Screen({
children,
horizon = false,
plain = false,
}: {
children: ReactNode;
horizon?: boolean;
plain?: boolean;
}) {
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
return <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>;
}) {
const id = useId();
return (
<div className="field">
<label className="field__label" htmlFor={id}>
<span>{label}</span>
{optional && <span className="field__optional">optional</span>}
</label>
<input
id={id}
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>
);
}