.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/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css
new file mode 100644
index 0000000..84693f9
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_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/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css
new file mode 100644
index 0000000..57edf8e
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_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/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css
new file mode 100644
index 0000000..04a084a
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_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/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css
new file mode 100644
index 0000000..900a7fe
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_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/SD-0002-products-bulk-csv-import-export/app/app.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/app.jsx
new file mode 100644
index 0000000..4849d2d
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/app.jsx
@@ -0,0 +1,170 @@
+/* app.jsx — wires the state machine, Tweaks, and mounts. */
+
+/* Pull every cross-file dependency off window with `var` (safe under both the
+ shared-global and isolated babel-script models — no redeclaration error). */
+var { AdminShell, ProductsPage, UploadScreen, PreviewScreen, RunDetailScreen } = window;
+var { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakButton, useTweaks } = window;
+const { useState: _aState, useEffect: _aEffect, useRef: _aRef } = React;
+
+const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+ "catalog": "populated",
+ "density": "comfortable",
+ "scenario": "canonical"
+}/*EDITMODE-END*/;
+
+/* ---- synthesize image problems / errors for historical runs ---- */
+function synthProblems(rejected, failed) {
+ const out = [];
+ const handles = ['oxford-button-down', 'weekender-duffel', 'camp-mug-enamel', 'aspen-knit-sweater', 'lantern-flannel', 'river-stone-belt', 'cedar-beanie', 'driftwood-henley'];
+ for (let i = 0; i < rejected; i++) out.push({ handle: handles[i % handles.length], variant: i % 2 ? 'M / Slate' : '—', url: `https://cdn.northfield.example/${handles[i % handles.length]}-${i}.jpg`, outcome: 'rejected', reason: 'Below the resolution bar — needs at least 800 × 800.' });
+ for (let i = 0; i < failed; i++) out.push({ handle: handles[(i + rejected) % handles.length], variant: '—', url: `https://oldhost.example/${handles[(i + rejected) % handles.length]}.jpg`, outcome: 'failed', reason: 'Unreachable — host timed out.' });
+ return out;
+}
+function synthErrors(n) {
+ const base = [
+ { line: 88, column: 'Type', title: 'Gift Card', message: "Shopify 'Gift Card' products aren't catalog products here — skipped." },
+ { line: 1042, column: 'Type', title: 'Essentials Bundle', message: 'Looks like a bundle — kits arrive in a coming release.' },
+ { line: 1188, column: 'Variant Price', title: 'Trail Mug', message: "'9,00' is not a price — use a dot for decimals." },
+ { line: 1455, column: 'Handle', title: '(row 1455)', message: 'Empty Handle — every row needs one.' },
+ ];
+ return base.slice(0, n);
+}
+
+function buildRunView(run, data) {
+ if (run.id === 'run-0007') {
+ return { scenario: data.SCENARIOS.canonical, images: data.IMAGE_OUTCOMES, run };
+ }
+ const isShopify = run.dialect.includes('Shopify');
+ const scenario = {
+ file: run.file,
+ dialect: run.dialect + (isShopify ? ' — mapped to canonical' : ' format'),
+ dialectKind: isShopify ? 'shopify' : 'canonical',
+ summary: { add: run.added, update: run.updated, unchanged: 0, error: run.errors },
+ records: { error: synthErrors(run.errors), add: [], update: [], unchanged: [] },
+ extra: {},
+ unknownColumns: [],
+ };
+ const total = run.images.fetched + run.images.rejected + run.images.failed;
+ const images = { total, fetched: run.images.fetched, problems: synthProblems(run.images.rejected, run.images.failed) };
+ return { scenario, images, run };
+}
+
+function Toast({ toast }) {
+ if (!toast) return null;
+ const color = toast.kind === 'success' ? 'var(--st-add)' : 'var(--wv-violet)';
+ return (
+
+
+ {toast.msg}
+
+ );
+}
+
+function App() {
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+ const [screen, setScreen] = _aState('products');
+ const [view, setView] = _aState(null);
+ const [toast, setToast] = _aState(null);
+ const toastTimer = _aRef(null);
+ const data = window.IEData;
+ const catalogEmpty = t.catalog === 'empty';
+
+ _aEffect(() => { document.documentElement.dataset.density = t.density; }, [t.density]);
+
+ const showToast = (msg, kind) => {
+ setToast({ msg, kind, id: Date.now() });
+ clearTimeout(toastTimer.current);
+ toastTimer.current = setTimeout(() => setToast(null), 2600);
+ };
+
+ const getImages = (scenario) => {
+ const m = {
+ canonical: data.IMAGE_OUTCOMES,
+ shopify: { total: 519, fetched: 511, problems: synthProblems(6, 2) },
+ roundtrip: { total: 0, fetched: 0, problems: [] },
+ };
+ return m[scenario.key] || { total: 0, fetched: 0, problems: [] };
+ };
+
+ const goProducts = () => { setScreen('products'); setView(null); };
+ const goImport = () => setScreen('upload');
+ const onValidated = (key) => { setView({ scenario: data.SCENARIOS[key] }); setScreen('preview'); };
+ const onConfirm = (scenario) => {
+ setView({ scenario, images: getImages(scenario), run: null, live: true });
+ setScreen('rundetail');
+ if (catalogEmpty) setTweak('catalog', 'populated');
+ };
+ const onOpenRun = (runId) => {
+ const run = data.HISTORY.find((r) => r.id === runId);
+ if (!run) return;
+ setView({ ...buildRunView(run, data), live: false });
+ setScreen('rundetail');
+ };
+
+ // tweak jump helpers
+ const jumpPreview = () => { setView({ scenario: data.SCENARIOS[t.scenario] }); setScreen('preview'); };
+ const jumpRun = () => { const sc = data.SCENARIOS[t.scenario === 'roundtrip' ? 'canonical' : t.scenario]; onConfirm(sc); };
+
+ let body;
+ if (screen === 'products') {
+ body = ;
+ } else if (screen === 'upload') {
+ body = ;
+ } else if (screen === 'preview' && view) {
+ body = ;
+ } else if (screen === 'rundetail' && view) {
+ body = ;
+ } else {
+ body = ;
+ }
+
+ return (
+
+ goProducts()} storefront={data.STOREFRONT}>
+ {body}
+
+
+
+
+
+ { setTweak('catalog', v); setScreen('products'); setView(null); }} />
+ setTweak('density', v)} />
+
+
+ ({ value: v, label: l }))}
+ onChange={(v) => setTweak('scenario', v)} />
+
+
+
+
+
+
+
+
+ );
+}
+
+/* Mount once everything is on window (guards against script-eval ordering). */
+function ieMount() {
+ const ready = window.AdminShell && window.ProductsPage && window.UploadScreen &&
+ window.PreviewScreen && window.RunDetailScreen && window.TweaksPanel &&
+ window.TweakSection && window.TweakRadio && window.TweakSelect && window.TweakButton &&
+ window.useTweaks && window.IEData;
+ if (!ready) { setTimeout(ieMount, 20); return; }
+ // refresh local bindings in case they were read before the producer ran
+ AdminShell = window.AdminShell; ProductsPage = window.ProductsPage;
+ UploadScreen = window.UploadScreen; PreviewScreen = window.PreviewScreen;
+ RunDetailScreen = window.RunDetailScreen; TweaksPanel = window.TweaksPanel;
+ TweakSection = window.TweakSection; TweakRadio = window.TweakRadio;
+ TweakSelect = window.TweakSelect; TweakButton = window.TweakButton; useTweaks = window.useTweaks;
+ if (!window.__ieRoot) window.__ieRoot = ReactDOM.createRoot(document.getElementById('ie-root'));
+ window.__ieRoot.render( );
+}
+ieMount();
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/data.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/data.jsx
new file mode 100644
index 0000000..d586cc0
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/data.jsx
@@ -0,0 +1,435 @@
+/* data.jsx — sample catalog + import scenarios for the bulk CSV import/export prototype.
+ A boutique apparel store, "Wander & Wool". Numbers chosen so every count is backed
+ by a concrete row the merchant can drill into. Exported to window.IEData. */
+
+const STOREFRONT = {
+ name: 'Wander & Wool',
+ handle: 'wander-and-wool',
+ email: 'mara@wanderandwool.com',
+ productCount: 412,
+ imageProblemCount: 9,
+ latestRunId: 'run-0007',
+};
+
+/* ---- Import history (newest first). Statuses mirror §5.2 ---- */
+const HISTORY = [
+ {
+ id: 'run-0007', date: '2026-06-11T12:40:00', rel: 'just now',
+ file: 'spring-2026-pricing.csv', dialect: 'Canonical',
+ added: 8, updated: 14, errors: 3,
+ images: { fetched: 18, rejected: 2, failed: 1 },
+ status: 'complete_with_problems',
+ },
+ {
+ id: 'run-0006', date: '2026-05-02T09:12:00', rel: '5 weeks ago',
+ file: 'shopify-store-export.csv', dialect: 'Shopify product CSV',
+ added: 286, updated: 0, errors: 4,
+ images: { fetched: 511, rejected: 6, failed: 2 },
+ status: 'complete_with_problems',
+ },
+ {
+ id: 'run-0005', date: '2026-04-18T16:30:00', rel: '8 weeks ago',
+ file: 'restock-april.csv', dialect: 'Canonical',
+ added: 0, updated: 63, errors: 0,
+ images: { fetched: 0, rejected: 0, failed: 0 },
+ status: 'complete',
+ },
+ {
+ id: 'run-0004', date: '2026-03-30T11:05:00', rel: '10 weeks ago',
+ file: 'initial-catalog.csv', dialect: 'Canonical',
+ added: 118, updated: 0, errors: 0,
+ images: { fetched: 402, rejected: 0, failed: 0 },
+ status: 'complete',
+ },
+];
+
+/* =====================================================================
+ SCENARIO A — canonical seasonal pricing file (the default happy path).
+ 8 to add · 14 to update · 26 unchanged · 3 errors. (48 product rows.)
+ ===================================================================== */
+
+const A_ADDS = [
+ {
+ handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'add', variants: 8,
+ detail: {
+ product: [
+ ['Title', 'Alpine Fleece Pullover'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'outerwear, fleece, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Size (S·M·L·XL) × Color (Heather Grey · Forest)'],
+ ],
+ variantsPreview: [
+ ['S / Heather Grey', 'SKU AFP-S-HG', '$88.00'],
+ ['M / Heather Grey', 'SKU AFP-M-HG', '$88.00'],
+ ['L / Forest', 'SKU AFP-L-FO', '$88.00'],
+ ],
+ images: 2,
+ },
+ },
+ {
+ handle: 'coastal-linen-shirt', title: 'Coastal Linen Shirt', kind: 'add', variants: 6,
+ detail: {
+ product: [
+ ['Title', 'Coastal Linen Shirt'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'shirts, linen, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Size (S·M·L) × Color (Sand · Sky)'],
+ ],
+ variantsPreview: [
+ ['S / Sand', 'SKU CLS-S-SA', '$72.00'],
+ ['M / Sky', 'SKU CLS-M-SK', '$72.00'],
+ ],
+ images: 3,
+ },
+ },
+ {
+ handle: 'trailhead-cap', title: 'Trailhead Cap', kind: 'add', variants: 3,
+ detail: {
+ product: [
+ ['Title', 'Trailhead Cap'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'accessories, hats, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Color (Black · Olive · Rust)'],
+ ],
+ variantsPreview: [
+ ['Black', 'SKU THC-BK', '$34.00'],
+ ['Olive', 'SKU THC-OL', '$34.00'],
+ ['Rust', 'SKU THC-RU', '$34.00'],
+ ],
+ images: 1,
+ },
+ },
+ {
+ handle: 'merino-base-tee', title: 'Merino Base Tee', kind: 'add', variants: 8,
+ detail: {
+ product: [
+ ['Title', 'Merino Base Tee'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'base-layer, merino, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Size (XS·S·M·L) × Color (Charcoal · Bone)'],
+ ],
+ variantsPreview: [
+ ['XS / Charcoal', 'SKU MBT-XS-CH', '$58.00'],
+ ['M / Bone', 'SKU MBT-M-BO', '$58.00'],
+ ],
+ images: 2,
+ },
+ },
+ {
+ handle: 'dune-chino-short', title: 'Dune Chino Short', kind: 'add', variants: 5,
+ detail: {
+ product: [
+ ['Title', 'Dune Chino Short'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'shorts, spring-26'],
+ ['Status', 'draft'],
+ ['Options', 'Size (28·30·32·34·36)'],
+ ],
+ variantsPreview: [
+ ['28', 'SKU DCS-28', '$64.00'],
+ ['34', 'SKU DCS-34', '$64.00'],
+ ],
+ images: 1,
+ },
+ },
+ {
+ handle: 'harbor-tote', title: 'Harbor Canvas Tote', kind: 'add', variants: 1,
+ detail: {
+ product: [
+ ['Title', 'Harbor Canvas Tote'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'bags, accessories, spring-26'],
+ ['Status', 'active'],
+ ['Options', '— (single variant)'],
+ ],
+ variantsPreview: [
+ ['Default', 'SKU HCT-01', '$48.00'],
+ ],
+ images: 2,
+ },
+ },
+ {
+ handle: 'cirrus-rain-shell', title: 'Cirrus Rain Shell', kind: 'add', variants: 6,
+ detail: {
+ product: [
+ ['Title', 'Cirrus Rain Shell'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'outerwear, rain, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Size (S·M·L) × Color (Slate · Marigold)'],
+ ],
+ variantsPreview: [
+ ['S / Slate', 'SKU CRS-S-SL', '$186.00'],
+ ['L / Marigold', 'SKU CRS-L-MA', '$186.00'],
+ ],
+ images: 3,
+ },
+ },
+ {
+ handle: 'wildflower-scarf', title: 'Wildflower Wool Scarf', kind: 'add', variants: 2,
+ detail: {
+ product: [
+ ['Title', 'Wildflower Wool Scarf'],
+ ['Vendor', 'Wander & Wool'],
+ ['Type', 'standalone'],
+ ['Tags', 'accessories, wool, spring-26'],
+ ['Status', 'active'],
+ ['Options', 'Color (Meadow · Dusk)'],
+ ],
+ variantsPreview: [
+ ['Meadow', 'SKU WWS-ME', '$42.00'],
+ ['Dusk', 'SKU WWS-DU', '$42.00'],
+ ],
+ images: 1,
+ },
+ },
+];
+
+const A_UPDATES = [
+ {
+ handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'update', variants: 6,
+ detail: {
+ changes: [
+ { field: 'Variant Price', scope: 'all 6 variants', before: '$118.00', after: '$98.00' },
+ { field: 'Tags', scope: 'product', before: 'dresses, summer', after: 'dresses, summer, sale' },
+ { field: 'Status', scope: 'product', before: 'active', after: 'active', noop: true },
+ ],
+ },
+ },
+ {
+ handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'update', variants: 9,
+ detail: {
+ changes: [
+ { field: 'Variant Price', scope: 'all 9 variants', before: '$240.00', after: '$264.00' },
+ { field: 'Variant Cost', scope: 'all 9 variants', before: '$96.00', after: '$112.00' },
+ { field: 'Variant Inventory Qty', scope: 'S / Slate', before: '4', after: '22' },
+ ],
+ },
+ },
+ {
+ handle: 'field-canvas-pant', title: 'Field Canvas Pant', kind: 'update', variants: 10,
+ detail: {
+ changes: [
+ { field: 'Variant Price', scope: 'all 10 variants', before: '$92.00', after: '$84.00' },
+ { field: 'Description', scope: 'product', before: 'Durable canvas work pant.', after: 'Durable organic-canvas work pant, garment-dyed.' },
+ ],
+ },
+ },
+ {
+ handle: 'aspen-knit-sweater', title: 'Aspen Knit Sweater', kind: 'update', variants: 8,
+ detail: {
+ changes: [
+ { field: 'Variant Price', scope: 'all 8 variants', before: '$134.00', after: '$118.00' },
+ { field: 'Status', scope: 'product', before: 'draft', after: 'active' },
+ { field: 'Tags', scope: 'product', before: 'knitwear, fall', after: 'knitwear, fall, spring-26' },
+ ],
+ },
+ },
+ {
+ handle: 'river-stone-belt', title: 'River Stone Belt', kind: 'update', variants: 4,
+ detail: {
+ changes: [
+ { field: 'Variant Cost', scope: 'all 4 variants', before: '$11.00', after: '$14.50' },
+ { field: 'Variant Barcode', scope: 'M / Tan', before: '— (empty)', after: '7 290 1148 22 901' },
+ ],
+ },
+ },
+ {
+ handle: 'lantern-flannel', title: 'Lantern Flannel', kind: 'update', variants: 12,
+ detail: {
+ changes: [
+ { field: 'Variant Price', scope: 'all 12 variants', before: '$78.00', after: '$68.00' },
+ { field: 'Tags', scope: 'product', before: 'shirts, flannel', after: 'shirts, flannel, sale' },
+ ],
+ },
+ },
+];
+
+const A_UPDATES_EXTRA = 8; // 14 total updates; 6 shown above, 8 more collapsed into the count
+
+const A_UNCHANGED = [
+ { handle: 'sandpiper-sock-3pk', title: 'Sandpiper Sock 3-Pack', kind: 'unchanged', variants: 3 },
+ { handle: 'cedar-beanie', title: 'Cedar Ribbed Beanie', kind: 'unchanged', variants: 4 },
+ { handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
+];
+const A_UNCHANGED_EXTRA = 23; // 26 total
+
+const A_ERRORS = [
+ {
+ handle: 'gardenia-sundress', title: 'Gardenia Sundress', kind: 'error', variants: 5,
+ line: 214, column: 'Variant Price',
+ message: "'12,50' is not a price — use a dot for decimals (12.50), no thousands separators.",
+ },
+ {
+ handle: 'pathfinder-vest', title: 'Pathfinder Vest', kind: 'error', variants: 6,
+ line: 327, column: 'Option1 Value',
+ message: "Variant has value 'Medium' but the product never declares 'Option1 Name'. Add the option name, or remove the value.",
+ },
+ {
+ handle: 'trail-kit-starter', title: 'Trail Starter Kit', kind: 'error', variants: 1,
+ line: 402, column: 'Type',
+ message: "'kit_assembled' is reserved — bundled kits arrive in a coming release. Use 'standalone' for now.",
+ },
+];
+
+const SCENARIO_CANONICAL = {
+ key: 'canonical',
+ file: 'spring-2026-pricing.csv',
+ dialect: 'Canonical format',
+ dialectKind: 'canonical',
+ rows: 48,
+ summary: { add: 8, update: 14, unchanged: 26, error: 3 },
+ unknownColumns: ['Season Note', 'Internal Ref'],
+ records: { add: A_ADDS, update: A_UPDATES, unchanged: A_UNCHANGED, error: A_ERRORS },
+ extra: { update: A_UPDATES_EXTRA, unchanged: A_UNCHANGED_EXTRA },
+ noop: false,
+};
+
+/* =====================================================================
+ SCENARIO B — an unmodified Shopify product export (the migration path).
+ Recognized + mapped. Big add, many not-imported columns warned.
+ ===================================================================== */
+
+const B_ADDS = [
+ {
+ handle: 'oxford-button-down', title: 'Oxford Button-Down', kind: 'add', variants: 8, mapped: true,
+ detail: {
+ product: [
+ ['Title', 'Oxford Button-Down'],
+ ['Vendor', 'Northfield Supply'],
+ ['Type', 'standalone', 'mapped from Shopify "Shirts" → warned, see below'],
+ ['Tags', 'shirts, oxford'],
+ ['Status', 'active'],
+ ['Options', 'Size (S·M·L·XL) × Fit (Slim · Classic)'],
+ ],
+ variantsPreview: [
+ ['S / Slim', 'SKU OBD-S-SL', '$74.00'],
+ ['L / Classic', 'SKU OBD-L-CL', '$74.00'],
+ ],
+ images: 4,
+ },
+ },
+ {
+ handle: 'weekender-duffel', title: 'Weekender Duffel', kind: 'add', variants: 2, mapped: true,
+ detail: {
+ product: [
+ ['Title', 'Weekender Duffel'],
+ ['Vendor', 'Northfield Supply'],
+ ['Type', 'standalone'],
+ ['Tags', 'bags, travel'],
+ ['Status', 'active'],
+ ['Options', 'Color (Tan · Charcoal)'],
+ ],
+ variantsPreview: [
+ ['Tan', 'SKU WKD-TN', '$148.00'],
+ ['Charcoal', 'SKU WKD-CH', '$148.00'],
+ ],
+ images: 5,
+ },
+ },
+ {
+ handle: 'camp-mug-enamel', title: 'Enamel Camp Mug', kind: 'add', variants: 3, mapped: true,
+ detail: {
+ product: [
+ ['Title', 'Enamel Camp Mug'],
+ ['Vendor', 'Northfield Supply'],
+ ['Type', 'standalone'],
+ ['Tags', 'home, camp'],
+ ['Status', 'active'],
+ ['Options', 'Color (Red · Navy · Cream)'],
+ ],
+ variantsPreview: [
+ ['Red', 'SKU ECM-RD', '$18.00'],
+ ['Navy', 'SKU ECM-NV', '$18.00'],
+ ],
+ images: 2,
+ },
+ },
+];
+const B_ADDS_EXTRA = 283; // 286 total adds
+
+const B_ERRORS = [
+ {
+ handle: 'gift-card', title: 'Gift Card', kind: 'error', variants: 4,
+ line: 88, column: 'Type',
+ message: "Shopify 'Gift Card' products aren't catalog products here — this row is skipped, not imported.",
+ },
+ {
+ handle: 'bundle-essentials', title: 'Essentials Bundle', kind: 'error', variants: 1,
+ line: 1042, column: 'Type',
+ message: "Looks like a Shopify bundle — bundled kits arrive in a coming release. Skipped for now.",
+ },
+];
+const B_ERRORS_EXTRA = 2; // 4 total
+
+const SCENARIO_SHOPIFY = {
+ key: 'shopify',
+ file: 'shopify-store-export.csv',
+ dialect: 'Shopify product CSV — mapped to canonical',
+ dialectKind: 'shopify',
+ rows: 1894,
+ summary: { add: 286, update: 0, unchanged: 0, error: 4 },
+ unknownColumns: [
+ 'Compare At Price', 'SEO Title', 'SEO Description', 'Google Shopping / Condition',
+ 'Google Shopping / Gender', 'Variant Fulfillment Service', 'Variant Tax Code', 'Gift Card',
+ ],
+ records: { add: B_ADDS, update: [], unchanged: [], error: B_ERRORS },
+ extra: { add: B_ADDS_EXTRA, error: B_ERRORS_EXTRA },
+ noop: false,
+};
+
+/* =====================================================================
+ SCENARIO C — re-importing our own export (the round-trip no-op).
+ Everything unchanged → the import action is disabled (PUC-10).
+ ===================================================================== */
+
+const C_UNCHANGED = [
+ { handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'unchanged', variants: 8 },
+ { handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'unchanged', variants: 6 },
+ { handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'unchanged', variants: 9 },
+ { handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
+];
+const C_UNCHANGED_EXTRA = 408; // 412 total
+
+const SCENARIO_ROUNDTRIP = {
+ key: 'roundtrip',
+ file: 'wander-wool-catalog-export.csv',
+ dialect: 'Canonical format',
+ dialectKind: 'canonical',
+ rows: 1620,
+ summary: { add: 0, update: 0, unchanged: 412, error: 0 },
+ unknownColumns: [],
+ records: { add: [], update: [], unchanged: C_UNCHANGED, error: [] },
+ extra: { unchanged: C_UNCHANGED_EXTRA },
+ noop: true,
+};
+
+/* ---- Image outcomes for the canonical run's post-commit phase (PUC-7) ---- */
+const IMAGE_OUTCOMES = {
+ total: 21,
+ fetched: 18,
+ problems: [
+ { handle: 'cirrus-rain-shell', variant: 'L / Marigold', url: 'https://cdn.northfield.example/cirrus-marigold-detail.jpg', outcome: 'rejected', reason: 'Below the resolution bar (320 × 480) — needs at least 800 × 800.' },
+ { handle: 'coastal-linen-shirt', variant: 'M / Sky', url: 'https://images.example.org/linen/sky.gif', outcome: 'rejected', reason: 'Not an image we can host (animated GIF).' },
+ { handle: 'harbor-tote', variant: '—', url: 'https://oldhost.example/harbor-tote-2.jpg', outcome: 'failed', reason: 'Unreachable — host returned 404.' },
+ ],
+};
+
+const SCENARIOS = {
+ canonical: SCENARIO_CANONICAL,
+ shopify: SCENARIO_SHOPIFY,
+ roundtrip: SCENARIO_ROUNDTRIP,
+};
+
+window.IEData = { STOREFRONT, HISTORY, SCENARIOS, IMAGE_OUTCOMES };
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/importflow.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/importflow.jsx
new file mode 100644
index 0000000..3f58b83
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/importflow.jsx
@@ -0,0 +1,556 @@
+/* importflow.jsx — the three import screens.
+ UploadScreen (§5.3) · PreviewScreen (§5.4, the consent gate) · RunDetailScreen (§5.5).
+ Exported to window. */
+
+var { Button, Eyebrow } = window.WiggleverseDesignSystem_94cd80 || {};
+
+const { useState: _ifState, useEffect: _ifEffect, useRef: _ifRef } = React;
+
+/* ============================ small shared bits ============================ */
+
+function ScreenHeader({ eyebrow, title, onBack, backLabel = 'Products', right }) {
+ return (
+
+
+ Back to {backLabel}
+
+
+
+ {eyebrow && {eyebrow} }
+
{title}
+
+ {right}
+
+
+ );
+}
+
+/* ================================ UPLOAD ================================== */
+
+const SAMPLE_FILES = [
+ { key: 'canonical', name: 'spring-2026-pricing.csv', size: '48 rows · 11 KB', desc: 'Canonical format — a seasonal repricing with new arrivals, updates, and a few bad rows.', icon: 'file' },
+ { key: 'shopify', name: 'shopify-store-export.csv', size: '1,894 rows · 2.1 MB', desc: 'An unmodified Shopify product export — auto-detected and mapped to canonical.', icon: 'file' },
+ { key: 'roundtrip', name: 'wander-wool-catalog-export.csv', size: '1,620 rows · 1.7 MB', desc: 'Your own export, re-imported — the lossless round-trip (nothing to change).', icon: 'refresh' },
+];
+const REJECT_FILE = { key: 'reject', name: 'orders-q1.csv', size: '900 rows · 240 KB', desc: 'A file that is not a product catalog — to see an honest whole-file rejection.', icon: 'alert' };
+
+function UploadScreen({ onBack, onValidated, toast }) {
+ const [phase, setPhase] = _ifState('idle'); // idle | validating | rejected
+ const [progress, setProgress] = _ifState({ done: 0, total: 0, file: '' });
+ const [dragOver, setDragOver] = _ifState(false);
+ const timer = _ifRef(null);
+ const fileInput = _ifRef(null);
+
+ _ifEffect(() => () => clearInterval(timer.current), []);
+
+ const start = (file) => {
+ if (file.key === 'reject') {
+ setPhase('rejected');
+ return;
+ }
+ const total = file.rows;
+ setProgress({ done: 0, total, file: file.name });
+ setPhase('validating');
+ clearInterval(timer.current);
+ const step = Math.max(1, Math.round(total / 22));
+ timer.current = setInterval(() => {
+ setProgress((p) => {
+ const done = Math.min(total, p.done + step);
+ if (done >= total) {
+ clearInterval(timer.current);
+ setTimeout(() => onValidated(file.key), 320);
+ }
+ return { ...p, done };
+ });
+ }, 70);
+ };
+
+ const ROWS = { canonical: 48, shopify: 1894, roundtrip: 1620 };
+ const pick = (key, name) => start({ key, name, rows: ROWS[key] });
+
+ const onDrop = (e) => {
+ e.preventDefault(); setDragOver(false);
+ pick('canonical', 'spring-2026-pricing.csv');
+ toast('Reading dropped file…');
+ };
+
+ return (
+
+
+
+ {phase === 'validating' ? (
+
+
+
+ Validating…
+
+
+ {progress.file} — {progress.done.toLocaleString()} of {progress.total.toLocaleString()} rows
+
+
+
+ { clearInterval(timer.current); setPhase('idle'); }}>Cancel
+ Canceling is free — nothing has been written.
+
+
+ ) : (
+ <>
+ {/* drop zone */}
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={onDrop}
+ onClick={() => fileInput.current && fileInput.current.click()}
+ style={{
+ border: `2px dashed ${dragOver ? 'var(--wv-violet)' : 'var(--paper-line-strong)'}`,
+ background: dragOver ? 'var(--violet-tint)' : 'var(--surface-card-light)',
+ borderRadius: '16px', padding: '2.6rem 2rem', textAlign: 'center', cursor: 'pointer',
+ transition: 'border-color var(--dur-fast), background var(--dur-fast)',
+ }}>
+
{ pick('canonical', 'spring-2026-pricing.csv'); }} />
+
+
+
+
Drop a CSV here, or click to choose
+
CSV, up to 5,000 rows · 10 MB. Selecting a file starts validation right away.
+
+ Works with the canonical format or a Shopify product export — we detect which.
+
+
+
+
+ {/* file-level rejection */}
+ {phase === 'rejected' && (
+
+
+
+
This file can't be imported as a catalog.
+
It's missing the required column Title and matches no known dialect. Nothing was imported and no import run was recorded. Fix the file and try again.
+
+
+ )}
+
+ {/* sample files */}
+
+
Or try a sample file
+
+ {[...SAMPLE_FILES, REJECT_FILE].map((f) => (
+ start({ ...f, rows: ROWS[f.key] })} className="ie-samplefile" style={{
+ display: 'flex', alignItems: 'center', gap: '1rem', width: '100%', textAlign: 'left',
+ background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)',
+ borderRadius: '12px', padding: '.9rem 1.1rem', cursor: 'pointer', font: 'inherit',
+ transition: 'border-color var(--dur-fast), transform var(--dur-fast)',
+ }}>
+
+
+
+
+
+ {f.name}
+ {f.size}
+
+ {f.desc}
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
+
+/* ================================ PREVIEW ================================= */
+
+function FieldRow({ label, value, note }) {
+ return (
+
+
{label}
+
+ {value}
+ {note && ⚠ {note} }
+
+
+ );
+}
+
+function RecordDetail({ rec }) {
+ const d = rec.detail || {};
+ if (rec.kind === 'add') {
+ return (
+
+
New product — fields to be set
+
+ {d.product.map((row, i) => )}
+
+
+
+
Variants ({rec.variants}) · sample
+
+ {d.variantsPreview.map((v, i) => (
+
+ {v[0]}
+ {v[1]}
+ {v[2]}
+
+ ))}
+ {rec.variants > d.variantsPreview.length &&
+ {rec.variants - d.variantsPreview.length} more variants
}
+
+
+
+ {d.images} image{d.images === 1 ? '' : 's'} to fetch
+
+
+
+ );
+ }
+ if (rec.kind === 'update') {
+ return (
+
+
Changes — before → after
+
+ {d.changes.map((c, i) => (
+
+
+
{c.field}
+
{c.scope}
+
+
+
+ ))}
+
+
+ );
+ }
+ if (rec.kind === 'error') {
+ return (
+
+
+
+
+
Row {rec.line} · {rec.column}
+
{rec.message}
+
This product will not be imported. Correct the row and re-import — the valid products still go through.
+
+
+
+ );
+ }
+ return This product matches your catalog exactly — nothing to change.
;
+}
+
+function PreviewRow({ rec, expanded, onToggle }) {
+ return (
+
+
+
+
+
{rec.title}
+
{rec.handle}
+
+ {rec.variants} variant{rec.variants === 1 ? '' : 's'}
+
+
+ {expanded &&
}
+
+ );
+}
+
+function ErrorTable({ errors }) {
+ return (
+
+
+
+
+ Line
+ Column
+ What's wrong
+
+
+
+ {errors.map((e, i) => (
+
+ {e.line}
+ {e.column}
+
+ {e.title} — {e.message}
+
+
+ ))}
+
+
+
+ );
+}
+
+function PreviewScreen({ scenario, onCancel, onConfirm }) {
+ const order = ['add', 'update', 'unchanged', 'error'];
+ const firstActive = order.find((k) => scenario.summary[k] > 0) || 'unchanged';
+ const [active, setActive] = _ifState(firstActive);
+ const [expanded, setExpanded] = _ifState(() => new Set());
+ const [warnOpen, setWarnOpen] = _ifState(true);
+
+ const toggle = (h) => setExpanded((s) => { const n = new Set(s); n.has(h) ? n.delete(h) : n.add(h); return n; });
+
+ const applyCount = scenario.summary.add + scenario.summary.update;
+ const records = scenario.records[active] || [];
+ const extra = (scenario.extra && scenario.extra[active]) || 0;
+
+ return (
+
+
+
+
+ Recognized: {scenario.dialect}
+
+
+ }
+ />
+
+ {/* nothing-written reassurance */}
+
+ Nothing has been written to your catalog yet. This is a preview.
+
+
+ {/* warnings band */}
+ {scenario.unknownColumns.length > 0 && (
+
+
setWarnOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: '.6rem', width: '100%', padding: '.8rem 1.1rem', background: 'transparent', border: 'none', cursor: 'pointer', font: 'inherit', textAlign: 'left' }}>
+
+
+ {scenario.unknownColumns.length} columns won't be imported.
+ They're recognized but have no home in your catalog — warned, never silently dropped.
+
+
+
+ {warnOpen && (
+
+ {scenario.unknownColumns.map((c) => (
+ {c}
+ ))}
+
+ )}
+
+ )}
+
+ {/* summary tiles */}
+
+ setActive('add')} disabled={scenario.summary.add === 0} />
+ setActive('update')} disabled={scenario.summary.update === 0} />
+ setActive('unchanged')} disabled={scenario.summary.unchanged === 0} />
+ setActive('error')} disabled={scenario.summary.error === 0} />
+
+
+ {/* detail list / error table */}
+
+
+
+ {active === 'error' ? 'These rows are reported, not applied.' : active === 'unchanged' ? 'Re-supplied rows that already match your catalog.' : 'Expand any product to see exactly what will happen.'}
+
+
+
+ {active === 'error'
+ ?
+ : (
+
+ {records.length === 0
+ ? Nothing in this group.
+ : records.map((rec) => toggle(rec.handle)} />)}
+ {extra > 0 && (
+
+ + {extra.toLocaleString()} more {active} {extra === 1 ? 'product' : 'products'} in this file
+
+ )}
+
+ )}
+
+ {/* sticky footer */}
+
+
+ {scenario.noop
+ ? Nothing to change — your catalog already matches this file.
+ : {applyCount.toLocaleString()} products will be added or updated{scenario.summary.error ? , {scenario.summary.error} rows left in error : ''}. }
+
+
+ Cancel
+ onConfirm(scenario)}>
+ Import {applyCount.toLocaleString()} products
+
+
+
+
+ );
+}
+
+/* ============================== RUN DETAIL =============================== */
+
+function RunDetailScreen({ run, scenario, images, onBack, live }) {
+ // live: true → animate applying → fetching → complete. false → show final state.
+ const [phase, setPhase] = _ifState(live ? 'applying' : 'done');
+ const [fetched, setFetched] = _ifState(live ? 0 : images.fetched + images.problems.length);
+ const timer = _ifRef(null);
+ const total = images.total;
+
+ _ifEffect(() => {
+ if (!live) return;
+ const t1 = setTimeout(() => setPhase('fetching'), 1100);
+ return () => clearTimeout(t1);
+ }, [live]);
+
+ _ifEffect(() => {
+ if (phase !== 'fetching') return;
+ if (fetched >= total) { setPhase('done'); return; }
+ const id = setTimeout(() => setFetched((f) => Math.min(total, f + 1)), 120);
+ return () => clearTimeout(id);
+ }, [phase, fetched, total]);
+
+ const counts = run || { added: scenario.summary.add, updated: scenario.summary.update, errors: scenario.summary.error };
+ const hasProblems = images.problems.length > 0 || counts.errors > 0;
+ const statusLabel = phase === 'applying' ? 'Importing…' : phase === 'fetching' ? 'Fetching images…' : (hasProblems ? 'Complete · with problems' : 'Complete');
+ const statusColor = phase === 'done' ? (hasProblems ? 'var(--st-update)' : 'var(--st-add)') : 'var(--wv-violet)';
+
+ return (
+
+
+ {phase === 'done' ? : }
+ {statusLabel}
+
+ }
+ />
+
+ Imported {run ? run.rel : 'just now'} by {window.IEData.STOREFRONT.email} · {scenario.dialect}
+
+
+ {/* result summary */}
+
+
+
+
+
+
+ {/* images section */}
+
+ Images
+
+ {phase !== 'done' && phase === 'applying' ? (
+
+ Applying catalog rows — the image fetch begins once products commit.
+
+ ) : phase === 'fetching' ? (
+
+
+ Fetching images
+ {fetched.toLocaleString()} of {total.toLocaleString()}
+
+
+
Running in the background — your catalog is already live. You can leave this page and come back.
+
+ ) : (
+
+
+
+ p.outcome === 'rejected').length} color="var(--st-error)" glyph="x" />
+ p.outcome === 'failed').length} color="var(--st-error)" glyph="alert" />
+
+ {images.problems.length > 0 ? (
+
+
Only problems are listed — these products show a placeholder until you re-import a corrected URL.
+
+ {images.problems.map((p, i) => (
+
+
+
+
{p.handle} · {p.variant}
+
{p.url}
+
{p.outcome === 'rejected' ? 'Rejected' : 'Failed'} — {p.reason}
+
+
+ ))}
+
+
+ ) :
All images fetched cleanly.
}
+
+ )}
+
+
+
+ {/* error table */}
+ {scenario.records.error.length > 0 && (
+
+ Rows in error · unapplied
+
+
+ )}
+
+ );
+}
+
+function ResultTile({ n, label, kind }) {
+ const m = KIND_META[kind];
+ return (
+
+
+
+ {label}
+
+
{n.toLocaleString()}
+
+ );
+}
+
+function SummaryStat({ label, n, color, glyph }) {
+ return (
+
+
+
+
+
+
+ {n.toLocaleString()}
+ {label}
+
+
+ );
+}
+
+/* shared inline styles */
+const ghostBtn = {
+ fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.92rem', color: 'var(--text-on-light)',
+ background: 'transparent', border: '1.5px solid var(--paper-line-strong)', borderRadius: 999,
+ padding: '.6rem 1.25rem', cursor: 'pointer',
+};
+const linkStyle = { display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.88rem', textDecoration: 'none' };
+const codeStyle = { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '.84em', background: 'var(--violet-tint)', color: 'var(--wv-ink)', padding: '.08rem .35rem', borderRadius: 5 };
+
+Object.assign(window, { UploadScreen, PreviewScreen, RunDetailScreen });
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx
new file mode 100644
index 0000000..45a6ecd
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx
@@ -0,0 +1,257 @@
+/* products.jsx — the Products page (§5.2): catalog home, empty + populated,
+ export menu, notices band, import history. Exported to window as ProductsPage. */
+
+var { Button, Eyebrow, Notice } = window.WiggleverseDesignSystem_94cd80 || {};
+
+const { useState: _pUseState, useRef: _pUseRef, useEffect: _pUseEffect } = React;
+
+/* ---- real file download helpers (makes PUC-11 / PUC-9 tangible) ---- */
+function downloadText(filename, text) {
+ const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = filename; document.body.appendChild(a); a.click();
+ document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+const SAMPLE_CSV = [
+ 'Handle,Title,Description,Vendor,Type,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Cost,Variant Inventory Qty,Image Src,Image Position,Image Alt Text',
+ 'alpine-fleece-pullover,Alpine Fleece Pullover,"Brushed recycled fleece, cut for layering.
",Wander & Wool,standalone,"outerwear, fleece",active,TRUE,Size,S,Color,Heather Grey,AFP-S-HG,88.00,34.00,12,https://cdn.example.com/alpine-front.jpg,1,Alpine fleece — front',
+ 'alpine-fleece-pullover,,,,,,,,Size,M,Color,Heather Grey,AFP-M-HG,88.00,34.00,9,,,',
+ 'alpine-fleece-pullover,,,,,,,,Size,L,Color,Forest,AFP-L-FO,88.00,34.00,7,https://cdn.example.com/alpine-forest.jpg,2,Alpine fleece — forest',
+ 'harbor-tote,Harbor Canvas Tote,"Heavyweight organic-canvas tote.
",Wander & Wool,standalone,"bags, accessories",active,TRUE,,,,,HCT-01,48.00,18.00,40,https://cdn.example.com/harbor-tote.jpg,1,Harbor tote',
+].join('\n');
+
+function StatusFilterMenu({ onExport, onClose }) {
+ const [status, setStatus] = _pUseState('all');
+ const opts = [['all', 'All products'], ['active', 'Active'], ['draft', 'Draft'], ['archived', 'Archived']];
+ const ref = _pUseRef(null);
+ _pUseEffect(() => {
+ const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, []);
+ return (
+
+
Export — filter by status
+
+ {opts.map(([v, label]) => (
+ setStatus(v)} style={{
+ display: 'flex', alignItems: 'center', gap: '.6rem', padding: '.5rem .5rem', borderRadius: 8,
+ border: 'none', background: status === v ? 'var(--violet-tint)' : 'transparent', cursor: 'pointer',
+ font: 'inherit', fontSize: '.92rem', color: 'var(--text-on-light)', textAlign: 'left',
+ }}>
+
+ {status === v && }
+
+ {label}
+
+ ))}
+
+
+ onExport(status)} style={{
+ width: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '.45em',
+ fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.92rem', color: 'var(--cta-text)',
+ background: 'var(--cta)', border: 'none', borderRadius: 999, padding: '.55rem 1rem', cursor: 'pointer', whiteSpace: 'nowrap',
+ }}>
+ Download CSV
+
+
+
+ );
+}
+
+function HistoryStatus({ status }) {
+ const map = {
+ applying: ['Importing…', 'var(--wv-violet)', true],
+ fetching_images: ['Fetching images…', 'var(--wv-violet)', true],
+ complete: ['Complete', 'var(--st-add)', false],
+ complete_with_problems: ['Complete · problems', 'var(--st-update)', false],
+ };
+ const [label, color, spin] = map[status] || map.complete;
+ return (
+
+ {spin ? : }
+ {label}
+
+ );
+}
+
+function ImportHistory({ runs, onOpenRun }) {
+ return (
+
+
+
Import history
+ {runs.length} runs · newest first
+
+
+
+
+
+ When
+ File
+ Dialect
+ Added / Updated / Errors
+ Images
+ Status
+
+
+
+ {runs.map((r, i) => (
+ onOpenRun(r.id)} className="ie-hrow" style={{
+ borderTop: '1px solid var(--paper-line)', cursor: 'pointer',
+ }}>
+ {r.rel}
+
+
+ {r.file}
+
+
+ {r.dialect}
+
+ +{r.added}
+ ·
+ ±{r.updated}
+ ·
+ !{r.errors}
+
+
+ {(r.images.fetched + r.images.rejected + r.images.failed) > 0
+ ? {r.images.fetched} ✓ · {r.images.rejected + r.images.failed} ✗
+ : — }
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function ProductsPage({ data, catalogEmpty, onImport, onOpenRun, toast }) {
+ const { STOREFRONT, HISTORY } = data;
+ const [menuOpen, setMenuOpen] = _pUseState(false);
+ const count = catalogEmpty ? 0 : STOREFRONT.productCount;
+ const runs = catalogEmpty ? [] : HISTORY;
+
+ const doExport = (status) => {
+ setMenuOpen(false);
+ toast(`Exporting ${status === 'all' ? 'all' : status} products…`);
+ setTimeout(() => {
+ const header = SAMPLE_CSV.split('\n')[0];
+ downloadText(`wander-wool-catalog-${status}.csv`, header + '\n' + SAMPLE_CSV.split('\n').slice(1).join('\n'));
+ toast(`Exported ${count.toLocaleString()} products → wander-wool-catalog-${status}.csv`, 'success');
+ }, 900);
+ };
+
+ return (
+
+ {/* header row */}
+
+
+ Catalog
+
+ Products · {count.toLocaleString()}
+
+
+
+
+ !catalogEmpty && setMenuOpen((o) => !o)} disabled={catalogEmpty} title={catalogEmpty ? 'Nothing to export yet' : ''} style={{
+ display: 'inline-flex', alignItems: 'center', gap: '.45em', fontFamily: 'var(--font-display)',
+ fontWeight: 500, fontSize: '.92rem', color: 'var(--text-on-light)', background: 'var(--surface-card-light)',
+ border: '1.5px solid var(--paper-line-strong)', borderRadius: 999, padding: '.55rem 1.1rem',
+ cursor: catalogEmpty ? 'not-allowed' : 'pointer', opacity: catalogEmpty ? .5 : 1,
+ }}>
+ Export
+
+ {menuOpen && setMenuOpen(false)} />}
+
+
+ Import products
+
+
+
+
+ {catalogEmpty
+ ?
An honest empty shell — nothing to manage yet.
+ :
One canonical CSV in, your whole catalog out — any time. }
+
+ {/* notices band */}
+ {!catalogEmpty && STOREFRONT.imageProblemCount > 0 && (
+
onOpenRun(STOREFRONT.latestRunId)} style={{
+ display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%', textAlign: 'left',
+ marginTop: '1.5rem', padding: '.85rem 1.1rem', borderRadius: '12px', cursor: 'pointer',
+ background: 'var(--st-update-tint)', border: '1px solid var(--st-update-line)', font: 'inherit',
+ }}>
+
+
+ {STOREFRONT.imageProblemCount} products have image problems.
+ Some images were rejected or unreachable in your latest import.
+
+
+ View run
+
+
+ )}
+
+ {/* primary content */}
+ {catalogEmpty ? (
+
+
+
+
+ No products yet
+
+ Bulk import is how product data gets in. Bring a CSV — yours, or an export from your old platform — and your catalog becomes your storefront.
+
+
+ Import products
+
+
+
{ e.preventDefault(); downloadText('wander-wool-sample.csv', SAMPLE_CSV); toast('Sample CSV downloaded', 'success'); }} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
+ Download sample CSV
+
+
e.preventDefault()} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
+ Column reference
+
+
+
+ ) : (
+
+
+
+
+
+
Your catalog is loaded — {count.toLocaleString()} products.
+
The browsable product list arrives with an upcoming release. For now, manage it in bulk: import to change it, export to take it.
+
+ List · soon
+
+ )}
+
+ {/* history */}
+ {runs.length > 0
+ ?
+ : !catalogEmpty ? null : (
+
+ Import history
+ No imports yet.
+
+ )}
+
+ );
+}
+
+window.ProductsPage = ProductsPage;
+window.IE_downloadText = downloadText;
+window.IE_SAMPLE_CSV = SAMPLE_CSV;
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx
new file mode 100644
index 0000000..dd164ca
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx
@@ -0,0 +1,117 @@
+/* shell.jsx — SD-0001 admin shell: dark cosmic sidebar nav + top header
+ (storefront name left, signed-in email + sign out right), light paper canvas.
+ Exported to window as AdminShell. */
+
+var { Notice, Soul } = window.WiggleverseDesignSystem_94cd80 || {};
+
+const NAV_ITEMS = [
+ { id: 'home', label: 'Home', icon: 'home' },
+ { id: 'products', label: 'Products', icon: 'products' },
+ { id: 'orders', label: 'Orders', icon: 'cart', soon: true },
+ { id: 'customers', label: 'Customers', icon: 'tag', soon: true },
+ { id: 'analytics', label: 'Analytics', icon: 'gauge', soon: true },
+ { id: 'settings', label: 'Settings', icon: 'gauge', soon: true },
+];
+
+function SidebarItem({ item, active, onClick }) {
+ const [hover, setHover] = _shUseState(false);
+ return (
+ !item.soon && onClick(item.id)}
+ onMouseEnter={() => setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ disabled={item.soon}
+ style={{
+ display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%',
+ padding: '.6rem .8rem', borderRadius: '10px', border: '1px solid transparent',
+ font: 'inherit', textAlign: 'left', cursor: item.soon ? 'default' : 'pointer',
+ fontFamily: 'var(--font-body)', fontSize: '.95rem', fontWeight: active ? 600 : 500,
+ color: active ? 'var(--wv-starlight)' : item.soon ? 'var(--wv-starlight-55)' : 'var(--wv-starlight-78)',
+ background: active ? 'var(--wv-lilac-16)' : hover && !item.soon ? 'var(--wv-lilac-08)' : 'transparent',
+ borderColor: active ? 'var(--wv-lilac-32)' : 'transparent',
+ transition: 'background var(--dur-fast), color var(--dur-fast)',
+ }}>
+
+ {item.label}
+ {item.soon && Soon }
+
+ );
+}
+
+function AdminShell({ current, onNav, storefront, children }) {
+ return (
+
+ {/* ---- Sidebar ---- */}
+
+ {/* brand lockup */}
+
+
+
+
{storefront.name}
+
ecomm · admin
+
+
+
+
+ {NAV_ITEMS.map((it) => (
+
+ ))}
+
+
+
+ Your catalog is yours — it can leave whole, any time.
+
+
+
+ {/* ---- Main column ---- */}
+
+ {/* top header */}
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+const { useState: _shUseState } = React;
+window.AdminShell = AdminShell;
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx
new file mode 100644
index 0000000..ba6eaf9
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx
@@ -0,0 +1,541 @@
+// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
+
+/* BEGIN USAGE */
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
+// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
+// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
+// options are many or long. For color tweaks always curate 3-4 options rather than
+// a free picker; an option can also be a whole 2–5 color palette (the stored value
+// is the array). The Tweak* controls are a floor, not a ceiling — build custom
+// controls inside the panel if a tweak calls for UI they don't cover.
+/* END USAGE */
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(() => window.requestAnimationFrame(() => clampToViewport()));
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx b/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx
new file mode 100644
index 0000000..6f8d620
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx
@@ -0,0 +1,176 @@
+/* ui.jsx — shared primitives for the import/export admin: thin geometric line icons
+ (consistent with the Circle-of-Equals mark, single weight — flagged as a net-new
+ admin icon set, not house brand), status badges, diff chips, stat tiles, progress.
+ Exported to window. */
+
+const { useState: _uiUseState } = React;
+
+/* ---------- Icon set: thin, single-weight, geometric ---------- */
+const ICON_PATHS = {
+ products: 'M3 7.5 12 3l9 4.5-9 4.5-9-4.5Zm0 0v9l9 4.5 9-4.5v-9M12 12v9',
+ upload: 'M12 16V4m0 0-4 4m4-4 4 4M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3',
+ download: 'M12 4v12m0 0-4-4m4 4 4-4M4 20h16',
+ check: 'M4 12.5 9.5 18 20 6',
+ x: 'M6 6l12 12M18 6 6 18',
+ plus: 'M12 5v14M5 12h14',
+ minus: 'M5 12h14',
+ equal: 'M5 9h14M5 15h14',
+ alert: 'M12 3 2 20h20L12 3Zm0 6v6m0 3h.01',
+ clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3.5 2',
+ image: 'M3 5h18v14H3V5Zm0 11 5-5 4 4 3-3 6 6M9.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
+ chevron: 'M9 6l6 6-6 6',
+ chevronDown: 'M6 9l6 6 6-6',
+ file: 'M6 2h8l4 4v16H6V2Zm8 0v4h4',
+ home: 'M3 11.5 12 4l9 7.5M5 10v10h5v-6h4v6h5V10',
+ cart: 'M3 4h2l2.5 12h11L21 7H6',
+ gauge: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-9 4-3',
+ tag: 'M3 12V4h8l10 10-8 8L3 12Zm5-4.5a1 1 0 1 0 0-.01',
+ signout: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M10 16l-4-4 4-4M6 12h10',
+ search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm6 2-3.5-3.5',
+ arrowRight: 'M5 12h14m0 0-5-5m5 5-5 5',
+ arrowLeft: 'M19 12H5m0 0 5-5m-5 5 5 5',
+ filter: 'M3 5h18l-7 8v6l-4-2v-4L3 5Z',
+ refresh: 'M20 11a8 8 0 1 0-.5 4M20 5v6h-6',
+ link: 'M9 15l6-6M8 12l-2 2a3 3 0 0 0 4 4l2-2M16 12l2-2a3 3 0 0 0-4-4l-2 2',
+};
+
+function Icon({ name, size = 18, stroke = 1.6, style, ...rest }) {
+ return (
+
+
+
+ );
+}
+
+/* ---------- Circle-of-Equals mark (compact, for the admin lockup) ---------- */
+function CircleMark({ size = 26 }) {
+ const id = 'cm' + Math.random().toString(36).slice(2, 7);
+ // six equal nodes on a ring, connected by one wiggling line, empty center
+ const cx = 18, cy = 18, R = 13;
+ const pts = [];
+ for (let i = 0; i < 6; i++) {
+ const a = (Math.PI / 3) * i - Math.PI / 2;
+ pts.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]);
+ }
+ // wiggle path: between each pair of nodes, bow the line in/out alternately
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
+ for (let i = 1; i <= 6; i++) {
+ const p = pts[i % 6];
+ const prev = pts[i - 1];
+ const mx = (prev[0] + p[0]) / 2, my = (prev[1] + p[1]) / 2;
+ const out = i % 2 === 0 ? 1.18 : 0.82;
+ const ctrlx = cx + (mx - cx) * out, ctrly = cy + (my - cy) * out;
+ d += ` Q ${ctrlx} ${ctrly} ${p[0]} ${p[1]}`;
+ }
+ return (
+
+
+
+
+
+
+
+
+ {pts.map((p, i) => )}
+
+ );
+}
+
+/* ---------- Status / kind badges ---------- */
+const KIND_META = {
+ add: { glyph: 'plus', label: 'Add', cssvar: 'add' },
+ update: { glyph: 'equal', label: 'Update', cssvar: 'update' },
+ unchanged: { glyph: 'minus', label: 'Unchanged', cssvar: 'muted' },
+ error: { glyph: 'alert', label: 'Error', cssvar: 'error' },
+};
+
+function KindBadge({ kind, size = 'md' }) {
+ const m = KIND_META[kind];
+ const pad = size === 'sm' ? '.12rem .5rem .12rem .42rem' : '.22rem .62rem .22rem .5rem';
+ return (
+
+
+ {m.label}
+
+ );
+}
+
+/* ---------- Diff value: before → after ---------- */
+function DiffPair({ before, after, noop }) {
+ if (noop) {
+ return {after} (unchanged) ;
+ }
+ return (
+
+ {before}
+
+ {after}
+
+ );
+}
+
+/* ---------- Stat tile (summary filter) ---------- */
+function StatTile({ n, label, kind, active, onClick, disabled }) {
+ const m = KIND_META[kind];
+ const accent = `var(--st-${m.cssvar})`;
+ return (
+
+
+
+ {label}
+
+
+ {n.toLocaleString()}
+
+
+ );
+}
+
+/* ---------- Progress bar ---------- */
+function ProgressBar({ value, max, accent = 'var(--wv-violet)', height = 8 }) {
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
+ return (
+
+ );
+}
+
+/* ---------- Generic paper card ---------- */
+function Card({ children, style, pad = '1.5rem', ...rest }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/* ---------- Spinner (thin, geometric) ---------- */
+function Spinner({ size = 16, stroke = 2 }) {
+ return (
+
+
+
+
+ );
+}
+
+Object.assign(window, {
+ Icon, CircleMark, KindBadge, DiffPair, StatTile, ProgressBar, Card, Spinner, KIND_META,
+});