.prompt.md`,
+and one `@dsCard` HTML thumbnail. Mount components in card/kit HTML via
+`const { X } = window.WiggleverseDesignSystem_94cd80` after loading
+`_ds_bundle.js` (generated automatically — do not edit).
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css
new file mode 100644
index 0000000..84693f9
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css
@@ -0,0 +1,8 @@
+/* Wiggleverse Design System — global entry point.
+ Consumers link THIS one file. It is an @import manifest only — no rules here.
+ Everything reachable from here ships to consumers (tokens + @font-face webfonts). */
+
+@import url("./assets/fonts/fonts.css"); /* Space Grotesk · Inter · Fraunces (@font-face) */
+@import url("./tokens/colors.css"); /* palette + semantic color aliases */
+@import url("./tokens/typography.css"); /* families, scale, weights, tracking */
+@import url("./tokens/spacing.css"); /* spacing, radii, borders, motion, layout */
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css
new file mode 100644
index 0000000..57edf8e
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css
@@ -0,0 +1,60 @@
+/* Wiggleverse — Color tokens
+ Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
+ Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
+
+:root {
+ /* ---- Brand palette (base values) ---- */
+ --wv-midnight: #0E1230; /* sky / ground — primary dark background */
+ --wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
+ --wv-indigo-2: #232A63; /* hover state for raised surfaces */
+ --wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
+ --wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
+ --wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
+ --wv-gold-hi: #F7D488; /* gold hover */
+ --wv-starlight:#EDEAFF; /* nodes / text on dark */
+ --wv-paper: #F6F4FB; /* light-mode background */
+ --wv-ink: #3B2F7A; /* text on light */
+ --wv-night: #090C22; /* footer / deepest ground */
+
+ /* CTA text-on-gold (very dark gold-brown, not pure black) */
+ --wv-gold-ink: #2A2003;
+ --wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
+
+ /* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
+ --wv-lilac-08: rgba(155, 140, 255, .08);
+ --wv-lilac-12: rgba(155, 140, 255, .12);
+ --wv-lilac-16: rgba(155, 140, 255, .16);
+ --wv-lilac-18: rgba(155, 140, 255, .18);
+ --wv-lilac-32: rgba(155, 140, 255, .32);
+ --wv-gold-28: rgba(244, 199, 107, .28);
+ --wv-gold-40: rgba(244, 199, 107, .40);
+ --wv-starlight-85: rgba(237, 234, 255, .85);
+ --wv-starlight-78: rgba(237, 234, 255, .78);
+ --wv-starlight-60: rgba(237, 234, 255, .60);
+ --wv-starlight-55: rgba(237, 234, 255, .55);
+
+ /* ---- Semantic aliases ---- */
+ --surface-sky: var(--wv-midnight); /* page ground (dark) */
+ --surface-raised: var(--wv-indigo); /* cards / panels on dark */
+ --surface-raised-hi: var(--wv-indigo-2); /* raised hover */
+ --surface-paper: var(--wv-paper); /* long-reading light sections */
+ --surface-card-light:#FFFFFF; /* build-cards on paper */
+ --surface-footer: var(--wv-night);
+
+ --text-on-dark: var(--wv-starlight);
+ --text-on-dark-soft: var(--wv-starlight-78);
+ --text-on-dark-mute: var(--wv-starlight-60);
+ --text-on-light: var(--wv-ink);
+ --text-on-light-soft:#4B4170;
+
+ --accent: var(--wv-lilac); /* links + nodes on dark */
+ --accent-on-light: var(--wv-violet); /* links + strokes on light */
+ --cta: var(--wv-gold); /* primary action / horizon */
+ --cta-hover: var(--wv-gold-hi);
+ --cta-text: var(--wv-gold-ink);
+
+ --border-soft: var(--wv-lilac-16); /* hairlines on dark */
+ --border-card: var(--wv-lilac-18);
+ --border-strong: var(--wv-lilac-32);
+ --focus-ring: var(--wv-gold);
+}
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css
new file mode 100644
index 0000000..04a084a
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css
@@ -0,0 +1,55 @@
+/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
+ Derived from the marketing-site CSS. The brand has almost no shadow system —
+ depth is carried by surface color + hairline borders, not drop shadows. */
+
+:root {
+ /* ---- Spacing scale (rem) ---- */
+ --space-0: 0;
+ --space-1: .25rem;
+ --space-2: .5rem;
+ --space-3: .7rem;
+ --space-4: 1rem;
+ --space-5: 1.2rem;
+ --space-6: 1.6rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+
+ /* Section rhythm — fluid vertical padding for page bands */
+ --section-pad: clamp(3rem, 7vw, 5.5rem);
+ --page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
+
+ /* ---- Layout ---- */
+ --wrap-max: 1080px; /* content column */
+ --wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
+ --measure-prose: 68ch; /* reading measure for prose blocks */
+
+ /* ---- Radii ---- */
+ --radius-card: 14px; /* path-cards, build-cards */
+ --radius-panel: 12px; /* principle tiles */
+ --radius-sm: 4px; /* focus ring rounding */
+ --radius-pill: 999px; /* buttons, tags, notices */
+ --radius-mark: 26px; /* favicon tile rounding */
+
+ /* ---- Borders ---- */
+ --border-hair: 1px; /* default hairline */
+ --border-card-w: 1px;
+ --btn-border-w: 1.5px; /* ghost button / focus weight */
+
+ /* ---- Elevation ---- *
+ * The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
+ * not a shadow. These tokens exist for the rare card that needs real elevation. */
+ --shadow-none: none;
+ --shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
+ --lift-1: translateY(-1px); /* @kind other */ /* buttons */
+ --lift-3: translateY(-3px); /* @kind other */ /* cards */
+
+ /* ---- Glass (sticky header) ---- */
+ --glass-sky: rgba(14, 18, 48, .82);
+ --glass-blur: 10px;
+
+ /* ---- Motion ---- */
+ --ease: ease; /* @kind other */
+ --dur-fast: .15s; /* @kind other */ /* hover / press transitions */
+ --dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
+}
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css
new file mode 100644
index 0000000..900a7fe
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css
@@ -0,0 +1,53 @@
+/* Wiggleverse — Typography tokens
+ Two registers, one meaning (BRAND.md):
+ • Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
+ • Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
+ for the lines that carry conscience ("the soul").
+ @font-face rules live in assets/fonts/fonts.css. */
+
+:root {
+ /* ---- Families ---- */
+ --wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
+ --wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
+ --wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
+
+ /* semantic aliases */
+ --font-display: var(--wv-font-display);
+ --font-body: var(--wv-font-body);
+ --font-soul: var(--wv-font-human);
+
+ /* ---- Weights ---- */
+ --weight-regular: 400; /* Inter body */
+ --weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
+ --weight-semibold:600; /* Inter emphasis, ledger totals */
+ --weight-bold: 700; /* Space Grotesk headings, wordmark */
+ --weight-soul: 500; /* Fraunces italic pull-quotes */
+
+ /* ---- Fluid display sizes (clamp: min, vw, max) ---- */
+ --text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
+ --text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
+ --text-h3: 1.2rem;
+ --text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
+ --text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
+
+ /* ---- Body / UI scale ---- */
+ --text-body: 1rem; /* 16px base */
+ --text-small: .95rem;
+ --text-fine: .92rem;
+ --text-eyebrow: .8rem; /* uppercase label */
+ --text-tag: .72rem; /* pill tags */
+
+ /* ---- Line heights ---- */
+ --leading-tight: 1.12; /* headings */
+ --leading-body: 1.6; /* paragraphs */
+
+ /* ---- Letter spacing ---- */
+ --tracking-display: -0.015em; /* headings + wordmark draw in slightly */
+ --tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
+ --tracking-tag: 0.08em;
+ --tracking-notice: 0.06em;
+
+ /* ---- Measure ---- */
+ --measure-lead: 60ch;
+ --measure-prose:68ch;
+}
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-mono-gold.svg b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-mono-gold.svg
new file mode 100644
index 0000000..cc7fde2
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-mono-gold.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-tile.svg b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-tile.svg
new file mode 100644
index 0000000..51d6c69
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/mark-tile.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/wiggleverse-mark.svg b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/wiggleverse-mark.svg
new file mode 100644
index 0000000..1d80e5b
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/assets/wiggleverse-mark.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/design-canvas.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/design-canvas.jsx
new file mode 100644
index 0000000..0eac177
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/design-canvas.jsx
@@ -0,0 +1,974 @@
+// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
+
+/* BEGIN USAGE */
+// DesignCanvas.jsx — Figma-ish design canvas wrapper
+// Warm gray grid bg + Sections + Artboards + PostIt notes.
+// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
+// Artboards are reorderable (grip-drag), deletable, labels/titles are
+// inline-editable, and any artboard can be opened in a fullscreen focus
+// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
+// via the host bridge. No assets, no deps.
+//
+// Usage:
+//
+//
+// …
+// …
+//
+//
+//
+// Artboards are static design frames, not scroll regions — never use
+// height: 100% + overflow: auto/scroll on inner elements; size each artboard
+// to fit its content (explicit pixel height, or let it grow).
+/* END USAGE */
+
+const DC = {
+ bg: '#f0eee9',
+ grid: 'rgba(0,0,0,0.06)',
+ label: 'rgba(60,50,40,0.7)',
+ title: 'rgba(40,30,20,0.85)',
+ subtitle: 'rgba(60,50,40,0.6)',
+ postitBg: '#fef4a8',
+ postitText: '#5a4a2a',
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
+};
+
+// One-time CSS injection (classes are dc-prefixed so they don't collide with
+// the hosted design's own styles).
+if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
+ const s = document.createElement('style');
+ s.id = 'dc-styles';
+ s.textContent = [
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
+ '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
+ // isolation:isolate contains artboard content's z-indexes so a
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
+ // the .dc-menu popover that drops into the top of the card.
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
+ '.dc-card *{scrollbar-width:none}',
+ '.dc-card *::-webkit-scrollbar{display:none}',
+ // Per-artboard header: grip + label on the left, delete/expand on the
+ // right. Single flex row; when the artboard's on-screen width is too
+ // narrow for both the label yields (ellipsis, then hidden entirely below
+ // ~4ch via the container query) and the buttons stay on the row.
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
+ ' display:flex;align-items:center;container-type:inline-size}',
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
+ '.dc-grip:active{cursor:grabbing}',
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
+ // until the card is moused.
+ '@container (max-width: 110px){',
+ ' .dc-labeltext{display:none}',
+ ' .dc-grip{opacity:0}',
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
+ '}',
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
+ ' font:inherit;transition:background .12s,color .12s}',
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
+ // Slot hosting an open menu floats above later siblings (which otherwise
+ // paint on top — same z-index:auto, later DOM order) so the popup isn't
+ // clipped by the next card.
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
+ ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
+ '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
+ ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
+ ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
+ '.dc-menu button:hover{background:rgba(0,0,0,.05)}',
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
+ '.dc-menu .dc-danger{color:#c96442}',
+ '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
+ // DCViewport on every transform update and inherits to all descendants —
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
+ // it the same way.
+ //
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
+ // matter) with its world-space width set to card-width / inv-zoom so that
+ // after counter-scaling its on-screen width exactly matches the card's —
+ // that's what lets the container query + text-overflow behave against the
+ // card's visible edge at every zoom level.
+ //
+ // The section head uses CSS zoom instead of transform so its layout box
+ // grows with the counter-scale, pushing the card row down — otherwise the
+ // constant-screen-size title would overflow into the (shrinking) world-
+ // space gap and overlap the artboard headers at low zoom.
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
+ ].join('\n');
+ document.head.appendChild(s);
+}
+
+const DCCtx = React.createContext(null);
+
+// Recursively unwrap React.Fragment so <>…> grouping doesn't hide
+// DCSection/DCArtboard children from the type-based walks below.
+function dcFlatten(children) {
+ const out = [];
+ React.Children.forEach(children, (c) => {
+ if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
+ else out.push(c);
+ });
+ return out;
+}
+
+// ─────────────────────────────────────────────────────────────
+// DesignCanvas — stateful wrapper around the pan/zoom viewport.
+// Owns runtime state (per-section order, renamed titles/labels, hidden
+// artboards, focused artboard). Order/titles/labels/hidden persist to a
+// .design-canvas.state.json
+// sidecar next to the HTML. Reads go via plain fetch() so the saved
+// arrangement is visible anywhere the HTML + sidecar are served together
+// (omelette preview, direct link, downloaded zip). Writes go through the
+// host's window.omelette bridge — editing requires the omelette runtime.
+// Focus is ephemeral.
+// ─────────────────────────────────────────────────────────────
+const DC_STATE_FILE = '.design-canvas.state.json';
+
+function DesignCanvas({ children, minScale, maxScale, style }) {
+ const [state, setState] = React.useState({ sections: {}, focus: null });
+ // Hold rendering until the sidecar read settles so the saved order/titles
+ // appear on first paint (no source-order flash). didRead gates writes until
+ // the read settles so the empty initial state can't clobber a slow read;
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
+ // hydration.
+ const [ready, setReady] = React.useState(false);
+ const didRead = React.useRef(false);
+ const skipNextWrite = React.useRef(false);
+
+ React.useEffect(() => {
+ let off = false;
+ fetch('./' + DC_STATE_FILE)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((saved) => {
+ if (off || !saved || !saved.sections) return;
+ skipNextWrite.current = true;
+ setState((s) => ({ ...s, sections: saved.sections }));
+ })
+ .catch(() => {})
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
+ return () => { off = true; clearTimeout(t); };
+ }, []);
+
+ React.useEffect(() => {
+ if (!didRead.current) return;
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
+ const t = setTimeout(() => {
+ window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
+ }, 250);
+ return () => clearTimeout(t);
+ }, [state.sections]);
+
+ // Build registries synchronously from children so FocusOverlay can read
+ // them in the same render. Fragments are flattened; wrapping in other
+ // elements still opts out of focus/reorder.
+ const registry = {}; // slotId -> { sectionId, artboard }
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
+ const sectionOrder = [];
+ dcFlatten(children).forEach((sec) => {
+ if (!sec || sec.type !== DCSection) return;
+ const sid = sec.props.id ?? sec.props.title;
+ if (!sid) return;
+ sectionOrder.push(sid);
+ const persisted = state.sections[sid] || {};
+ const abs = [];
+ dcFlatten(sec.props.children).forEach((ab) => {
+ if (!ab || ab.type !== DCArtboard) return;
+ const aid = ab.props.id ?? ab.props.label;
+ if (aid) abs.push([aid, ab]);
+ });
+ // hidden is scoped to one source revision — when the agent regenerates
+ // (artboard-ID set changes), prior deletes don't apply to new content.
+ const srcKey = abs.map(([k]) => k).join('\x1f');
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
+ const srcIds = [];
+ abs.forEach(([aid, ab]) => {
+ if (hidden.includes(aid)) return;
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
+ srcIds.push(aid);
+ });
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
+ sectionMeta[sid] = {
+ title: persisted.title ?? sec.props.title,
+ subtitle: sec.props.subtitle,
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
+ };
+ });
+
+ const api = React.useMemo(() => ({
+ state,
+ section: (id) => state.sections[id] || {},
+ patchSection: (id, p) => setState((s) => ({
+ ...s,
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
+ })),
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
+ }), [state]);
+
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
+ React.useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
+ const onPd = (e) => {
+ const ae = document.activeElement;
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
+ };
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('pointerdown', onPd, true);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('pointerdown', onPd, true);
+ };
+ }, [api]);
+
+ return (
+
+ {ready && children}
+ {state.focus && registry[state.focus] && (
+
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCViewport — transform-based pan/zoom (internal)
+//
+// Input mapping (Figma-style):
+// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
+// • trackpad scroll → pan (two-finger)
+// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
+// • middle-drag / primary-drag-on-bg → pan
+//
+// Transform state lives in a ref and is written straight to the DOM
+// (translate3d + will-change) so wheel ticks don't go through React —
+// keeps pans at 60fps on dense canvases.
+// ─────────────────────────────────────────────────────────────
+function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
+ const vpRef = React.useRef(null);
+ const worldRef = React.useRef(null);
+ const tf = React.useRef({ x: 0, y: 0, scale: 1 });
+ // Persist viewport across reloads so the user lands back where they were
+ // after an agent edit or browser refresh. The sandbox origin is already
+ // per-project; pathname keeps multiple canvas files in one project apart.
+ const tfKey = 'dc-viewport:' + location.pathname;
+ const saveT = React.useRef(0);
+
+ const lastPostedScale = React.useRef();
+ const apply = React.useCallback(() => {
+ const { x, y, scale } = tf.current;
+ const el = worldRef.current;
+ if (!el) return;
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
+ // ticks leave scale unchanged — skip the cross-frame post for those.
+ if (lastPostedScale.current !== scale) {
+ lastPostedScale.current = scale;
+ window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
+ }
+ clearTimeout(saveT.current);
+ saveT.current = setTimeout(() => {
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ }, 200);
+ }, [tfKey]);
+
+ React.useLayoutEffect(() => {
+ const flush = () => {
+ clearTimeout(saveT.current);
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ };
+ try {
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
+ apply();
+ }
+ } catch {}
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
+ // window doesn't drop the last pan/zoom.
+ window.addEventListener('pagehide', flush);
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
+ }, []);
+
+ React.useEffect(() => {
+ const vp = vpRef.current;
+ if (!vp) return;
+
+ const zoomAt = (cx, cy, factor) => {
+ const r = vp.getBoundingClientRect();
+ const px = cx - r.left, py = cy - r.top;
+ const t = tf.current;
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
+ const k = next / t.scale;
+ // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
+ // marginBottom) reflow on every scale change, vertically shifting the
+ // world layout — so a world point mathematically pinned under the cursor
+ // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
+ // Anchor the DOM element under the cursor instead: record its screen Y,
+ // apply the transform + --dc-inv-zoom, then cancel whatever vertical
+ // drift the reflow introduced so it stays put on screen.
+ let marker = null, markerY0 = 0;
+ if (k !== 1) {
+ const hit = document.elementFromPoint(cx, cy);
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
+ }
+ // keep the world point under the cursor fixed
+ t.x = px - (px - t.x) * k;
+ t.y = py - (py - t.y) * k;
+ t.scale = next;
+ apply();
+ if (marker) {
+ // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
+ // departure after the --dc-inv-zoom reflow is the layout drift.
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
+ if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
+ }
+ };
+
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
+ // two-finger scroll sends small/fractional pixel deltas, often with
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
+ const isMouseWheel = (e) =>
+ e.deltaMode !== 0 ||
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
+
+ const onWheel = (e) => {
+ e.preventDefault();
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
+ if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
+ // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
+ // wheels fall through to the fixed-step branch below.
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
+ } else if (isMouseWheel(e)) {
+ // notched mouse wheel — fixed-ratio step per click
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
+ } else {
+ // trackpad two-finger scroll — pan
+ tf.current.x -= e.deltaX;
+ tf.current.y -= e.deltaY;
+ apply();
+ }
+ };
+
+ // Safari sends native gesture* events for trackpad pinch with a smooth
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
+ // better feel there. No-ops on other browsers. Safari also fires
+ // ctrlKey wheel events during the same pinch — isGesturing makes
+ // onWheel drop those entirely so they neither zoom nor pan.
+ let gsBase = 1;
+ let isGesturing = false;
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
+ const onGestureChange = (e) => {
+ e.preventDefault();
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
+ };
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
+
+ // Drag-pan: middle button anywhere, or primary button on canvas
+ // background (anything that isn't an artboard or an inline editor).
+ let drag = null;
+ const onPointerDown = (e) => {
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
+ e.preventDefault();
+ vp.setPointerCapture(e.pointerId);
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
+ vp.style.cursor = 'grabbing';
+ };
+ const onPointerMove = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tf.current.x += e.clientX - drag.lx;
+ tf.current.y += e.clientY - drag.ly;
+ drag.lx = e.clientX; drag.ly = e.clientY;
+ apply();
+ };
+ const onPointerUp = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ vp.releasePointerCapture(e.pointerId);
+ drag = null;
+ vp.style.cursor = '';
+ };
+
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
+ const onHostMsg = (e) => {
+ const d = e.data;
+ if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
+ const r = vp.getBoundingClientRect();
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
+ } else if (d && d.type === '__dc_probe') {
+ // Host's [readyGen] reset asks whether a canvas is present; it
+ // fires on the iframe's native 'load', which for canvases with
+ // images/fonts is after our mount-time announce, so re-announce.
+ // Clear the pan-tick guard so apply() re-posts the current scale
+ // even if it's unchanged — the host just reset dcScale to 1.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+ }
+ };
+ window.addEventListener('message', onHostMsg);
+ // Announce canvas mode so the host toolbar proxies its % control here
+ // instead of scaling the iframe element (which would just shrink the
+ // viewport window of an infinite canvas). The apply() that follows emits
+ // the initial __dc_zoom so the toolbar % is correct before first pinch.
+ // lastPostedScale reset mirrors the __dc_probe handler: the layout
+ // effect's restore-path apply() may already have posted the restored
+ // scale (before __dc_present), so clear the guard to re-post it in order.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+
+ vp.addEventListener('wheel', onWheel, { passive: false });
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
+ vp.addEventListener('pointerdown', onPointerDown);
+ vp.addEventListener('pointermove', onPointerMove);
+ vp.addEventListener('pointerup', onPointerUp);
+ vp.addEventListener('pointercancel', onPointerUp);
+ return () => {
+ window.removeEventListener('message', onHostMsg);
+ vp.removeEventListener('wheel', onWheel);
+ vp.removeEventListener('gesturestart', onGestureStart);
+ vp.removeEventListener('gesturechange', onGestureChange);
+ vp.removeEventListener('gestureend', onGestureEnd);
+ vp.removeEventListener('pointerdown', onPointerDown);
+ vp.removeEventListener('pointermove', onPointerMove);
+ vp.removeEventListener('pointerup', onPointerUp);
+ vp.removeEventListener('pointercancel', onPointerUp);
+ };
+ }, [apply, minScale, maxScale]);
+
+ const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
+ return (
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCSection — editable title + h-row of artboards in persisted order
+// ─────────────────────────────────────────────────────────────
+function DCSection({ id, title, subtitle, children, gap = 48 }) {
+ const ctx = React.useContext(DCCtx);
+ const sid = id ?? title;
+ const all = React.Children.toArray(dcFlatten(children));
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
+ const sec = (ctx && sid && ctx.section(sid)) || {};
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
+ const srcKey = allIds.join('\x1f');
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
+
+ const order = React.useMemo(() => {
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
+ }, [sec.order, srcOrder.join('|')]);
+
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
+
+ // marginBottom counter-scales so the on-screen gap between sections stays
+ // constant — otherwise at low zoom the (world-space) gap collapses while
+ // the screen-constant sectionhead below it doesn't, and the title reads as
+ // belonging to the section above. paddingBottom below is just enough for
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
+ // the title sits tight against its own row at every zoom.
+ return (
+
+
+
+
ctx && sid && ctx.patchSection(sid, { title: v })}
+ style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
+ {subtitle && {subtitle}
}
+
+
+
+ {order.map((k) => (
+ ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
+ onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
+ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
+ srcKey,
+ }))}
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
+ ))}
+
+ {rest}
+
+ );
+}
+
+// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
+function DCArtboard() { return null; }
+
+// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
+// self-contained clone: computed styles baked in, @font-face /
/
+// inline-style background-image urls inlined as data URIs. PNG wraps the
+// clone in foreignObject→canvas at 3× the artboard's natural width×height
+// (same pipeline the host uses for page captures); HTML wraps it in a
+// minimal standalone document. Both are independent of viewport zoom.
+async function dcExport(node, w, h, name, kind) {
+ try { await document.fonts.ready; } catch {}
+ const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
+ const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
+ })).catch(() => url);
+
+ // Collect @font-face rules. ss.cssRules throws SecurityError on
+ // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
+ // the CSS text directly (those endpoints send ACAO:*) and regex-extract
+ // the blocks. @import and @media/@supports are walked so nested
+ // @font-face rules aren't missed.
+ const fontRules = [], pending = [], seen = new Set();
+ const scrapeCss = (href) => {
+ if (seen.has(href)) return; seen.add(href);
+ pending.push(fetch(href).then((r) => r.text()).then((css) => {
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
+ scrapeCss(new URL(m[1], href).href);
+ }).catch(() => {}));
+ };
+ const walk = (rules, base) => {
+ for (const r of rules) {
+ if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
+ const ibase = r.styleSheet.href || base;
+ try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
+ } else if (r.cssRules) walk(r.cssRules, base);
+ }
+ };
+ for (const ss of document.styleSheets) {
+ const base = ss.href || location.href;
+ try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
+ }
+ while (pending.length) await pending.shift();
+ const fontCss = (await Promise.all(fontRules.map(async (rule) => {
+ let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
+ while ((m = re.exec(rule.css))) {
+ if (m[2].indexOf('data:') === 0) continue;
+ let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
+ out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
+ }
+ return out;
+ }))).join('\n');
+
+ const cloneStyled = (src) => {
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
+ const dst = src.cloneNode(false);
+ if (src.nodeType === 1) {
+ const cs = getComputedStyle(src); let txt = '';
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
+ if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
+ }
+ for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
+ return dst;
+ };
+ const clone = cloneStyled(node);
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
+ // Drop the card's own shadow/radius so the export is a flush w×h rect;
+ // the artboard's own background (if any) is already in the computed style.
+ clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
+
+ const jobs = [];
+ clone.querySelectorAll('img').forEach((el) => {
+ const s = el.getAttribute('src');
+ if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
+ });
+ [clone, ...clone.querySelectorAll('*')].forEach((el) => {
+ const bg = el.style.backgroundImage; if (!bg) return;
+ let m; const re = /url\(["']?([^"')]+)["']?\)/g;
+ while ((m = re.exec(bg))) {
+ const tok = m[0], url = m[1];
+ if (url.indexOf('data:') === 0) continue;
+ jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
+ }
+ });
+ await Promise.all(jobs);
+
+ const xml = new XMLSerializer().serializeToString(clone);
+ const save = (blob, ext) => {
+ if (!blob) return;
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ };
+
+ if (kind === 'html') {
+ const html = '' + name + '' +
+ (fontCss ? '' : '') +
+ '' + xml + '';
+ return save(new Blob([html], { type: 'text/html' }), 'html');
+ }
+
+ // PNG: the SVG's own width/height must be the output resolution — an
+ //
-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
+ // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
+ // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
+ // the HTML at full resolution.
+ const px = 3;
+ const svg = '';
+ const img = new Image();
+ await new Promise((res, rej) => {
+ img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
+ });
+ const cv = document.createElement('canvas');
+ cv.width = w * px; cv.height = h * px;
+ cv.getContext('2d').drawImage(img, 0, 0);
+ cv.toBlob((blob) => save(blob, 'png'), 'image/png');
+}
+
+function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
+ const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
+ const id = rawId ?? rawLabel;
+ const ref = React.useRef(null);
+ const cardRef = React.useRef(null);
+ const menuRef = React.useRef(null);
+ const [menuOpen, setMenuOpen] = React.useState(false);
+ const [confirming, setConfirming] = React.useState(false);
+
+ // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
+ // the menu — first click arms the row, second commits; closing disarms.
+ React.useEffect(() => {
+ if (!menuOpen) { setConfirming(false); return; }
+ const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
+ document.addEventListener('pointerdown', off, true);
+ return () => document.removeEventListener('pointerdown', off, true);
+ }, [menuOpen]);
+
+ const doExport = (kind) => {
+ setMenuOpen(false);
+ if (!cardRef.current) return;
+ const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
+ dcExport(cardRef.current, width, height, name, kind)
+ .catch((e) => console.error('[design-canvas] export failed:', e));
+ };
+
+ // Live drag-reorder: dragged card sticks to cursor; siblings slide into
+ // their would-be slots in real time via transforms. DOM order only
+ // changes on drop.
+ const onGripDown = (e) => {
+ e.preventDefault(); e.stopPropagation();
+ const me = ref.current;
+ // translateX is applied in local (pre-scale) space but pointer deltas and
+ // getBoundingClientRect().left are screen-space — divide by the viewport's
+ // current scale so the dragged card tracks the cursor at any zoom level.
+ const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
+ const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
+ const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
+ const slotXs = homes.map((h) => h.x);
+ const startIdx = order.indexOf(id);
+ const startX = e.clientX;
+ let liveOrder = order.slice();
+ me.classList.add('dc-dragging');
+
+ const layout = () => {
+ for (const h of homes) {
+ if (h.id === id) continue;
+ const slot = liveOrder.indexOf(h.id);
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
+ }
+ };
+
+ const move = (ev) => {
+ const dx = ev.clientX - startX;
+ me.style.transform = `translateX(${dx / scale}px)`;
+ const cur = homes[startIdx].x + dx;
+ let nearest = 0, best = Infinity;
+ for (let i = 0; i < slotXs.length; i++) {
+ const d = Math.abs(slotXs[i] - cur);
+ if (d < best) { best = d; nearest = i; }
+ }
+ if (liveOrder.indexOf(id) !== nearest) {
+ liveOrder = order.filter((k) => k !== id);
+ liveOrder.splice(nearest, 0, id);
+ layout();
+ }
+ };
+
+ const up = () => {
+ document.removeEventListener('pointermove', move);
+ document.removeEventListener('pointerup', up);
+ const finalSlot = liveOrder.indexOf(id);
+ me.classList.remove('dc-dragging');
+ me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
+ // After the settle transition, kill transitions + clear transforms +
+ // commit the reorder in the same frame so there's no visual snap-back.
+ setTimeout(() => {
+ for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
+ if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ for (const h of homes) h.el.style.transition = '';
+ }));
+ }, 180);
+ };
+ document.addEventListener('pointermove', move);
+ document.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
+
+ e.stopPropagation()}
+ style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
+
+
+
+
+
+ {menuOpen && (
+
e.stopPropagation()}>
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+// Inline rename — commits on blur or Enter.
+function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
+ const T = tag;
+ return (
+ e.stopPropagation()}
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
+ style={style}>{value}
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
+// sections, Esc or backdrop click to exit.
+// ─────────────────────────────────────────────────────────────
+function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
+ const ctx = React.useContext(DCCtx);
+ const { sectionId, artboard } = entry;
+ const sec = ctx.section(sectionId);
+ const meta = sectionMeta[sectionId];
+ const peers = meta.slotIds;
+ const aid = artboard.props.id ?? artboard.props.label;
+ const idx = peers.indexOf(aid);
+ const secIdx = sectionOrder.indexOf(sectionId);
+
+ const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
+ const goSection = (d) => {
+ // Sections whose artboards are all deleted have slotIds:[] — step past
+ // them to the next non-empty section so ↑/↓ doesn't dead-end.
+ const n = sectionOrder.length;
+ for (let i = 1; i < n; i++) {
+ const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
+ if (first) { ctx.setFocus(`${ns}/${first}`); return; }
+ }
+ };
+
+ React.useEffect(() => {
+ const k = (e) => {
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
+ if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
+ };
+ document.addEventListener('keydown', k);
+ return () => document.removeEventListener('keydown', k);
+ });
+
+ const { width = 260, height = 480, children } = artboard.props;
+ const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
+ React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
+ const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
+
+ const [ddOpen, setDd] = React.useState(false);
+ const Arrow = ({ dir, onClick }) => (
+
+ );
+
+ // Portal to body so position:fixed is the real viewport regardless of any
+ // transform on DesignCanvas's ancestors (including the canvas zoom itself).
+ return ReactDOM.createPortal(
+ ctx.setFocus(null)}
+ onWheel={(e) => e.preventDefault()}
+ style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
+ fontFamily: DC.font, color: '#fff' }}>
+
+ {/* top bar: section dropdown (left) · close (right) */}
+
e.stopPropagation()}
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
+
+
+ {ddOpen && (
+
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* card centered, label + index below — only the card itself stops
+ propagation so any backdrop click (including the margins around
+ the card) exits focus */}
+
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
+
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
+ {(sec.labels || {})[aid] ?? artboard.props.label}
+ {idx + 1} / {peers.length}
+
+
+
+
go(-1)} />
+ go(1)} />
+
+ {/* dots */}
+ e.stopPropagation()}
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
+ {peers.map((p, i) => (
+
+ ,
+ document.body,
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Post-it — absolute-positioned sticky note
+// ─────────────────────────────────────────────────────────────
+function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
+ return (
+ {children}
+ );
+}
+
+Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
+
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-admin.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-admin.jsx
new file mode 100644
index 0000000..5990d6e
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-admin.jsx
@@ -0,0 +1,80 @@
+// hf-admin.jsx — Admin shell (PUC-8 / PUC-9) hi-fi. Honestly empty + states.
+const { HFScreen: AScreen, HFTopBar: ATopBar, HFAccountChip: AChip, HFBanner: ABanner,
+ HFStateCard: AStateCard, HFStates: AStates, HF_H: A, HF_STORE: ASTORE, HF_DSC: ADS } = window;
+const { Eyebrow: AEyebrow, Soul: ASoul } = ADS;
+
+// storefront identity lockup — the admin's anchor
+function StoreId({ size = 17 }) {
+ return (
+
+
+
+ {ASTORE}
+ ecomm storefront
+
+
+ );
+}
+
+// the honest-empty centerpiece — intentional, not sparse
+function EmptyHome({ compact = false }) {
+ return (
+
+ {/* faint constellation seal — one decorative mark, low alpha */}
+
+

+
+
Your storefront
+
{ASTORE}
+
+ 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.
+
+
+ );
+}
+
+function HFAdmin({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ return (
+
+ }
+ right={desktop ? : } />
+
+
+ );
+}
+
+function HFAdminStates() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ For your security you've been signed out. Sign in again →
+
+
+
+
+
+
+ );
+}
+function Skel({ w, h, r = 6 }) {
+ return ;
+}
+
+Object.assign(window, { HFAdmin, HFAdminStates });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-bootstrap.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-bootstrap.jsx
new file mode 100644
index 0000000..6310f70
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-bootstrap.jsx
@@ -0,0 +1,205 @@
+// hf-bootstrap.jsx — Bootstrap (PUC-10 / PUC-11) hi-fi. Terminals + runbook.
+const { HF_H: B, HF_STARS: B_STARS, HF_DSC: BDS } = window;
+const { Eyebrow: BEyebrow, Soul: BSoul, Callout: BCallout } = BDS;
+const BMONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
+
+function BBay({ children, pad = 32 }) {
+ return {children}
;
+}
+function BHead({ kicker, title, sub }) {
+ return (
+
+
{kicker}
+
{title}
+ {sub &&
{sub}
}
+
+ );
+}
+
+function BTerminal({ title, lines, style = {} }) {
+ return (
+
+
+
+ {['rgba(155,140,255,.55)', 'rgba(244,199,107,.55)', 'rgba(237,234,255,.32)'].map((c, i) => (
+
+ ))}
+
+ {title}
+
+
+ {lines.map((ln, i) => )}
+
+
+ );
+}
+function BLine({ ln }) {
+ const [k, t] = ln;
+ if (k === 'sp') return ;
+ if (k === 'cmd') return $ {t}
;
+ if (k === 'step') return → {t}
;
+ if (k === 'ok') return ✓ {t}
;
+ if (k === 'hl') return {t}
;
+ if (k === 'note') return {t}
;
+ return {t}
;
+}
+
+// 1 · principle ----------------------------------------------------------------
+function HFBootPrinciple() {
+ const steps = [
+ ['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
+ ['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
+ ['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
+ ['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
+ ['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
+ ];
+ return (
+
+
+
+ {steps.map(([t, d, c], i) => {
+ const col = c === 'gold' ? B.gold : B.lilac;
+ return (
+
+
+ {i + 1}
+ {i < steps.length - 1 && }
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+// 2 · runbook (Paper) ----------------------------------------------------------
+function HFBootRunbook() {
+ const envs = [
+ ['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
+ ['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
+ ['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
+ ];
+ return (
+
+
+ docs/BOOTSTRAP.md
+ the documented gesture
+
+
Bringing ecomm up from empty
+
+ One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
+
+
+ {envs.map(([env, cmd, note]) => (
+
+ ))}
+
+
+
Empty is a working state — there is no seed.
+
+ );
+}
+
+// 3 · localhost terminal -------------------------------------------------------
+function HFBootDev() {
+ return (
+
+
+
+
+ );
+}
+
+// 4 · the OTC channel ----------------------------------------------------------
+function HFBootChannel() {
+ return (
+
+
+
+
+ Dev — the code prints to the backend log
+
+
+
+
Deployed — the code arrives as real email
+
+
+ ecomm <no-reply@ecomm.wiggleverse.org>now
+
+
Your ecomm code: 419 204
+
+ Enter this code to sign in. It's good for 10 minutes. If you didn't request it, you can ignore this email.
+
+
+
+
+
+ );
+}
+function ChanLabel({ children, tone }) {
+ const c = tone === 'gold' ? B.gold : B.lilac;
+ return
+ {tone === 'gold' ? '◆' : '◇'}{children}
+
;
+}
+
+// 5 · deploy terminal ----------------------------------------------------------
+function HFBootDeploy() {
+ return (
+
+
+
+
+ );
+}
+
+Object.assign(window, { HFBootPrinciple, HFBootRunbook, HFBootDev, HFBootChannel, HFBootDeploy });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-intro.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-intro.jsx
new file mode 100644
index 0000000..18c24d8
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-intro.jsx
@@ -0,0 +1,87 @@
+// hf-intro.jsx — Hi-fi cover: the design language Direction A locks in.
+const { HFScreen: IScreen, HFWordmark: IWordmark, HF_H: I, HF_DSC: IDS } = window;
+const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
+
+function HFCover() {
+ const langs = [
+ ['Ground', 'Midnight sky with a barely-there starfield; a gold horizon glow warms the bottom of entry screens.'],
+ ['Depth', 'Carried by layered surfaces and hairlines — not drop shadows. Only the auth card earns real elevation.'],
+ ['Type', 'Space Grotesk for structure, Inter for body — calm and precise, never hypey. Sentence case throughout; uppercase only for small eyebrows.'],
+ ['Action', 'One gold CTA per screen. Lilac for links and nodes. Hover lifts 1px; focus is a gold ring.'],
+ ['Attention', 'Gold, always — the palette carries no red. Honest failure, never a faked success.'],
+ ['Emptiness', 'A finished state with its own composition — no zeroed tiles, no locked-feature teasers.'],
+ ];
+ return (
+
+
+
+ SD-0001 · Hi-fi · Direction A
+
+
+
Quiet centered — the full design
+
+ The language, before
the hundred screens
+
+
+ Every surface below is the same six decisions, applied. Get these right and the next ninety-six
+ screens inherit a calm, honest house style for free.
+
+
+
+ {langs.map(([k, v]) => (
+
+ ))}
+
+
+ );
+}
+
+// Entry routing — the shared spine, hi-fi
+function HFRouting() {
+ const lanes = [
+ { sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
+ { sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
+ { sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
+ ];
+ return (
+
+ The shared spine
+ Entry routing
+
+ One server answer — GET /api/auth/me — decides where every arrival lands.
+
+
+ {lanes.map((l, i) => {
+ const c = l.tone === 'gold' ? I.gold : l.tone === 'lilac' ? I.lilac : I.mute;
+ return (
+
+
+ {l.sess}{l.store}
+
+
→
+
+ {l.arrow}
+ {l.puc}
+
+
+ );
+ })}
+
+
+ ◇
+ Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
+
+
+ );
+}
+function Cond({ children }) {
+ return {children};
+}
+
+Object.assign(window, { HFCover, HFRouting });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-kit.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-kit.jsx
new file mode 100644
index 0000000..60bb7a7
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-kit.jsx
@@ -0,0 +1,267 @@
+// hf-kit.jsx — Wiggleverse ecomm HI-FI primitives (Direction A · Quiet centered).
+// Depth = layered surfaces + hairlines (the brand avoids shadows). Hover = 1–3px lift.
+// Merchant persona: Ben's Bets · ben@bensbets.com
+
+const HFDS = window.WiggleverseDesignSystem_94cd80;
+const { Button: HFButton, Soul: HFSoul, Eyebrow: HFEyebrow, Tag: HFTag } = HFDS;
+
+// Resolve a brand asset: blob URL when bundled offline (window.__resources), else relative path.
+const HF_ASSET = (id, path) => (window.__resources && window.__resources[id]) || path;
+window.HF_ASSET = HF_ASSET;
+
+// ---- token shorthands ----------------------------------------------------
+const H = {
+ sky: 'var(--wv-midnight)', night: 'var(--wv-night)',
+ raised: 'var(--wv-indigo)', raisedHi: 'var(--wv-indigo-2)',
+ star: 'var(--wv-starlight)', soft: 'var(--wv-starlight-78)',
+ mute: 'var(--wv-starlight-60)', faint: 'var(--wv-starlight-55)',
+ lilac: 'var(--wv-lilac)', violet: 'var(--wv-violet)',
+ gold: 'var(--wv-gold)', goldHi: 'var(--wv-gold-hi)', goldInk: 'var(--wv-gold-ink)',
+ hair: 'var(--border-soft)', hairCard: 'var(--border-card)', hairStrong: 'var(--border-strong)',
+ disp: 'var(--wv-font-display)', body: 'var(--wv-font-body)', soul: 'var(--wv-font-human)',
+ shadow: 'var(--shadow-soft)',
+};
+const MENU_EMAIL = 'ben@bensbets.com';
+const STORE = "Ben's Bets";
+
+// barely-there starfield (Direction A: restrained) — a few asymmetric nodes
+const HF_STARS =
+ '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)';
+// gold horizon — the brand's warmth, a low glow at the bottom of the sky
+const HF_HORIZON = 'radial-gradient(120% 70% at 50% 142%, rgba(244,199,107,.16) 0%, rgba(244,199,107,.05) 38%, transparent 64%)';
+
+// One-time injected stylesheet: hover + focus affordances (subtle motion only).
+function HFStyles() {
+ return (
+
+ );
+}
+
+// ── Screen ground ──────────────────────────────────────────────────────────
+function HFScreen({ children, horizon = false, stars = true, bg = H.sky, style = {} }) {
+ const layers = [];
+ if (stars) layers.push(HF_STARS);
+ if (horizon) layers.unshift(HF_HORIZON);
+ const background = layers.length ? `${layers.join(',')}, ${bg}` : bg;
+ return (
+ {children}
+ );
+}
+
+// ── Wordmark — mark tile + "ecomm" in Space Grotesk ───────────────────────
+function HFWordmark({ size = 26, sub = false }) {
+ return (
+
+
+
+ ecomm
+ {sub && by Wiggleverse}
+
+
+ );
+}
+
+// ── TopBar — glass chrome ──────────────────────────────────────────────────
+function HFTopBar({ left, right, pad = '0 36px', height = 68 }) {
+ return (
+
+ );
+}
+
+function HFAccountChip({ email = MENU_EMAIL, menu = false }) {
+ return (
+
+
B
+
{email}
+
+
+ {menu && (
+
+
+
+
Account settings
+
Sign out
+
+ )}
+
+ );
+}
+
+// ── Field ──────────────────────────────────────────────────────────────────
+function HFField({ label, placeholder, value, helper, error, focus = false, optional = false, hint }) {
+ const cls = `field${focus ? ' is-focus' : ''}${error ? ' is-attn' : ''}`;
+ return (
+
+
+
+ {value || placeholder}
+ {focus && !value && }
+ {hint && {hint}}
+
+ {error &&
{error}}
+ {helper && !error &&
{helper}}
+
+ );
+}
+function HFNote({ children, tone }) {
+ return
+ {tone === 'attn' && ◆}
+ {children}
+ ;
+}
+
+// ── CodeField — 6 cells ─────────────────────────────────────────────────────
+function HFCodeField({ filled = 0, error = false, vals = ['4', '1', '9', '2', '0', '4'] }) {
+ return (
+
+ {[0, 1, 2, 3, 4, 5].map((i) => {
+ const active = i === filled && !error;
+ const has = i < filled;
+ return (
+
+ {has ? vals[i] : (active ? : '')}
+
+ );
+ })}
+
+ );
+}
+
+// ── Banner — gold attention / lilac info (no red in palette) ───────────────
+function HFBanner({ children, tone = 'attn', title }) {
+ const attn = tone === 'attn';
+ return (
+
+
{attn ? '◆' : '◇'}
+
+ {title &&
{title}
}
+ {children}
+
+
+ );
+}
+
+// ── AuthCard — the one surface that earns a real shadow ────────────────────
+function HFAuthCard({ children, width = 440 }) {
+ return (
+ {children}
+ );
+}
+
+// ── Primary action wrapper (DS Button, full-width option) ──────────────────
+function HFPrimary({ children, full = true, disabled = false }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ── Footer ──────────────────────────────────────────────────────────────────
+function HFFooter({ pad = '20px 36px' }) {
+ return (
+
+ );
+}
+
+// ── Heading helper ───────────────────────────────────────────────────────────
+function HFHeading({ children, sub, size = 27 }) {
+ return (
+
+
{children}
+ {sub &&
{sub}
}
+
+ );
+}
+const hfHonest = { fontFamily: H.body, fontSize: 13, color: H.mute, lineHeight: 1.5, margin: 0 };
+
+// ── StateCard — frames a single state in the states stacks ─────────────────
+function HFStateCard({ label, note, children }) {
+ return (
+
+
+ {label}
+ {note && {note}}
+
+
{children}
+
+ );
+}
+function HFStates({ children }) {
+ return {children}
;
+}
+
+Object.assign(window, {
+ HF_H: H, HF_STARS, HF_HORIZON, HF_STORE: STORE, HF_EMAIL: MENU_EMAIL, HF_DSC: HFDS,
+ HFStyles, HFScreen, HFWordmark, HFTopBar, HFAccountChip, HFField, HFNote, HFCodeField,
+ HFBanner, HFAuthCard, HFPrimary, HFFooter, HFHeading, HFStateCard, HFStates,
+ hfHonest,
+});
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-landing.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-landing.jsx
new file mode 100644
index 0000000..6cd6102
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-landing.jsx
@@ -0,0 +1,86 @@
+// hf-landing.jsx — Landing (PUC-1), Direction A hi-fi. Desktop + mobile.
+const { HFScreen: LScreen, HFWordmark: LWordmark, HFTopBar: LTopBar, HFFooter: LFooter,
+ HFPrimary: LPrimary, HF_H: L, HF_DSC: LDS } = window;
+const { Soul: LSoul, Eyebrow: LEyebrow } = LDS;
+
+function NavLinks({ mobile = false }) {
+ if (mobile) return Log in;
+ return (
+
+ );
+}
+
+// trust row — three honest promises, hairline-separated (no icons-as-slop)
+function Promises({ device }) {
+ const items = [
+ ['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."],
+ ];
+ return (
+
+ {items.map(([t, d], i) => (
+
0 ? `1px solid ${L.hair}` : 'none',
+ }}>
+
+ ◆
+ {t}
+
+
{d}
+
+ ))}
+
+ );
+}
+
+function HFLanding({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ 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.
+
+
+
+
+
+
+
+ );
+}
+
+Object.assign(window, { HFLanding });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-signin.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-signin.jsx
new file mode 100644
index 0000000..c39ace5
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-signin.jsx
@@ -0,0 +1,116 @@
+// hf-signin.jsx — Sign in (PUC-2 / PUC-3) hi-fi. Email → one-time code + states.
+const { HFScreen: SScreen, HFWordmark: SWordmark, HFTopBar: STopBar, HFFooter: SFooter,
+ HFAuthCard: SAuthCard, HFField: SField, HFCodeField: SCodeField, HFBanner: SBanner,
+ HFPrimary: SPrimary, HFHeading: SHeading, HFStateCard: SStateCard, HFStates: SStates,
+ HF_H: S, hfHonest: sHonest } = window;
+
+function SBack({ label = 'Back' }) {
+ return ← {label};
+}
+
+// ── Step 1 · email ──────────────────────────────────────────────────────────
+function EmailStep({ framing = 'signup', s = {} }) {
+ const login = framing === 'login';
+ return (
+ <>
+
+
+ {login ? 'Log in' : 'Create your storefront'}
+
+ {s.deliveryFail && The email didn't go out. Try again in a moment — nothing was lost.}
+
+
+
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}
+
We'll email you a one-time code. That's all we need — no password, ever.
+
+ >
+ );
+}
+
+// ── Step 2 · code ────────────────────────────────────────────────────────────
+function CodeStep({ s = {} }) {
+ const email = s.email || 'ben@bensbets.com';
+ let err = null;
+ if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
+ if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
+ return (
+ <>
+
+ We sent a 6-digit code to {email}. Wrong address?>}>
+ Check your email
+
+ {s.welcome &&
+ {s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
+ }
+
+
+ {err && ◆{err}}
+
+
+ >
+ );
+}
+
+function HFSignIn({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ return (
+
+ } right={} />
+
+
+ {step === 'email' ? : }
+
+
+ {desktop && }
+
+ );
+}
+
+// ── States stack ─────────────────────────────────────────────────────────────
+function codeNote(text) {
+ return ◆{text};
+}
+function HFSignInStates() {
+ return (
+
+
+
+
+
+
+ Resend in 47s
+ One code per minute keeps inboxes — and abuse — in check.
+
+
+
+ The email didn't go out — try again. We'd never fake a success.
+
+
+ {codeNote("That code didn't match — 2 attempts left.")}
+
+
+ {codeNote('That code expired. Send a fresh one below.')}
+
+
+ That code is no longer valid. Request a fresh one to keep going.
+
+
+
+ A new account was created for this email.
+ Signed in to your existing account.
+
+
+
+ );
+}
+
+Object.assign(window, { HFSignIn, HFSignInStates });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-storefront.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-storefront.jsx
new file mode 100644
index 0000000..4692fa1
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/hf-storefront.jsx
@@ -0,0 +1,79 @@
+// hf-storefront.jsx — Create storefront (PUC-4/5/7) hi-fi + states.
+const { HFScreen: FScreen, HFWordmark: FWordmark, HFTopBar: FTopBar, HFAccountChip: FChip,
+ HFFooter: FFooter, HFAuthCard: FAuthCard, HFField: FField, HFBanner: FBanner,
+ HFPrimary: FPrimary, HFStateCard: FStateCard, HFStates: FStates, HF_H: F, HF_DSC: FDS } = window;
+const { Eyebrow: FEyebrow } = FDS;
+
+function CreateForm({ s = {} }) {
+ return (
+ <>
+
+
One storefront, fully yours
+
+ Create your storefront
+
+
+ This is the one thing to do right now. It costs nothing and commits you to nothing.
+
+
+ {s.alreadyOwns &&
+ ecomm is one storefront per account today. Go to your admin →
+ }
+
+ {s.blankPreview && (
+
+ ◇
+ Saves as “ben's storefront” — change it later.
+
+ )}
+
+
{s.creating ? 'Creating…' : 'Create storefront'}
+
+ You can rename, configure, or close your storefront whenever you like.
+
+
+ >
+ );
+}
+
+function HFCreate({ device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ return (
+
+ }
+ right={desktop ? : } />
+
+
+
+ {desktop && }
+
+ );
+}
+
+function HFCreateStates() {
+ return (
+
+
+
+
+
+ ◇
+ Saves as “ben's storefront”
+
+
+
+
+ Creating…
+
+
+
+ ecomm is one storefront per account today. Go to your admin →
+
+
+
+ );
+}
+
+Object.assign(window, { HFCreate, HFCreateStates });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-admin.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-admin.jsx
new file mode 100644
index 0000000..879214b
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-admin.jsx
@@ -0,0 +1,128 @@
+// wf-admin.jsx — Screen 4: Admin shell (PUC-8, PUC-9). A/B + states.
+const { Screen: AScreen, Wordmark: AWordmark, TopBar: ATopBar, AccountChip: AAccountChip,
+ Banner: ABanner, EmptyAdmin: AEmptyAdmin, StateBlock: AStateBlock, WF_T: AT, WF_DS: ADS } = window;
+const { Eyebrow: AEyebrow } = ADS;
+
+const STORE = 'Studio Fern';
+
+// storefront identity lockup — the admin's anchor (left of top bar in A)
+function StoreId({ size = 17 }) {
+ return (
+
+
+
+ {STORE}
+ ecomm storefront
+
+
+ );
+}
+
+// ── Direction A — top bar + centered honest-empty ──────────────────────────
+function AdminA({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ return (
+
+ }
+ right={desktop ? : } />
+
+
+ );
+}
+
+// ── Direction B — durable app shell (sidebar + content) ────────────────────
+function AdminB({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ if (!desktop) {
+ return (
+
+ } right={} />
+
+ Home
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+}
+
+function Sidebar() {
+ return (
+
+ );
+}
+
+function NavItem({ children, active, full }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+const signOutBtn = { fontFamily: AT.disp, fontWeight: 500, fontSize: 14, color: AT.star, background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 };
+
+// ── States stack ───────────────────────────────────────────────────────────
+function AdminStates() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ For your security you've been signed out. Sign in again →
+
+
+
+
+
+
+ );
+}
+function Skel({ w, h }) {
+ return ;
+}
+
+Object.assign(window, { AdminA, AdminB, AdminStates });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-bootstrap.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-bootstrap.jsx
new file mode 100644
index 0000000..2105b31
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-bootstrap.jsx
@@ -0,0 +1,214 @@
+// wf-bootstrap.jsx — Section 5: Bootstrap (PUC-10 / PUC-11, INV-1/INV-7).
+// Operator & developer surfaces — terminals and a runbook, not app screens.
+const { Screen: BScreen, Wordmark: BWordmark, WF_T: BT, WF_DS: BDS } = window;
+const { Eyebrow: BEyebrow, Soul: BSoul, Tag: BTag, Callout: BCallout } = BDS;
+
+const MONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
+
+// ── Terminal window ────────────────────────────────────────────────────────
+function Terminal({ title, lines, style = {} }) {
+ return (
+
+
+
+ {['rgba(155,140,255,.5)', 'rgba(244,199,107,.5)', 'rgba(237,234,255,.3)'].map((c, i) => (
+
+ ))}
+
+ {title}
+
+
+ {lines.map((ln, i) => )}
+
+
+ );
+}
+function TLine({ ln }) {
+ const [k, t] = ln;
+ if (k === 'sp') return ;
+ if (k === 'cmd') return $ {t}
;
+ if (k === 'step') return → {t}
;
+ if (k === 'ok') return ✓ {t}
;
+ if (k === 'hl') return {t}
;
+ if (k === 'note') return {t}
;
+ return {t}
;
+}
+
+// ── Wrap a section artboard on the sky ─────────────────────────────────────
+function Bay({ children, pad = 30 }) {
+ return {children}
;
+}
+function BayHead({ kicker, title, sub }) {
+ return (
+
+
{kicker}
+
{title}
+ {sub &&
{sub}
}
+
+ );
+}
+
+// ── 1 · The principle — empty is a working state (INV-1) ───────────────────
+function BootstrapPrinciple() {
+ const steps = [
+ ['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
+ ['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
+ ['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
+ ['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
+ ['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
+ ];
+ return (
+
+
+
+ {steps.map(([t, d, c], i) => {
+ const col = c === 'gold' ? BT.gold : BT.lilac;
+ return (
+
+
+ {i + 1}
+ {i < steps.length - 1 && }
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ── 2 · The runbook — docs/BOOTSTRAP.md (paper) ────────────────────────────
+function RunbookDoc() {
+ const envs = [
+ ['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
+ ['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
+ ['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
+ ];
+ return (
+
+
+ docs/BOOTSTRAP.md
+ the documented gesture
+
+
Bringing ecomm up from empty
+
+ One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
+
+
+ {envs.map(([env, cmd, note], i) => (
+
+ ))}
+
+
+
Empty is a working state — there is no seed.
+
+ );
+}
+
+// ── 3 · localhost — scripts/dev.sh ─────────────────────────────────────────
+function DevTerminal() {
+ const lines = [
+ ['cmd', 'git clone …/wiggleverse-ecomm && cd wiggleverse-ecomm'],
+ ['cmd', './scripts/dev.sh'],
+ ['sp'],
+ ['step', 'datastore: starting postgres (docker compose up)…'],
+ ['ok', 'postgres healthy · ecomm_dev · :5432'],
+ ['step', 'migrations: applying pending…'],
+ ['ok', '0001_init applied · schema current'],
+ ['step', 'backend: uvicorn on :8000'],
+ ['step', 'web: vite on :5173 (proxying /api)'],
+ ['ok', '/healthz 200 {status:"ok", migrations:"current"}'],
+ ['sp'],
+ ['out', ' ecomm is up against an empty database.'],
+ ['hl', ' open http://localhost:5173'],
+ ['sp'],
+ ['note', 'one-time codes print to this log (LogMailer) — no real mail in dev.'],
+ ];
+ return (
+
+
+
+
+ );
+}
+
+// ── 4 · The code channel — dev log vs real email ───────────────────────────
+function DevChannel() {
+ return (
+
+
+
+
+ Dev — the code prints to the backend log
+
+
+
+
Deployed — the code arrives as real email
+
+
+ ecomm <no-reply@ecomm.wiggleverse.org>now
+
+
Your ecomm code: 419 204
+
+ Enter this code to sign in. It's good for 10 minutes. If you didn't request it, ignore this email.
+
+
+
+
+
+ );
+}
+function ChanLabel({ children, tone }) {
+ const c = tone === 'gold' ? BT.gold : BT.lilac;
+ return
+ {tone === 'gold' ? '◆' : '◇'}{children}
+
;
+}
+
+// ── 5 · deployed — flotilla (PPE then Prod) ────────────────────────────────
+function DeployTerminal() {
+ const lines = [
+ ['cmd', 'flotilla deploy ecomm --env ppe'],
+ ['sp'],
+ ['step', 'provision: Cloud SQL (postgres) · backups + PITR…'],
+ ['ok', 'database ready · secrets resolved from Secret Manager'],
+ ['step', 'deploy: image → single VM · fail-stop'],
+ ['step', 'migrations: applying at startup…'],
+ ['ok', '0001_init applied'],
+ ['ok', '/healthz 200 — ppe live'],
+ ['out', ' rehearsal: real sign-up (real email) → storefront → admin ✓'],
+ ['sp'],
+ ['cmd', 'flotilla deploy ecomm --env prod'],
+ ['ok', 'same gesture — day-one prod is the bootstrap state'],
+ ];
+ return (
+
+
+
+
+ );
+}
+
+Object.assign(window, { BootstrapPrinciple, RunbookDoc, DevTerminal, DevChannel, DeployTerminal });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-intro.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-intro.jsx
new file mode 100644
index 0000000..fa21918
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-intro.jsx
@@ -0,0 +1,175 @@
+// wf-intro.jsx — System framing artboards: the two directions + entry-routing spine.
+const { Screen: IScreen, Wordmark: IWordmark, WF_T: IT, WF_DS: IDS } = window;
+const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
+
+// ── The two directions, side by side ───────────────────────────────────────
+function TwoDirections() {
+ return (
+
+
+
+ SD-0001 · MVP wireframes
+
+
+
One A/B, run across all four screens
+
+ Two bets on the lasting frame
+
+
+ These are the first screens of a product headed for hundreds. The variation isn't cosmetic — each
+ direction is a different answer to how much frame to commit to on day one. Same content, same tokens,
+ same honest copy; different skeleton.
+
+
+
+
+
+
+
+
◇
+
+ Both honor the OHM bar. No trial clock, no plan wall, no fabricated metrics or
+ locked-feature teasers. Empty is an honest, finished state — not a sales surface. Attention states use gold,
+ since the palette carries no red.
+
+
+
+ );
+}
+
+function DirCard({ letter, name, bet, lines, best, cost, tone }) {
+ const c = tone === 'gold' ? IT.gold : IT.lilac;
+ return (
+
+
+
+ {lines.map((l, i) => (
+ -
+ ·{l}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+function TradeRow({ k, v, c }) {
+ return (
+
+ {k}
+ {v}
+
+ );
+}
+
+// ── Entry routing — the exhaustive arrival rule (§6.5) ─────────────────────
+function EntryRouting() {
+ const lanes = [
+ { sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
+ { sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
+ { sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
+ ];
+ return (
+
+ The shared spine
+
+ Entry routing
+
+
+ One server answer — GET /api/auth/me —
+ decides where every arrival lands. Every screen here obeys it.
+
+
+ {lanes.map((l, i) => {
+ const c = l.tone === 'gold' ? IT.gold : l.tone === 'lilac' ? IT.lilac : IT.mute;
+ return (
+
+
+ {l.sess}
+ {l.store}
+
+
→
+
+ {l.arrow}
+ {l.puc}
+
+
+ );
+ })}
+
+
+ ◇
+ Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
+
+
+
+ );
+}
+function Cond({ children }) {
+ return {children};
+}
+
+// ── Direction A, chosen — the system this locks in ─────────────────────────
+function SystemA() {
+ const patterns = [
+ ['Entry surfaces', 'One centered card on the midnight sky — landing, sign-in, create. Content-led, no side chrome.'],
+ ['Admin', 'A slim top bar over a centered state. The storefront name is the anchor; no sidebar yet.'],
+ ['Actions', 'One primary gold CTA per screen. Lilac for secondary links. Ghost buttons stay rare.'],
+ ['Attention', 'Errors and waits read in gold — the palette carries no red. Honest failure, never a fake success.'],
+ ['Emptiness', 'Honestly empty everywhere: no zeroed tiles, no locked-feature teasers, no manufactured aspiration.'],
+ ];
+ return (
+
+
+
+ SD-0001 · MVP wireframes
+
+
+
Chosen direction
+
+ Quiet centered
+
+
+ A single calm column on the sky, with the least chrome the job allows. The honest voice carries the rest —
+ and every screen here uses the same tokens, the same copy register, the same primary action.
+
+
+
+ {patterns.map(([k, v], i) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+
◆
+
+ Deferred, not forgotten. When the product grows past forms, a persistent
+ nav frame gets introduced as its own deliberate step — designed then, against real sections, not guessed at now.
+
+
+
+ );
+}
+
+Object.assign(window, { SystemA, TwoDirections, EntryRouting });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-kit.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-kit.jsx
new file mode 100644
index 0000000..15b7c75
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-kit.jsx
@@ -0,0 +1,303 @@
+// wf-kit.jsx — Wiggleverse ecomm wireframe primitives.
+// Mid-fi: real Wiggleverse tokens + composed DS components (Button, Soul, Eyebrow, Tag, Notice).
+// Everything exported to window at the bottom so sibling babel scripts can use it.
+
+const DS = window.WiggleverseDesignSystem_94cd80;
+const { Button, Soul, Eyebrow, Tag, Notice } = DS;
+
+// ---- token shorthands (resolved from the linked token stylesheets) -------
+const T = {
+ sky: 'var(--wv-midnight)',
+ raised: 'var(--wv-indigo)',
+ raisedHi: 'var(--wv-indigo-2)',
+ night: 'var(--wv-night)',
+ star: 'var(--wv-starlight)',
+ soft: 'var(--wv-starlight-78)',
+ mute: 'var(--wv-starlight-60)',
+ lilac: 'var(--wv-lilac)',
+ gold: 'var(--wv-gold)',
+ goldInk: 'var(--wv-gold-ink)',
+ hair: 'var(--border-soft)',
+ hairCard: 'var(--border-card)',
+ disp: 'var(--wv-font-display)',
+ body: 'var(--wv-font-body)',
+};
+
+// faint scattered starfield — the brand's one decorative texture (off-center, low alpha)
+const STARFIELD =
+ 'radial-gradient(1.4px 1.4px at 18% 24%, rgba(237,234,255,.5), transparent),' +
+ 'radial-gradient(1.4px 1.4px at 73% 16%, rgba(237,234,255,.35), transparent),' +
+ 'radial-gradient(1.2px 1.2px at 88% 52%, rgba(155,140,255,.45), transparent),' +
+ 'radial-gradient(1.6px 1.6px at 32% 70%, rgba(237,234,255,.4), transparent),' +
+ 'radial-gradient(1.2px 1.2px at 58% 84%, rgba(237,234,255,.3), transparent),' +
+ 'radial-gradient(1.3px 1.3px at 8% 58%, rgba(155,140,255,.4), transparent),' +
+ 'radial-gradient(1.1px 1.1px at 46% 40%, rgba(237,234,255,.28), transparent)';
+
+// ── Screen — fills the artboard, the midnight ground, column flow ──────────
+function Screen({ children, starfield = false, bg = T.sky, style = {} }) {
+ return (
+ {children}
+ );
+}
+
+// ── Wordmark — the mark tile + "ecomm" (Space Grotesk) ─────────────────────
+function Wordmark({ size = 26, muted = false }) {
+ return (
+
+
+ ecomm
+
+ );
+}
+
+// ── TopBar — the chrome shared by every authenticated surface ──────────────
+function TopBar({ left, right, pad = '0 32px', height = 64 }) {
+ return (
+
+ );
+}
+
+// account chip used at top-right of authenticated surfaces
+function AccountChip({ email = 'mara@studiofern.com', menu = false }) {
+ return (
+
+
{email}
+
+
+ {menu && (
+
+ )}
+
+ );
+}
+function MenuRow({ children, strong }) {
+ return {children}
;
+}
+
+// ── Field — label + input + helper/error. Real token styling. ──────────────
+function Field({ label, placeholder, value, helper, error, focus = false, optional = false }) {
+ return (
+
+
+
+ {value || placeholder}
+ {focus && !value && }
+
+ {error &&
{error}}
+ {helper && !error &&
{helper}}
+
+ );
+}
+function FieldNote({ children, tone }) {
+ return
+ {tone === 'attn' && ◆}
+ {children}
+ ;
+}
+
+// ── CodeField — one-time-code entry, 6 cells ───────────────────────────────
+function CodeField({ filled = 0, error = false }) {
+ const cells = [0, 1, 2, 3, 4, 5];
+ const vals = ['4', '1', '9', '', '', ''];
+ return (
+
+ {cells.map((i) => {
+ const active = i === filled;
+ const has = i < filled;
+ return (
+
+ {has ? vals[i] : (active ? : '')}
+
+ );
+ })}
+
+ );
+}
+
+// ── Banner — inline status. attn = gold (no red in palette); info = lilac ──
+function Banner({ children, tone = 'attn', title }) {
+ const attn = tone === 'attn';
+ return (
+
+
+ {attn ? '◆' : '◇'}
+
+
+ {title &&
{title}
}
+ {children}
+
+
+ );
+}
+
+// ── AuthCard — the centered raised surface for Direction A ─────────────────
+function AuthCard({ children, width = 420 }) {
+ return (
+ {children}
+ );
+}
+
+// ── BrandRail — the persistent identity panel for Direction B ──────────────
+function BrandRail({ compact = false, soul = 'Your storefront is yours.' }) {
+ return (
+
+ );
+}
+
+// ── Footer — minimal project identity + the standing dedication ────────────
+function Footer({ onDark = true }) {
+ return (
+
+ );
+}
+
+// ── Section wrapper used in the canvas to title state stacks ───────────────
+function StateBlock({ label, note, children, h }) {
+ return (
+
+
+ {label}
+ {note && {note}}
+
+
{children}
+
+ );
+}
+
+// ── Honest-empty admin body — the heart of PUC-8 ───────────────────────────
+function EmptyAdmin({ storefront = 'Studio Fern', compact = false }) {
+ return (
+
+
Your storefront
+
{storefront}
+
+ There's nothing to manage yet. Catalog, orders, and settings will appear
+ here as ecomm grows.
+
+
+ );
+}
+
+Object.assign(window, {
+ WF_T: T, WF_STARFIELD: STARFIELD,
+ Screen, Wordmark, TopBar, AccountChip, MenuRow, Field, FieldNote, CodeField,
+ Banner, AuthCard, BrandRail, Footer, StateBlock, EmptyAdmin,
+ WF_DS: DS,
+});
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-landing.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-landing.jsx
new file mode 100644
index 0000000..411db2e
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-landing.jsx
@@ -0,0 +1,141 @@
+// wf-landing.jsx — Screen 1: Landing (PUC-1). Two directions.
+const { Screen: LScreen, Wordmark: LWordmark, TopBar: LTopBar, Footer: LFooter, WF_T: LT, WF_DS: LDS } = window;
+const { Button: LButton, Soul: LSoul, Eyebrow: LEyebrow } = LDS;
+
+function LogInLink({ size = 14 }) {
+ return Log in;
+}
+
+function LandingValue({ device }) {
+ const desktop = device === 'desktop';
+ return (
+ <>
+ One storefront, fully yours
+ Sell online,
honestly.
+
+ Claim your storefront 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.
+
+ >
+ );
+}
+
+// ── Direction A — Quiet centered ───────────────────────────────────────────
+function LandingA({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ return (
+
+ } right={} />
+
+
+
+ );
+}
+
+// ── Direction B — Durable frame (split) ────────────────────────────────────
+function LandingB({ device = 'desktop' }) {
+ const desktop = device === 'desktop';
+ if (!desktop) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function BrandRailLite() {
+ return (
+
+
+ Your storefront is yours.
+
+ );
+}
+
+function Doors() {
+ return (
+
+
+
+
+ );
+}
+function DoorRow({ primary, title, desc, cta }) {
+ return (
+
+
+ {cta}
+
+ );
+}
+
+Object.assign(window, { LandingA, LandingB });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-signin.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-signin.jsx
new file mode 100644
index 0000000..cd59053
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-signin.jsx
@@ -0,0 +1,161 @@
+// wf-signin.jsx — Screen 2: Sign in (PUC-2, PUC-3). Email → one-time code. A/B + states.
+const { Screen: SScreen, Wordmark: SWordmark, TopBar: STopBar, Field: SField, CodeField: SCodeField,
+ Banner: SBanner, AuthCard: SAuthCard, BrandRail: SBrandRail, WF_T: ST, WF_DS: SDS } = window;
+const { Button: SButton, Soul: SSoul } = SDS;
+
+function BackLink({ label = 'Back' }) {
+ return ← {label};
+}
+function Heading({ children, sub }) {
+ return (
+
+
{children}
+ {sub &&
{sub}
}
+
+ );
+}
+const honest = { fontFamily: ST.body, fontSize: 13, color: ST.mute, lineHeight: 1.5, margin: 0 };
+
+// ── Step renderers (shared by A card and B column) ─────────────────────────
+function EmailStep({ framing = 'signup', s = {} }) {
+ const title = framing === 'login' ? 'Log in' : 'Create your storefront';
+ const sub = framing === 'login' ? 'Enter your email and we\u2019ll send you a code to sign in.' : 'Enter your email to begin. No password to create.';
+ return (
+ <>
+ {title}
+ {s.deliveryFail && The email didn't go out. Try again in a moment — nothing was lost.}
+
+
+
+ {s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}
+
+
We'll email you a one-time code. That's all we need.
+
+ >
+ );
+}
+
+function CodeStep({ s = {} }) {
+ const email = s.email || 'mara@studiofern.com';
+ let err = null;
+ if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
+ if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
+ return (
+ <>
+ We sent a code to {email}. Wrong address?>}>
+ Check your email
+
+ {s.welcome &&
+ {s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
+ }
+ {s.codeExhausted && That code is no longer valid. Request a fresh one to keep going.}
+
+
+ {err && ◆{err}}
+
+
+ The code is good for 10 minutes. If you didn't request it, ignore this.
+ >
+ );
+}
+
+// ── Direction A — centered card ────────────────────────────────────────────
+function SignInA({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ return (
+
+ } right={} />
+
+
+ {step === 'email' ? : }
+
+
+
+ );
+}
+
+// ── Direction B — split (brand rail + form column) ─────────────────────────
+function SignInB({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ if (!desktop) {
+ return (
+
+
+
+ Your storefront is yours.
+
+
+ {step === 'email' ? : }
+
+
+ );
+ }
+ return (
+
+
+
+
+ {step === 'email' ? : }
+
+
+
+ );
+}
+
+// ── States stack ───────────────────────────────────────────────────────────
+const { StateBlock: SStateBlock } = window;
+function codeNote(text) {
+ return ◆{text};
+}
+function SignInStates() {
+ return (
+
+
+
+
+
+
+ Resend in 47s
+ One code per minute keeps inboxes (and abuse) in check.
+
+
+
+ The email didn't go out — try again. We'd never fake a success.
+
+
+
+ {codeNote("That code didn't match — 2 attempts left.")}
+
+
+
+ {codeNote('That code expired. Send a fresh one below.')}
+
+
+ That code is no longer valid. Request a fresh one to keep going.
+
+
+
+ A new account was created for this email.
+ Signed in to your existing account.
+
+
+
+ );
+}
+
+Object.assign(window, { SignInA, SignInB, EmailStep, CodeStep, SignInStates });
diff --git a/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-storefront.jsx b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-storefront.jsx
new file mode 100644
index 0000000..011f89a
--- /dev/null
+++ b/ui/designs/ecomm-login-and-create-storefront-designs/project/wf-storefront.jsx
@@ -0,0 +1,117 @@
+// wf-storefront.jsx — Screen 3: Create storefront (PUC-4/5/7). A/B + states.
+const { Screen: FScreen, Wordmark: FWordmark, TopBar: FTopBar, AccountChip: FAccountChip,
+ Field: FField, Banner: FBanner, AuthCard: FAuthCard, StateBlock: FStateBlock,
+ WF_T: FT, WF_DS: FDS } = window;
+const { Button: FButton, Soul: FSoul, Eyebrow: FEyebrow } = FDS;
+
+function CreateForm({ s = {} }) {
+ return (
+ <>
+
+
One storefront, fully yours
+
Create your storefront
+
+ This is the one thing to do right now. Nothing here costs anything or commits you to anything.
+
+
+ {s.alreadyOwns &&
+ ecomm is one storefront per account today. Go to your admin →
+ }
+
+ {s.blankPreview && (
+
+ ◇
+ Saves as “mara's storefront” — change it any time.
+
+ )}
+
+ {s.creating ? 'Creating…' : 'Create storefront'}
+
+ >
+ );
+}
+
+// ── Direction A — centered card ────────────────────────────────────────────
+function CreateA({ device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ return (
+
+ }
+ right={desktop ? : } />
+
+
+
+
+ );
+}
+
+// ── Direction B — split (the frame begins to form) ─────────────────────────
+function CreateB({ device = 'desktop', s = {} }) {
+ const desktop = device === 'desktop';
+ if (!desktop) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+}
+
+function SignOutMini() {
+ return ;
+}
+
+// ── States stack ───────────────────────────────────────────────────────────
+function CreateStates() {
+ return (
+
+
+
+
+
+ ◇
+ Saves as “mara's storefront”
+
+
+
+
+ Creating…
+
+
+
+ ecomm is one storefront per account today. Go to your admin →
+
+
+
+ );
+}
+
+Object.assign(window, { CreateA, CreateB, CreateStates });