From 8a0c7bc35eccf35a73ccd77dcee6c6e33dd717b7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:46:48 -0700 Subject: [PATCH] =?UTF-8?q?spec(SD-0002=20=C2=A75.1,=20D-14):=20commit=20C?= =?UTF-8?q?laude=20Design=20hi-fi=20bundle=20for=20the=20Products=20surfac?= =?UTF-8?q?e;=20amend=20design-tooling=20decision=20to=20Design=20?= =?UTF-8?q?=E2=86=92=20Code;=20adopt=20toast=20refinement;=20v0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 46 +- .../.thumbnail | Bin 0 -> 15633 bytes .../Products - Bulk CSV Import.html | 92 ++ .../_adherence.oxlintrc.json | 378 +++++++ .../_ds_bundle.js | 959 ++++++++++++++++++ .../_ds_manifest.json | 1 + .../assets/fonts/fonts.css | 10 + .../fonts/fraunces-v38-latin-500italic.woff2 | Bin 0 -> 22888 bytes .../fonts/fraunces-v38-latin-italic.woff2 | Bin 0 -> 22852 bytes .../assets/fonts/inter-v20-latin-500.woff2 | Bin 0 -> 24272 bytes .../assets/fonts/inter-v20-latin-600.woff2 | Bin 0 -> 24452 bytes .../fonts/inter-v20-latin-regular.woff2 | Bin 0 -> 23664 bytes .../fonts/space-grotesk-v22-latin-500.woff2 | Bin 0 -> 13312 bytes .../fonts/space-grotesk-v22-latin-700.woff2 | Bin 0 -> 12840 bytes .../readme.md | 170 ++++ .../styles.css | 8 + .../tokens/colors.css | 60 ++ .../tokens/spacing.css | 55 + .../tokens/typography.css | 53 + .../app/app.jsx | 170 ++++ .../app/data.jsx | 435 ++++++++ .../app/importflow.jsx | 556 ++++++++++ .../app/products.jsx | 257 +++++ .../app/shell.jsx | 117 +++ .../app/tweaks-panel.jsx | 541 ++++++++++ .../app/ui.jsx | 176 ++++ 26 files changed, 4069 insertions(+), 15 deletions(-) create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_bundle.js create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_manifest.json create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fonts.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fraunces-v38-latin-500italic.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/fraunces-v38-latin-italic.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-500.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-600.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/inter-v20-latin-regular.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/space-grotesk-v22-latin-500.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/assets/fonts/space-grotesk-v22-latin-700.woff2 create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/readme.md create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/colors.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/spacing.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/tokens/typography.css create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/app.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/data.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/importflow.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/products.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/shell.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/tweaks-panel.jsx create mode 100644 ui/designs/SD-0002-products-bulk-csv-import-export/app/ui.jsx diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 6509aca..d0c4efb 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -15,6 +15,7 @@ | --- | --- | --- | --- | | 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben | | 2026-06-11 | v0.2.0 | Full draft complete, §§1–14, authored section-at-a-time with operator review at each boundary (session ecomm-0030) | Claude + Ben | +| 2026-06-11 | v0.3.0 | Claude Design hi-fi bundle committed (`ui/designs/SD-0002-…`); §5.1 decision amended to Design → Code (D-14); §5.2 toast refinement adopted | Claude + Ben | --- @@ -484,22 +485,34 @@ Scenario: PUC-11 — The sample CSV and column reference teach the shape ## 5. UX Layout -Text is the source of truth; wireframes are generated from these descriptions -on demand and never committed. All surfaces live inside SD-0001's admin shell -(its header: storefront name left, signed-in email + sign out right). +Text is the source of truth; the committed **Claude Design hi-fi bundle** at +[`ui/designs/SD-0002-products-bulk-csv-import-export/`](../ui/designs/SD-0002-products-bulk-csv-import-export/) +renders these descriptions interactively (all four screens + states, built on +the wiggleverse design-system bundle) and serves as SLICE-5's visual +bootstrap artifact. Where bundle and prose disagree, this prose governs +(GUIDE §3.2). All surfaces live inside SD-0001's admin shell (its header: +storefront name left, signed-in email + sign out right; the bundle's sidebar +treatment is indicative — the shell remains SD-0001's). ### 5.1 Design tooling decision -- **Decision:** Code directly. -- **Rationale:** rubric score **−1** (claude-design-vs-code.md): Q1 **+1** - (net-new Products section, no data-surface precedent in the codebase), - Q2 **−1** (this section describes the UI to wireframe level — named - components, enumerated states), Q3 **0** (one admin section, multi-step but - utilitarian), Q4 **+1** (no design system in the ecomm app yet), Q5 **−1** - (solo build, operator is the only reviewer), Q6 **−1** (work is dominantly - logic: parsing, diffing, upsert, image pipeline). Default at −1 is Code; no - override fires — the import preview is novel but structural, and prose - specifies it adequately. +- **Decision:** Design → Code, with the Claude Design project scoped to: the + Products section (Products page · import upload · preview · run detail). + Export bundle committed at `ui/designs/SD-0002-products-bulk-csv-import-export/`; + per the rubric's integration note it is a **bootstrap artifact, not a + sync** — after SLICE-5 lands it, iteration happens in code, and Claude + Design returns only when a new spec independently scores for it. +- **Rationale:** the rubric scored **−1 → Code directly** as drafted (Q1 +1 + net-new section, Q2 −1 wireframe-level prose, Q3 0, Q4 +1 no app design + system, Q5 −1 solo review, Q6 −1 logic-dominant). The operator then ran + the Design step anyway — the shareable-artifact override in effect — and + produced the hi-fi bundle from this spec's §§4–5, validating the prose + rather than replacing it. Both the score and the actual path are recorded + so neither is re-litigated (D-14, §13). +- **Design notes carried into implementation:** the bundle's thin + single-weight geometric icon set is flagged (by the bundle itself) as a + **net-new admin icon set**, not yet house brand; the design-system bundle + (`_ds/wiggleverse-design-system-…`) is the same one SD-0001's designs use. ### 5.2 Screen: Products (serves PUC-1, PUC-7, PUC-8, PUC-9, PUC-11) @@ -529,8 +542,10 @@ on demand and never committed. All surfaces live inside SD-0001's admin shell imports yet." - **States:** loading: skeleton for count + history · error: honest failure banner with retry (INV-9 spirit) · empty/populated per above. -- **Notifications:** none beyond the notices band (no toasts here; outcomes - live on durable surfaces). +- **Notifications:** toasts for the two fire-and-forget gestures — export + started/completed and sample-CSV downloaded (a design-bundle refinement); + import outcomes never toast — they live on durable surfaces (preview, run + detail, notices band). ### 5.3 Screen: Import — upload (serves PUC-2, PUC-5a, PUC-11) @@ -1378,6 +1393,7 @@ rationale for engineering choices lives in §6.7) | D-11 | Format learning | Sample CSV + column reference, app-served (DOC-2/3) | 2026-06-11 | | D-12 | Re-import of images | Platform-hosted and already-fetched URLs are never re-fetched; fetch only new URLs + retries of failed/rejected (round-trip integrity, INV-12) | 2026-06-11 | | D-13 | Storage & serving | Catalog in existing Cloud SQL (additive); images in a per-env private GCS bucket, app-served with immutable caching; CDN named as the #24 seam (§6.3.1) | 2026-06-11 | +| D-14 | Design artifact | Operator ran the Claude Design step (overriding the rubric's Code-directly score); hi-fi bundle committed at `ui/designs/SD-0002-…/` as SLICE-5's bootstrap artifact; prose (§5) remains the source of truth (§5.1) | 2026-06-11 | ## 14. Glossary & References diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail b/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail new file mode 100644 index 0000000000000000000000000000000000000000..57d51341cd1cf83d5b492c09a7043e73d7dbcf4e GIT binary patch literal 15633 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 + + + + +Products — Bulk CSV Import / Export + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json new file mode 100644 index 0000000..236d2d9 --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json @@ -0,0 +1,378 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/brand/**", + "components/cards/**", + "components/core/**", + "components/navigation/**", + "ui_kits/wiggleverse-www/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Space Grotesk|Inter|Fraunces))/i]", + "message": "Font not provided by the design system. Available: Space Grotesk, Inter, Fraunces." + }, + { + "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|href|showWordmark|assetBase|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: variant, size, href, showWordmark, assetBase." + }, + { + "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|footer|onLight)$/]", + "message": " variant must be one of 'primary' | 'footer' | 'onLight'." + }, + { + "selector": "JSXOpeningElement[name.name='BuildCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:tag|title|children|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: tag, title, children." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|href|onLight|disabled|type|onClick|key|ref|className|style|children)$/]", + "message": " +
+
+ {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 +
+ +
+ + 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) => ( + + ))} +
+
+ + )} +
+ ); +} + +/* ================================ 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 ( +
+ + {expanded &&
} +
+ ); +} + +function ErrorTable({ errors }) { + return ( + + + + + + + + + + + {errors.map((e, i) => ( + + + + + + ))} + +
LineColumnWhat's wrong
{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 && ( +
+ + {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 : ''}.} +
+
+ + +
+
+ + ); +} + +/* ============================== 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]) => ( + + ))} +
+
+ +
+
+ ); +} + +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 +
+ + + + + + + + + + + + + + {runs.map((r, i) => ( + onOpenRun(r.id)} className="ie-hrow" style={{ + borderTop: '1px solid var(--paper-line)', cursor: 'pointer', + }}> + + + + + + + + ))} + +
WhenFileDialectAdded / Updated / ErrorsImagesStatus
{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()} +

+
+
+
+ + {menuOpen && setMenuOpen(false)} />} +
+ +
+
+ + {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 && ( + + )} + + {/* 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. +

+ + +
+ ) : ( + +
+ +
+
+
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 ( + + ); +} + +function AdminShell({ current, onNav, storefront, children }) { + return ( +
+ {/* ---- Sidebar ---- */} + + + {/* ---- Main column ---- */} +
+ {/* top header */} +
+
+ + {storefront.name} + · live storefront +
+
+ {storefront.email} + +
+
+ +
+
+ {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} + +
+
+ {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}
+ +
+ ); +} + +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) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +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 ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +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 ( + + ); +} + +/* ---------- 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, +});