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 0000000..57d5134 Binary files /dev/null and b/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail differ diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html b/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html new file mode 100644 index 0000000..e3ad2f5 --- /dev/null +++ b/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html @@ -0,0 +1,92 @@ + + + + + +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, +});