SD-0002: Products — bulk CSV import/export Solution Design (#13) + Claude Design bundle #4

Merged
ben.stull merged 12 commits from session-0030 into main 2026-06-11 20:57:57 +00:00
26 changed files with 5467 additions and 0 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Products — Bulk CSV Import / Export</title>
<!-- Wiggleverse design system: tokens + webfonts (one entry point @imports the rest) -->
<link rel="stylesheet" href="_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/styles.css" />
<style>
/* ---- paper-canvas + status tokens (status hues defined in oklch-adjacent
muted tones that harmonize with the violet/ink palette; never color-only) ---- */
:root {
--paper-line: rgba(60, 47, 122, .12);
--paper-line-strong: rgba(60, 47, 122, .22);
--violet-tint: rgba(124, 111, 224, .10);
--violet-line: rgba(124, 111, 224, .28);
--add-tint: rgba(31, 138, 91, .10);
--st-add: #1F8A5B; --st-add-tint: rgba(31,138,91,.10); --st-add-line: rgba(31,138,91,.30);
--st-update: #B5830F; --st-update-tint: rgba(181,131,15,.12); --st-update-line: rgba(181,131,15,.32);
--st-error: #C2513E; --st-error-tint: rgba(194,81,62,.10); --st-error-line: rgba(194,81,62,.30);
--st-muted: #6A5E94; --st-muted-tint: rgba(106,94,148,.10); --st-muted-line: rgba(106,94,148,.24);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--surface-paper);
color: var(--text-on-light);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection { background: var(--wv-lilac-32); }
@keyframes ie-spin { to { transform: rotate(360deg); } }
@keyframes ie-toast { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
/* hover affordances */
.ie-hrow:hover { background: var(--violet-tint); }
.ie-samplefile:hover { border-color: var(--violet-line) !important; transform: translateY(-1px); }
.ie-prow:hover { background: var(--violet-tint) !important; }
.ie-stat[data-active="0"]:hover { transform: translateY(-1px); border-color: var(--paper-line-strong) !important; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible {
outline: 3px solid var(--focus-ring); outline-offset: 2px; border-radius: 4px;
}
/* density */
[data-density="compact"] td { padding-top: .55rem !important; padding-bottom: .55rem !important; }
[data-density="compact"] .ie-prow { padding-top: .6rem !important; padding-bottom: .6rem !important; }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
</style>
</head>
<body>
<div id="ie-root"></div>
<script>
// Benign, non-actionable browser notification emitted by ResizeObserver-based
// layout (the Tweaks panel). Swallow only this exact message.
window.addEventListener('error', function (e) {
if (e && e.message && e.message.indexOf('ResizeObserver loop') !== -1) {
e.stopImmediatePropagation();
}
});
</script>
<!-- React + Babel (pinned) -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<!-- Wiggleverse component bundle (plain compiled JS — needs window.React) -->
<script src="_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_ds_bundle.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- app (order: data → ui → shell/products/importflow → tweaks → app) -->
<script type="text/babel" src="app/data.jsx"></script>
<script type="text/babel" src="app/ui.jsx"></script>
<script type="text/babel" src="app/shell.jsx"></script>
<script type="text/babel" src="app/products.jsx"></script>
<script type="text/babel" src="app/importflow.jsx"></script>
<script type="text/babel" src="app/tweaks-panel.jsx"></script>
<script type="text/babel" src="app/app.jsx"></script>
</body>
</html>
@@ -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": "<BrandLockup> 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": "<BrandLockup> 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": "<BuildCard> 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": "<Button> doesn't accept that prop. Declared props: children, variant, href, onLight, disabled, type, onClick."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|ghost)$/]",
"message": "<Button> variant must be one of 'primary' | 'ghost'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
},
{
"selector": "JSXOpeningElement[name.name='Callout'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|key|ref|className|style|children)$/]",
"message": "<Callout> doesn't accept that prop. Declared props: children, onLight."
},
{
"selector": "JSXOpeningElement[name.name='Eyebrow'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onLight|as|key|ref|className|style|children)$/]",
"message": "<Eyebrow> doesn't accept that prop. Declared props: children, onLight, as."
},
{
"selector": "JSXOpeningElement[name.name='Notice'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|key|ref|className|style|children)$/]",
"message": "<Notice> doesn't accept that prop. Declared props: children."
},
{
"selector": "JSXOpeningElement[name.name='PathCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|children|href|first|key|ref|className|style|children)$/]",
"message": "<PathCard> doesn't accept that prop. Declared props: icon, title, children, href, first."
},
{
"selector": "JSXOpeningElement[name.name='FooterColumn'] > JSXAttribute > JSXIdentifier[name!=/^(?:heading|links|key|ref|className|style|children)$/]",
"message": "<FooterColumn> doesn't accept that prop. Declared props: heading, links."
},
{
"selector": "JSXOpeningElement[name.name='NavLink'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|href|cta|key|ref|className|style|children)$/]",
"message": "<NavLink> doesn't accept that prop. Declared props: label, href, cta."
},
{
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|tone|onLight|as|key|ref|className|style|children)$/]",
"message": "<Soul> doesn't accept that prop. Declared props: children, size, tone, onLight, as."
},
{
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
"message": "<Soul> size must be one of 'sm' | 'md' | 'lg'."
},
{
"selector": "JSXOpeningElement[name.name='Soul'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:gold|plain|lilac)$/]",
"message": "<Soul> tone must be one of 'gold' | 'plain' | 'lilac'."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|onLight|key|ref|className|style|children)$/]",
"message": "<Tag> doesn't accept that prop. Declared props: children, variant, onLight."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:live|soon)$/]",
"message": "<Tag> variant must be one of 'live' | 'soon'."
}
]
},
"overrides": [
{
"files": [
"**/index.js"
],
"rules": {
"no-restricted-imports": "off"
}
}
],
"x-omelette": {
"components": {
"BrandLockup": {
"replaces": []
},
"BuildCard": {
"replaces": []
},
"Button": {
"replaces": []
},
"Callout": {
"replaces": []
},
"Eyebrow": {
"replaces": []
},
"Notice": {
"replaces": []
},
"PathCard": {
"replaces": []
},
"FooterColumn": {
"replaces": []
},
"NavLink": {
"replaces": []
},
"Soul": {
"replaces": []
},
"Tag": {
"replaces": []
}
},
"tokens": [
"--accent",
"--accent-on-light",
"--border-card",
"--border-card-w",
"--border-hair",
"--border-soft",
"--border-strong",
"--btn-border-w",
"--cta",
"--cta-hover",
"--cta-text",
"--dur-fast",
"--dur-mid",
"--ease",
"--focus-ring",
"--font-body",
"--font-display",
"--font-soul",
"--glass-blur",
"--glass-sky",
"--leading-body",
"--leading-tight",
"--lift-1",
"--lift-3",
"--measure-lead",
"--measure-prose",
"--page-hero-pad",
"--radius-card",
"--radius-mark",
"--radius-panel",
"--radius-pill",
"--radius-sm",
"--section-pad",
"--shadow-none",
"--shadow-soft",
"--space-0",
"--space-1",
"--space-10",
"--space-12",
"--space-2",
"--space-3",
"--space-4",
"--space-5",
"--space-6",
"--space-8",
"--surface-card-light",
"--surface-footer",
"--surface-paper",
"--surface-raised",
"--surface-raised-hi",
"--surface-sky",
"--text-body",
"--text-eyebrow",
"--text-fine",
"--text-h1",
"--text-h2",
"--text-h3",
"--text-lead",
"--text-on-dark",
"--text-on-dark-mute",
"--text-on-dark-soft",
"--text-on-light",
"--text-on-light-soft",
"--text-small",
"--text-soul",
"--text-tag",
"--tracking-display",
"--tracking-eyebrow",
"--tracking-notice",
"--tracking-tag",
"--weight-bold",
"--weight-medium",
"--weight-regular",
"--weight-semibold",
"--weight-soul",
"--wrap-gutter",
"--wrap-max",
"--wv-font-body",
"--wv-font-display",
"--wv-font-human",
"--wv-gold",
"--wv-gold-28",
"--wv-gold-40",
"--wv-gold-hi",
"--wv-gold-ink",
"--wv-gold-ink-soft",
"--wv-indigo",
"--wv-indigo-2",
"--wv-ink",
"--wv-lilac",
"--wv-lilac-08",
"--wv-lilac-12",
"--wv-lilac-16",
"--wv-lilac-18",
"--wv-lilac-32",
"--wv-midnight",
"--wv-night",
"--wv-paper",
"--wv-starlight",
"--wv-starlight-55",
"--wv-starlight-60",
"--wv-starlight-78",
"--wv-starlight-85",
"--wv-violet"
],
"tokenKinds": {
"--wv-midnight": "color",
"--wv-indigo": "color",
"--wv-indigo-2": "color",
"--wv-lilac": "color",
"--wv-violet": "color",
"--wv-gold": "color",
"--wv-gold-hi": "color",
"--wv-starlight": "color",
"--wv-paper": "color",
"--wv-ink": "color",
"--wv-night": "color",
"--wv-gold-ink": "color",
"--wv-gold-ink-soft": "color",
"--wv-lilac-08": "color",
"--wv-lilac-12": "color",
"--wv-lilac-16": "color",
"--wv-lilac-18": "color",
"--wv-lilac-32": "color",
"--wv-gold-28": "color",
"--wv-gold-40": "color",
"--wv-starlight-85": "color",
"--wv-starlight-78": "color",
"--wv-starlight-60": "color",
"--wv-starlight-55": "color",
"--surface-sky": "color",
"--surface-raised": "color",
"--surface-raised-hi": "color",
"--surface-paper": "color",
"--surface-card-light": "color",
"--surface-footer": "color",
"--text-on-dark": "font",
"--text-on-dark-soft": "font",
"--text-on-dark-mute": "font",
"--text-on-light": "font",
"--text-on-light-soft": "font",
"--accent": "color",
"--accent-on-light": "color",
"--cta": "color",
"--cta-hover": "color",
"--cta-text": "font",
"--border-soft": "color",
"--border-card": "color",
"--border-strong": "color",
"--focus-ring": "color",
"--wv-font-display": "font",
"--wv-font-body": "font",
"--wv-font-human": "font",
"--font-display": "font",
"--font-body": "font",
"--font-soul": "font",
"--weight-regular": "font",
"--weight-medium": "font",
"--weight-semibold": "font",
"--weight-bold": "font",
"--weight-soul": "font",
"--text-h1": "font",
"--text-h2": "font",
"--text-h3": "font",
"--text-lead": "font",
"--text-soul": "font",
"--text-body": "font",
"--text-small": "font",
"--text-fine": "font",
"--text-eyebrow": "font",
"--text-tag": "font",
"--leading-tight": "font",
"--leading-body": "font",
"--tracking-display": "font",
"--tracking-eyebrow": "font",
"--tracking-tag": "font",
"--tracking-notice": "font",
"--measure-lead": "spacing",
"--measure-prose": "spacing",
"--space-0": "spacing",
"--space-1": "spacing",
"--space-2": "spacing",
"--space-3": "spacing",
"--space-4": "spacing",
"--space-5": "spacing",
"--space-6": "spacing",
"--space-8": "spacing",
"--space-10": "spacing",
"--space-12": "spacing",
"--section-pad": "spacing",
"--page-hero-pad": "spacing",
"--wrap-max": "spacing",
"--wrap-gutter": "spacing",
"--radius-card": "radius",
"--radius-panel": "radius",
"--radius-sm": "radius",
"--radius-pill": "radius",
"--radius-mark": "radius",
"--border-hair": "spacing",
"--border-card-w": "spacing",
"--btn-border-w": "spacing",
"--shadow-none": "shadow",
"--shadow-soft": "shadow",
"--lift-1": "other",
"--lift-3": "other",
"--glass-sky": "color",
"--glass-blur": "spacing",
"--ease": "other",
"--dur-fast": "other",
"--dur-mid": "other"
},
"fontFamilies": [
"Fraunces",
"Inter",
"Space Grotesk"
]
}
}
@@ -0,0 +1,959 @@
/* @ds-bundle: {"format":3,"namespace":"WiggleverseDesignSystem_94cd80","components":[{"name":"BrandLockup","sourcePath":"components/brand/BrandLockup.jsx"},{"name":"BuildCard","sourcePath":"components/cards/BuildCard.jsx"},{"name":"PathCard","sourcePath":"components/cards/PathCard.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Callout","sourcePath":"components/core/Callout.jsx"},{"name":"Eyebrow","sourcePath":"components/core/Eyebrow.jsx"},{"name":"Notice","sourcePath":"components/core/Notice.jsx"},{"name":"Soul","sourcePath":"components/core/Soul.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"SiteFooter","sourcePath":"components/navigation/SiteFooter.jsx"},{"name":"SiteHeader","sourcePath":"components/navigation/SiteHeader.jsx"}],"sourceHashes":{"components/brand/BrandLockup.jsx":"70ef76a92b84","components/cards/BuildCard.jsx":"f4588d77bd79","components/cards/PathCard.jsx":"f8779016b751","components/core/Button.jsx":"58601f356762","components/core/Callout.jsx":"4347df19558c","components/core/Eyebrow.jsx":"38c0374f060f","components/core/Notice.jsx":"471101cf1c07","components/core/Soul.jsx":"d1b2ddeea071","components/core/Tag.jsx":"192ca2a7e57d","components/navigation/SiteFooter.jsx":"61fbdd9cf145","components/navigation/SiteHeader.jsx":"6541bfe8e43a","ui_kits/wiggleverse-www/app.jsx":"f0cea1954d57"},"inlinedExternals":[],"unexposedExports":[]} */
(() => {
const __ds_ns = (window.WiggleverseDesignSystem_94cd80 = window.WiggleverseDesignSystem_94cd80 || {});
const __ds_scope = {};
(__ds_ns.__errors = __ds_ns.__errors || []);
// components/brand/BrandLockup.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse BrandLockup — the mark + "Wiggleverse" wordmark, linked home.
* 'primary' uses the gradient mark on dark; 'footer' uses the mono-gold mark;
* 'onLight' uses the ink mark for paper. Wordmark is Space Grotesk 700, never italic.
*/
function BrandLockup({
variant = 'primary',
size = 34,
href = '/',
showWordmark = true,
assetBase = '/assets',
...rest
}) {
const marks = {
primary: 'wiggleverse-mark.svg',
footer: 'mark-mono-gold.svg',
onLight: 'mark-on-light.svg'
};
const wordColor = variant === 'onLight' ? 'var(--wv-ink)' : 'var(--wv-starlight)';
// Resolve asset relative to the given base so the lockup works from any depth.
const src = `${assetBase}/${marks[variant] || marks.primary}`;
return /*#__PURE__*/React.createElement("a", _extends({
href: href,
"aria-label": "Wiggleverse home",
style: {
display: 'inline-flex',
alignItems: 'center',
gap: '.55rem',
textDecoration: 'none'
}
}, rest), /*#__PURE__*/React.createElement("img", {
src: src,
alt: "",
width: size,
height: size,
style: {
width: size,
height: size
}
}), showWordmark && /*#__PURE__*/React.createElement("span", {
style: {
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-bold)',
fontSize: `${Math.round(size * 0.0338 * 10) / 10}rem`,
letterSpacing: 'var(--tracking-display)',
color: wordColor
}
}, "Wiggleverse"));
}
Object.assign(__ds_scope, { BrandLockup });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/brand/BrandLockup.jsx", error: String((e && e.message) || e) }); }
// components/cards/BuildCard.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse BuildCard — a portfolio item on a paper section. White card with
* a violet hairline, a status Tag, title, and copy. Built for light backgrounds.
*/
function BuildCard({
tag,
title,
children,
...rest
}) {
return /*#__PURE__*/React.createElement("div", _extends({
style: {
padding: '1.4rem',
borderRadius: 'var(--radius-card)',
border: '1px solid rgba(124,111,224,.3)',
background: 'var(--surface-card-light)'
}
}, rest), tag && /*#__PURE__*/React.createElement("div", {
style: {
marginBottom: '.6rem'
}
}, tag), /*#__PURE__*/React.createElement("h3", {
style: {
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-bold)',
fontSize: 'var(--text-h3)',
margin: '0 0 .35rem',
color: 'var(--wv-ink)',
letterSpacing: 'var(--tracking-display)'
}
}, title), /*#__PURE__*/React.createElement("p", {
style: {
margin: 0,
fontSize: 'var(--text-small)',
lineHeight: 1.55,
color: 'var(--text-on-light-soft)'
}
}, children));
}
Object.assign(__ds_scope, { BuildCard });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/BuildCard.jsx", error: String((e && e.message) || e) }); }
// components/cards/PathCard.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse PathCard — the audience-router doorway. A raised indigo card with
* a glyph icon, title, and one line of copy. Hover lifts -3px and the border
* warms to lilac. 'first' gives it a gold border (the recommended door).
*/
function PathCard({
icon,
title,
children,
href = '#',
first = false,
...rest
}) {
const [hover, setHover] = React.useState(false);
return /*#__PURE__*/React.createElement("a", _extends({
href: href,
onMouseEnter: () => setHover(true),
onMouseLeave: () => setHover(false),
style: {
display: 'block',
padding: '1.2rem',
borderRadius: 'var(--radius-card)',
background: hover ? 'var(--surface-raised-hi)' : 'var(--surface-raised)',
border: `1px solid ${first ? 'var(--wv-gold)' : hover ? 'var(--wv-lilac)' : 'var(--border-card)'}`,
color: 'var(--wv-starlight)',
textDecoration: 'none',
transform: hover ? 'var(--lift-3)' : 'none',
transition: 'transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease)'
}
}, rest), icon && /*#__PURE__*/React.createElement("div", {
"aria-hidden": "true",
style: {
fontSize: '1.5rem'
}
}, icon), /*#__PURE__*/React.createElement("h3", {
style: {
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-bold)',
fontSize: 'var(--text-h3)',
margin: '.5rem 0 .25rem',
color: 'var(--wv-starlight)',
letterSpacing: 'var(--tracking-display)'
}
}, title), /*#__PURE__*/React.createElement("p", {
style: {
margin: 0,
fontSize: 'var(--text-fine)',
lineHeight: 1.5,
color: 'var(--wv-starlight-78)'
}
}, children));
}
Object.assign(__ds_scope, { PathCard });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/cards/PathCard.jsx", error: String((e && e.message) || e) }); }
// components/core/Button.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Button — pill action in the machine register (Space Grotesk 500).
* Primary = gold horizon CTA; ghost = lilac-outlined. Hover lifts -1px (never a shadow).
*/
function Button({
children,
variant = 'primary',
href,
onLight = false,
disabled = false,
type = 'button',
onClick,
...rest
}) {
const base = {
display: 'inline-flex',
alignItems: 'center',
gap: '.4em',
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-medium)',
fontSize: 'var(--text-body)',
lineHeight: 1,
padding: '.7rem 1.25rem',
borderRadius: 'var(--radius-pill)',
border: 'var(--btn-border-w) solid transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
textDecoration: 'none',
opacity: disabled ? 0.5 : 1,
transition: 'transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)'
};
const variants = {
primary: {
background: 'var(--cta)',
color: 'var(--cta-text)'
},
ghost: {
background: 'transparent',
borderColor: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
}
};
const style = {
...base,
...(variants[variant] || variants.primary)
};
const handleEnter = e => {
if (!disabled) e.currentTarget.style.transform = 'var(--lift-1)';
};
const handleLeave = e => {
e.currentTarget.style.transform = 'none';
};
const common = {
style,
onMouseEnter: handleEnter,
onMouseLeave: handleLeave,
...rest
};
if (href && !disabled) {
return /*#__PURE__*/React.createElement("a", _extends({
href: href,
onClick: onClick
}, common), children);
}
return /*#__PURE__*/React.createElement("button", _extends({
type: type,
disabled: disabled,
onClick: onClick
}, common), children);
}
Object.assign(__ds_scope, { Button });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Button.jsx", error: String((e && e.message) || e) }); }
// components/core/Eyebrow.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Eyebrow — uppercase, letter-spaced kicker in the machine register
* that sits above a section heading. Lilac on dark, violet on paper.
*/
function Eyebrow({
children,
onLight = false,
as = 'p',
...rest
}) {
const Tag = as;
return /*#__PURE__*/React.createElement(Tag, _extends({
style: {
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-medium)',
fontSize: 'var(--text-eyebrow)',
letterSpacing: 'var(--tracking-eyebrow)',
textTransform: 'uppercase',
color: onLight ? 'var(--wv-violet)' : 'var(--wv-lilac)',
margin: '0 0 1rem'
}
}, rest), children);
}
Object.assign(__ds_scope, { Eyebrow });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Eyebrow.jsx", error: String((e && e.message) || e) }); }
// components/core/Notice.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Notice — a hollow gold pill set inline beside a heading to mark
* roadmap status ("In build", "Coming"). Quieter than a filled Tag.
*/
function Notice({
children,
...rest
}) {
return /*#__PURE__*/React.createElement("span", _extends({
style: {
display: 'inline-block',
fontFamily: 'var(--wv-font-display)',
fontSize: '.82rem',
fontWeight: 'var(--weight-medium)',
letterSpacing: 'var(--tracking-notice)',
textTransform: 'uppercase',
color: 'var(--wv-gold)',
border: '1px solid var(--wv-gold-40)',
borderRadius: 'var(--radius-pill)',
padding: '.25rem .8rem',
verticalAlign: 'middle'
}
}, rest), children);
}
Object.assign(__ds_scope, { Notice });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Notice.jsx", error: String((e && e.message) || e) }); }
// components/core/Soul.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Soul — the human register. Fraunces ITALIC ONLY, used sparingly
* for the lines that carry conscience (taglines, pull-quotes). Gold by default
* (hero/page-hero) or starlight/ink when 'plain'.
*/
function Soul({
children,
size = 'md',
tone = 'gold',
onLight = false,
as = 'span',
...rest
}) {
const Tag = as;
const sizes = {
sm: '1.1rem',
md: 'var(--text-soul)',
/* clamp(1.2rem, 2.4vw, 1.7rem) */
lg: 'clamp(1.4rem, 3vw, 2rem)'
};
const tones = {
gold: 'var(--wv-gold)',
plain: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)',
lilac: 'var(--wv-lilac)'
};
return /*#__PURE__*/React.createElement(Tag, _extends({
style: {
fontFamily: 'var(--wv-font-human)',
fontStyle: 'italic',
fontWeight: 'var(--weight-soul)',
fontSize: sizes[size] || sizes.md,
lineHeight: 1.3,
color: tones[tone] || tones.gold
}
}, rest), children);
}
Object.assign(__ds_scope, { Soul });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Soul.jsx", error: String((e && e.message) || e) }); }
// components/core/Callout.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Callout — a gold left-rule framing a single conscience line.
* No box, no fill: just a 3px gold border and a Fraunces italic quote.
*/
function Callout({
children,
onLight = false,
...rest
}) {
return /*#__PURE__*/React.createElement("div", _extends({
style: {
borderLeft: '3px solid var(--wv-gold)',
padding: '.4rem 0 .4rem 1.1rem',
margin: '1.5rem 0'
}
}, rest), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
size: "lg",
tone: "plain",
onLight: onLight,
as: "p",
style: {
margin: 0
}
}, children));
}
Object.assign(__ds_scope, { Callout });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Callout.jsx", error: String((e && e.message) || e) }); }
// components/core/Tag.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
/**
* Wiggleverse Tag — small filled pill marking a product's status on cards.
* 'live' = lilac tint (first product); 'soon' = gold tint (coming).
*/
function Tag({
children,
variant = 'live',
onLight = false,
...rest
}) {
const base = {
display: 'inline-block',
fontFamily: 'var(--wv-font-display)',
fontSize: 'var(--text-tag)',
fontWeight: 'var(--weight-medium)',
letterSpacing: 'var(--tracking-tag)',
textTransform: 'uppercase',
padding: '.15rem .55rem',
borderRadius: 'var(--radius-pill)'
};
const variants = {
live: {
background: 'var(--wv-lilac-16)',
color: onLight ? 'var(--wv-ink)' : 'var(--wv-starlight)'
},
soon: {
background: 'var(--wv-gold-28)',
color: 'var(--wv-gold-ink-soft)'
}
};
return /*#__PURE__*/React.createElement("span", _extends({
style: {
...base,
...(variants[variant] || variants.live)
}
}, rest), children);
}
Object.assign(__ds_scope, { Tag });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Tag.jsx", error: String((e && e.message) || e) }); }
// components/navigation/SiteFooter.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
const DEFAULT_COLUMNS = [{
heading: 'Explore',
links: [{
label: 'Build',
href: '/building/'
}, {
label: 'Partner',
href: '/partner/'
}, {
label: 'Learn',
href: '/learn/'
}]
}, {
heading: 'The org',
links: [{
label: 'About',
href: '/about/'
}, {
label: 'Give',
href: '/give/'
}, {
label: 'Finances',
href: '/finances/'
}, {
label: 'Code of Conduct',
href: '/code-of-conduct/'
}]
}];
const DEFAULT_LEGAL = "Wiggleverse.org, Inc. — a Washington 501(c)(3) nonprofit creating art and software that supports humanity on the path to world peace. Dedicated to Aaron Swartz, who believed knowledge belongs to everyone.";
/**
* Wiggleverse SiteFooter — deepest ground (night). Mono-gold lockup, a lilac
* Soul tagline, link columns, and the dedication legal line.
*/
function SiteFooter({
columns = DEFAULT_COLUMNS,
tagline = 'We are verbs, not nouns.',
legal = DEFAULT_LEGAL,
assetBase = '/assets',
...rest
}) {
const wrap = {
width: 'min(var(--wrap-max), 92vw)',
margin: '0 auto'
};
return /*#__PURE__*/React.createElement("footer", _extends({
style: {
background: 'var(--surface-footer)',
borderTop: '1px solid var(--border-soft)',
padding: '3rem 0 2.5rem',
fontSize: '.92rem'
}
}, rest), /*#__PURE__*/React.createElement("div", {
style: {
...wrap,
display: 'flex',
flexWrap: 'wrap',
gap: '2rem 3rem',
justifyContent: 'space-between'
}
}, /*#__PURE__*/React.createElement("div", {
style: {
maxWidth: '22rem'
}
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
variant: "footer",
size: 30,
assetBase: assetBase
}), /*#__PURE__*/React.createElement(__ds_scope.Soul, {
size: "sm",
tone: "lilac",
as: "span",
style: {
display: 'block',
marginTop: '.6rem'
}
}, tagline)), columns.map(col => /*#__PURE__*/React.createElement("div", {
key: col.heading
}, /*#__PURE__*/React.createElement("h4", {
style: {
fontFamily: 'var(--wv-font-display)',
fontSize: '.8rem',
letterSpacing: '.1em',
textTransform: 'uppercase',
color: 'var(--wv-lilac)',
margin: '0 0 .7rem'
}
}, col.heading), /*#__PURE__*/React.createElement("ul", {
style: {
listStyle: 'none',
margin: 0,
padding: 0
}
}, col.links.map(l => /*#__PURE__*/React.createElement("li", {
key: l.href,
style: {
marginBottom: '.45rem'
}
}, /*#__PURE__*/React.createElement("a", {
href: l.href,
style: {
color: 'var(--wv-starlight-85)',
textDecoration: 'none'
}
}, l.label))))))), /*#__PURE__*/React.createElement("div", {
style: {
...wrap,
marginTop: '2.2rem',
paddingTop: '1.3rem',
borderTop: '1px solid var(--wv-lilac-12)',
color: 'var(--wv-starlight-60)',
fontSize: '.85rem'
}
}, legal));
}
Object.assign(__ds_scope, { SiteFooter });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteFooter.jsx", error: String((e && e.message) || e) }); }
// components/navigation/SiteHeader.jsx
try { (() => {
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
const DEFAULT_LINKS = [{
label: 'About',
href: '/about/'
}, {
label: 'Build',
href: '/building/'
}, {
label: 'Partner',
href: '/partner/'
}, {
label: 'Learn',
href: '/learn/'
}, {
label: 'Give',
href: '/give/'
}];
/**
* Wiggleverse SiteHeader — sticky glass nav: brand lockup + machine-register
* links. Translucent midnight with a blur and a lilac hairline underneath.
*/
function SiteHeader({
links = DEFAULT_LINKS,
current,
assetBase = '/assets',
homeHref = '/',
...rest
}) {
return /*#__PURE__*/React.createElement("header", _extends({
style: {
position: 'sticky',
top: 0,
zIndex: 50,
background: 'var(--glass-sky)',
backdropFilter: 'blur(var(--glass-blur))',
WebkitBackdropFilter: 'blur(var(--glass-blur))',
borderBottom: '1px solid var(--border-soft)'
}
}, rest), /*#__PURE__*/React.createElement("div", {
style: {
width: 'min(var(--wrap-max), 92vw)',
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '.7rem 0'
}
}, /*#__PURE__*/React.createElement(__ds_scope.BrandLockup, {
variant: "primary",
assetBase: assetBase,
href: homeHref
}), /*#__PURE__*/React.createElement("nav", null, /*#__PURE__*/React.createElement("ul", {
style: {
display: 'flex',
alignItems: 'center',
gap: '1.2rem',
listStyle: 'none',
margin: 0,
padding: 0
}
}, links.map(l => {
const isCurrent = current === l.href || current === l.label;
return /*#__PURE__*/React.createElement("li", {
key: l.href
}, /*#__PURE__*/React.createElement("a", {
href: l.href,
"aria-current": isCurrent ? 'page' : undefined,
style: {
fontFamily: 'var(--wv-font-display)',
fontWeight: 'var(--weight-medium)',
fontSize: '.95rem',
color: l.cta ? 'var(--wv-gold)' : 'var(--wv-starlight)',
textDecoration: isCurrent ? 'underline' : 'none',
textUnderlineOffset: '4px',
textDecorationColor: 'var(--wv-lilac)'
}
}, l.label));
})))));
}
Object.assign(__ds_scope, { SiteHeader });
})(); } catch (e) { __ds_ns.__errors.push({ path: "components/navigation/SiteHeader.jsx", error: String((e && e.message) || e) }); }
// ui_kits/wiggleverse-www/app.jsx
try { (() => {
/* Wiggleverse marketing-site recreation. Composes the DS components and routes
between screens by intercepting in-app anchor clicks. */
const {
Button,
Tag,
Notice,
Eyebrow,
Soul,
Callout,
PathCard,
BuildCard,
SiteHeader,
SiteFooter
} = window.WiggleverseDesignSystem_94cd80;
const ASSET_BASE = '../../assets';
const NAV = [{
label: 'About',
href: '#/about'
}, {
label: 'Build',
href: '#/ecomm'
}, {
label: 'Partner',
href: '#/about'
}, {
label: 'Learn',
href: '#/finances'
}, {
label: 'Give',
href: '#/finances',
cta: true
}];
const FOOTER_COLS = [{
heading: 'Explore',
links: [{
label: 'Build',
href: '#/ecomm'
}, {
label: 'Partner',
href: '#/about'
}, {
label: 'Learn',
href: '#/finances'
}]
}, {
heading: 'The org',
links: [{
label: 'About',
href: '#/about'
}, {
label: 'Give',
href: '#/finances'
}, {
label: 'Finances',
href: '#/finances'
}, {
label: 'Code of Conduct',
href: '#/about'
}]
}];
/* ---------- Home ---------- */
function HomeScreen() {
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
className: "hero"
}, /*#__PURE__*/React.createElement("div", {
className: "hero__sky",
"aria-hidden": "true"
}, /*#__PURE__*/React.createElement("img", {
className: "hero__mark",
src: ASSET_BASE + '/wiggleverse-mark.svg',
alt: ""
})), /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement("div", {
className: "hero__inner"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "A 501(c)(3) nonprofit \xB7 art & software for a world at peace"), /*#__PURE__*/React.createElement("h1", null, "Ethical alternatives to the software that connects you to other humans."), /*#__PURE__*/React.createElement(Soul, {
size: "md",
as: "span",
className: "hero__soul"
}, "Treat humans as humans \u2014 everything else follows."), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "A wise world where people and machines build together on one shared set of ethics. Low-cost commerce for small businesses. Tools for moving toward agreement on shared meaning. A place where, if you're learning, you're succeeding."), /*#__PURE__*/React.createElement("div", {
className: "hero__cta"
}, /*#__PURE__*/React.createElement(Button, {
variant: "primary",
href: "#/about"
}, "Why Wiggleverse \u2192"), /*#__PURE__*/React.createElement(Button, {
variant: "ghost",
href: "#/ecomm"
}, "See what we're building"))))), /*#__PURE__*/React.createElement("section", {
className: "section"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Find your way in"), /*#__PURE__*/React.createElement("h2", null, "I'm here as a\u2026"), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "Wiggleverse is an umbrella. Pick the door that fits \u2014 each leads to its own part of the world."), /*#__PURE__*/React.createElement("div", {
className: "router__grid"
}, /*#__PURE__*/React.createElement(PathCard, {
icon: "\u2727",
title: "Curious human",
href: "#/about",
first: true
}, "What is this, and why does it matter? Start with the story."), /*#__PURE__*/React.createElement(PathCard, {
icon: "\u25C7",
title: "Small business",
href: "#/ecomm"
}, "People and families who join, sell, and contribute. Near-zero-cost commerce."), /*#__PURE__*/React.createElement(PathCard, {
icon: "\u25C8",
title: "Builder",
href: "#/about"
}, "A developer who wants to build ethical software? We'd love your help."), /*#__PURE__*/React.createElement(PathCard, {
icon: "\u274D",
title: "Funder",
href: "#/finances"
}, "Donors, foundations, and grantmakers supporting a 501(c)(3) at work.")))), /*#__PURE__*/React.createElement("section", {
className: "section section--paper"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, {
onLight: true
}, "One set of principles, many alternatives"), /*#__PURE__*/React.createElement("h2", null, "What we're building"), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "Ethical alternatives to the platforms people rely on every day \u2014 every one built on the same shared set of principles."), /*#__PURE__*/React.createElement("div", {
className: "build__grid"
}, /*#__PURE__*/React.createElement(BuildCard, {
tag: /*#__PURE__*/React.createElement(Tag, {
variant: "live",
onLight: true
}, "First product"),
title: "Ecomm"
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. ", /*#__PURE__*/React.createElement("a", {
href: "#/ecomm"
}, "Why ecomm first \u2192")), /*#__PURE__*/React.createElement(BuildCard, {
tag: /*#__PURE__*/React.createElement(Tag, {
variant: "soon"
}, "Coming"),
title: "Apps"
}, "A branded mobile app of their own for every small business \u2014 so they meet their people directly, instead of renting space inside someone else's platform."), /*#__PURE__*/React.createElement(BuildCard, {
tag: /*#__PURE__*/React.createElement(Tag, {
variant: "soon"
}, "Coming"),
title: "Learn"
}, "A place where anyone \u2014 including small businesses who share our ethics \u2014 can post educational content that's true, right-sized, and actually teaches.")))), /*#__PURE__*/React.createElement("section", {
className: "section"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Open Core \xB7 a partner ecosystem"), /*#__PURE__*/React.createElement("h2", null, "Built on an open core"), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "The platform's core is open \u2014 anyone can inspect it, extend it, and build on it. Because no two businesses run the same way, we don't sell one-size-fits-all \u2014 we grow a partner ecosystem that builds software shaped to the work it's actually for."), /*#__PURE__*/React.createElement("div", {
className: "router__grid"
}, /*#__PURE__*/React.createElement(PathCard, {
icon: "\u25D0",
title: "Partner",
href: "#/about",
first: true
}, "Build custom software on an open platform \u2014 value flowing to the builders and the served, never extracted by a platform in the middle. Why custom-on-open is the future \u2192")))));
}
/* ---------- About ---------- */
function AboutScreen() {
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
className: "page-hero"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "About"), /*#__PURE__*/React.createElement("h1", null, "The era of infinite alternatives"), /*#__PURE__*/React.createElement(Soul, {
size: "md",
as: "p",
className: "page-hero__soul"
}, "Welcome to the Wiggleverse."), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "We've entered an era of alternatives \u2014 infinite alternatives. Wiggleverse exists to build the humane ones: ethical alternatives to the platforms people use every day, kept low-cost and high-value, in service of human flourishing."))), /*#__PURE__*/React.createElement("section", {
className: "section"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap prose"
}, /*#__PURE__*/React.createElement("h2", null, "Now it's the platforms"), /*#__PURE__*/React.createElement("p", null, "With large language models, we've entered the era where the platforms themselves are being commoditized \u2014 and with them comes a world of infinite alternatives for software. One genuinely skilled developer can now do what would have taken ten of them a year ago. The things everyone assumed were permanent fixtures are suddenly open to alternatives."), /*#__PURE__*/React.createElement("h2", null, "The moats are becoming anchors"), /*#__PURE__*/React.createElement("p", null, "The large platforms have thrived on three moats: the manpower and capital to run software at scale, vendor lock-in, and the network effect. My hypothesis is that the first is turning into an anchor, the second is dissolving as custom migration code gets cheap, and the third is already fragmented."), /*#__PURE__*/React.createElement("h2", null, "What Wiggleverse is for"), /*#__PURE__*/React.createElement("p", null, "None of this is about taking anyone's place. Wiggleverse exists to offer ethical alternatives to platforms like Shopify, YouTube, Facebook, and Instagram \u2014 rooted in ethics, built to give people value rather than to extract it."), /*#__PURE__*/React.createElement(Callout, null, "The intention isn't to take as much as we can from people \u2014 it's to give as much value as we can to everyone."), /*#__PURE__*/React.createElement("p", null, "Welcome to the Wiggleverse, and welcome to the era of infinite alternatives \u2014 even to the platforms everyone assumed were permanent."), /*#__PURE__*/React.createElement(Soul, {
size: "sm",
tone: "plain",
as: "p",
style: {
marginTop: '1.5rem'
}
}, "\u2014 Ben Stull, Founder"))));
}
/* ---------- Ecomm ---------- */
function EcommScreen() {
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
className: "page-hero"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "The portfolio \xB7 first product"), /*#__PURE__*/React.createElement("h1", null, "Ecomm"), /*#__PURE__*/React.createElement(Soul, {
size: "md",
as: "p",
className: "page-hero__soul"
}, "We take only what it takes to run."), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "Shopify-class commerce at near-zero cost, for people and families \u2014 because small businesses are really just people. It's the first product to move from principle into the world, and there's a reason it's first."))), /*#__PURE__*/React.createElement("section", {
className: "section"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap prose"
}, /*#__PURE__*/React.createElement("h2", null, "Why commerce first"), /*#__PURE__*/React.createElement("p", null, "Running an organization \u2014 and the infrastructure under a platform \u2014 takes money. Commerce is where money moves most, so by building close to commerce we reach a sustainable position quickest. Ecomm is first because it's the fastest honest path to standing on our own feet."), /*#__PURE__*/React.createElement(Callout, null, "Enough to keep the lights on, and no more."), /*#__PURE__*/React.createElement("h2", null, "What we commit to"), /*#__PURE__*/React.createElement("p", null, "The mission for Ecomm is to keep transaction fees and retail-media fees as low as they can possibly go. While we're still in beta, here is what we're committing to:"), /*#__PURE__*/React.createElement("ul", null, /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Retail media, free."), " Earned through platform engagement rather than paid for."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "Marketplace storefronts: low or no transaction fees."), " Selling in the shared marketplace costs little or nothing."), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("b", null, "White-label storefronts: low transaction fees."), " Your own branded storefront stays low-cost to run.")), /*#__PURE__*/React.createElement("p", null, /*#__PURE__*/React.createElement(Button, {
variant: "ghost",
href: "#/finances"
}, "See the open book \u2192")))));
}
/* ---------- Finances (open book) ---------- */
function FinancesScreen() {
const lines = [{
item: 'Google Cloud (GCP)',
for: 'Servers, databases, and the infrastructure the platform runs on — on startup credits today',
current: 32,
ant: 75,
antFrom: 'July 2026'
}, {
item: 'Google Workspace',
for: 'Email and documents — one seat',
current: 7,
ant: 14,
antFrom: 'Sept 2026'
}, {
item: 'Anthropic',
for: 'Claude — one Max plan, the AI we build alongside every day',
current: 100,
ant: 100,
antFrom: null
}];
const sumCur = lines.reduce((a, l) => a + l.current, 0);
const sumAnt = lines.reduce((a, l) => a + l.ant, 0);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("section", {
className: "page-hero"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap"
}, /*#__PURE__*/React.createElement(Eyebrow, null, "Radical transparency \xB7 open book"), /*#__PURE__*/React.createElement("h1", null, "Open book"), /*#__PURE__*/React.createElement(Soul, {
size: "md",
as: "p",
className: "page-hero__soul"
}, "We show you the receipts."), /*#__PURE__*/React.createElement("p", {
className: "lead"
}, "We take only what it takes to run this organization and the platform \u2014 and the only way to mean that is to show our work. Here is what it actually costs to run Wiggleverse, line by line."))), /*#__PURE__*/React.createElement("section", {
className: "section"
}, /*#__PURE__*/React.createElement("div", {
className: "wrap prose"
}, /*#__PURE__*/React.createElement("h2", null, "What it costs to run Wiggleverse"), /*#__PURE__*/React.createElement("p", {
className: "ledger-stamp"
}, "Last updated 8 June 2026 \xB7 hand-kept estimates, refreshed monthly for now."), /*#__PURE__*/React.createElement("table", {
className: "ledger"
}, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("th", {
scope: "col"
}, "Line item"), /*#__PURE__*/React.createElement("th", {
scope: "col"
}, "What it's for"), /*#__PURE__*/React.createElement("th", {
scope: "col",
className: "num"
}, "Current / mo"), /*#__PURE__*/React.createElement("th", {
scope: "col",
className: "num"
}, "Anticipated / mo"))), /*#__PURE__*/React.createElement("tbody", null, lines.map(l => /*#__PURE__*/React.createElement("tr", {
key: l.item
}, /*#__PURE__*/React.createElement("td", null, l.item), /*#__PURE__*/React.createElement("td", null, l.for), /*#__PURE__*/React.createElement("td", {
className: "num"
}, "$", l.current), /*#__PURE__*/React.createElement("td", {
className: "num"
}, "$", l.ant, l.antFrom && /*#__PURE__*/React.createElement("span", {
className: "when"
}, "from ", l.antFrom))))), /*#__PURE__*/React.createElement("tfoot", null, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("td", null, "Total"), /*#__PURE__*/React.createElement("td", null, "per month, plus tax"), /*#__PURE__*/React.createElement("td", {
className: "num"
}, "$", sumCur), /*#__PURE__*/React.createElement("td", {
className: "num"
}, "$", sumAnt, /*#__PURE__*/React.createElement("span", {
className: "when"
}, "by Sept 2026"))))), /*#__PURE__*/React.createElement("p", null, "Revenue today is ", /*#__PURE__*/React.createElement("strong", null, "$0"), " \u2014 we haven't launched commerce yet. Covering these costs ourselves is the whole point of ", /*#__PURE__*/React.createElement("a", {
href: "#/ecomm"
}, "building commerce first"), "."), /*#__PURE__*/React.createElement(Callout, null, "Ethics that cannot be checked are just claims."))));
}
const SCREENS = {
'#/': HomeScreen,
'#/about': AboutScreen,
'#/ecomm': EcommScreen,
'#/finances': FinancesScreen
};
function App() {
const [route, setRoute] = React.useState(window.location.hash || '#/');
React.useEffect(() => {
const onHash = () => {
setRoute(window.location.hash || '#/');
window.scrollTo(0, 0);
};
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
const Screen = SCREENS[route] || HomeScreen;
const current = route === '#/about' ? 'About' : route === '#/ecomm' ? 'Build' : route === '#/finances' ? 'Learn' : undefined;
return /*#__PURE__*/React.createElement("div", {
className: "wv-app"
}, /*#__PURE__*/React.createElement(SiteHeader, {
links: NAV,
current: current,
assetBase: ASSET_BASE,
homeHref: "#/"
}), /*#__PURE__*/React.createElement("main", null, /*#__PURE__*/React.createElement(Screen, null)), /*#__PURE__*/React.createElement(SiteFooter, {
columns: FOOTER_COLS,
assetBase: ASSET_BASE
}));
}
ReactDOM.createRoot(document.getElementById('root')).render(/*#__PURE__*/React.createElement(App, null));
})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/wiggleverse-www/app.jsx", error: String((e && e.message) || e) }); }
__ds_ns.BrandLockup = __ds_scope.BrandLockup;
__ds_ns.BuildCard = __ds_scope.BuildCard;
__ds_ns.PathCard = __ds_scope.PathCard;
__ds_ns.Button = __ds_scope.Button;
__ds_ns.Callout = __ds_scope.Callout;
__ds_ns.Eyebrow = __ds_scope.Eyebrow;
__ds_ns.Notice = __ds_scope.Notice;
__ds_ns.Soul = __ds_scope.Soul;
__ds_ns.Tag = __ds_scope.Tag;
__ds_ns.SiteFooter = __ds_scope.SiteFooter;
__ds_ns.SiteHeader = __ds_scope.SiteHeader;
})();
@@ -0,0 +1,10 @@
/* Space Grotesk — machine register: headings, wordmark */
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:500;font-display:swap;src:url('./space-grotesk-v22-latin-500.woff2') format('woff2');}
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:700;font-display:swap;src:url('./space-grotesk-v22-latin-700.woff2') format('woff2');}
/* Inter — machine register: body / UI */
@font-face{font-family:'Inter';font-style:normal;font-weight:400;font-display:swap;src:url('./inter-v20-latin-regular.woff2') format('woff2');}
@font-face{font-family:'Inter';font-style:normal;font-weight:500;font-display:swap;src:url('./inter-v20-latin-500.woff2') format('woff2');}
@font-face{font-family:'Inter';font-style:normal;font-weight:600;font-display:swap;src:url('./inter-v20-latin-600.woff2') format('woff2');}
/* Fraunces — human register: pull-quotes (italic only) */
@font-face{font-family:'Fraunces';font-style:italic;font-weight:400;font-display:swap;src:url('./fraunces-v38-latin-italic.woff2') format('woff2');}
@font-face{font-family:'Fraunces';font-style:italic;font-weight:500;font-display:swap;src:url('./fraunces-v38-latin-500italic.woff2') format('woff2');}
@@ -0,0 +1,170 @@
# Wiggleverse Design System
The core design system for **Wiggleverse.org** — a Washington 501(c)(3)
nonprofit building art and software for a world at peace, on one shared set of
ethics. Its first product is *Ecomm* (Shopify-class commerce at near-zero cost);
the portfolio also names *Apps* and *Learn*, all built on an **Open Core**.
> *Treat humans as humans — everything else follows.*
This system captures the brand exactly as it ships on the marketing site so that
agents and designers can produce on-brand interfaces, decks, and assets.
## Sources
Built from the live marketing-site codebase, the source of truth:
- **Codebase:** `wiggleverse-www/` — static site (plain HTML/CSS/JS, no build
step), deployed to Cloudflare Pages. Brand tokens live in
`wiggleverse-www/assets/tokens.css`; the design system CSS is
`wiggleverse-www/styles.css`.
- **Brand source of truth (referenced, not attached):** `wiggleverse-org/corp`
`brand/BRAND.md` (§510). The site's `assets/` kit is a vendored copy. The
site design spec is `corp/docs/superpowers/specs/2026-06-04-wiggleverse-www-design.md`.
- **Fonts** are the real vendored woff2 files (Space Grotesk, Inter, Fraunces),
copied into `assets/fonts/`. No substitutions were made.
---
## Content fundamentals
How Wiggleverse writes. The voice is **two registers passing through one
review** — the same idea the product is built on.
- **Machine register (default).** Precise, structural, plain-spoken. Most copy
lives here: headings, UI, body prose. Calm and declarative, never hypey.
*"We take only what it takes to run."*
- **Human register ("the soul").** Warm, literary, used **sparingly** for the
lines that carry conscience — taglines and pull-quotes only. Always set in
**Fraunces italic**. *"We are verbs, not nouns." · "Ethics that cannot be
checked are just claims."*
Specifics:
- **Person.** Mostly **we / our** (the org speaking plainly and accountably).
Slips to **first-person singular** for the founder's voice in the About essay
("I felt that one firsthand… — Ben Stull, Founder"). Addresses the reader as
**you** in product/partner copy.
- **Tone.** Honest, humble, anti-extractive, quietly ambitious. Leads with
*what is enough?* rather than *how much can we get?* Admits limits openly
("hand-kept estimates, refreshed monthly for now").
- **Casing.** Sentence case everywhere — headings, buttons, nav. **Eyebrows**
and small labels are UPPERCASE with wide tracking. Product names are casual
and lowercase-ish in prose ("ecomm"), Title Case as proper nouns ("Ecomm").
- **Punctuation.** Em dashes for asides. Arrows (`→`) end calls to action
("Why Wiggleverse →"). Italic emphasis on the load-bearing question
(*what is enough?*). Bold for the load-bearing noun phrases in a list.
- **No emoji.** None, anywhere. Status and emphasis come from type and a small
set of geometric glyphs (see Iconography).
- **Recurring phrases.** "Treat humans as humans." · "We take only what it takes
to run." · "Radical transparency / we show you the receipts." · "Build the
dictionary first." · "If you're learning, you're succeeding." · "We are verbs,
not nouns." · the dedication to Aaron Swartz in every footer.
- **The vibe.** A standards document (RFC/IETF) with a conscience — exact where
it matters, tender where it counts.
---
## Visual foundations
The whole system answers to one motif: a **Circle of Equals** — one living,
wiggling line through six equal points, closed into a ring, with a **deliberately
empty center** ("no center, no hub, no sun"). The connecting line *is* the ethic.
- **Ground.** **Dark "sky" is primary** (`--wv-midnight #0E1230`). Long-reading
passages flip to **Paper** (`--wv-paper #F6F4FB`) sections. Most of the system
lives on dark.
- **Color.** Indigo (`#1C2150`) for raised surfaces; **lilac** (`#9B8CFF`) is the
accent — links, nodes, "the bonds between us"; **violet** (`#7C6FE0`) is its
on-paper counterpart; **gold** (`#F4C76B`) is warmth / horizon / the single
primary CTA color; **starlight** (`#EDEAFF`) is text on dark; **ink**
(`#3B2F7A`) is text on paper. A near-black **night** (`#090C22`) grounds the
footer.
- **Type.** Two machine faces + one human face. **Space Grotesk** 500/700 for
display & wordmark (tracked in 0.015em). **Inter** 400/500/600 for body & UI.
**Fraunces italic** 400/500 for the soul — *italic only, never upright, never
body copy.* Headings use fluid `clamp()` sizes.
- **Backgrounds.** No photography in the system. The hero uses a **scattered
starfield** (faint multi-radial-gradient nodes, intentionally off-center) and a
large, very low-opacity (≈0.14) mark bleeding off the right edge. Otherwise:
flat color fields. No big gradients as surfaces (the only gradient is *inside*
the mark, lilac→gold).
- **Borders & cards.** Depth is carried by **surface color + hairline borders**,
not shadows. Cards are indigo (on dark) or white (on paper) with a 1px
lilac/violet hairline at low alpha, **14px** radius. The system is
**near-shadowless** — a soft shadow token exists but is rarely used.
- **Radii.** 14px cards, 12px panels, 4px focus, **999px pills** (buttons, tags,
notices), 26px app-tile.
- **Animation.** Restrained. Transitions ≈ `.15s ease`. Hover = **translateY
lift** (1px buttons, 3px cards), *not* a shadow or scale. Mobile nav reveals
via `max-height .25s`. Everything is killed under
`prefers-reduced-motion: reduce`. No bounces, no loops, no parallax.
- **Hover / press.** Links underline (3px offset, lilac). Buttons lift and
lighten (gold → `#F7D488`). Cards lift and warm their border to lilac, surface
to `#232A63`. No color-darkening press states; no shrink.
- **Transparency & blur.** The **sticky header** is the one glass surface:
`rgba(14,18,48,.82)` + `blur(10px)` + a lilac hairline. Alpha lilac/gold/
starlight washes (`.08``.40`) do tints, dividers, and focus rings.
- **Focus.** Always visible: **3px gold outline**, 2px offset, 4px rounding.
Accessibility is load-bearing (skip links, `aria-current`, reduced-motion).
- **Imagery vibe.** Cool, dark, cosmic, calm — starlight on midnight, warmed by a
single gold horizon. Never busy, never loud.
- **Layout.** Content column is `min(1080px, 92vw)`, gutter-aligned (not
auto-centered text). Prose blocks cap at a 68ch measure. Sections breathe with
fluid `clamp(3rem, 7vw, 5.5rem)` vertical padding.
---
## Iconography
Wiggleverse is **iconography-light by design**. There is **no icon font and no
icon set** in the codebase.
- **The mark** is the one true graphic: the *Circle of Equals*, shipped as SVG in
four cuts — `wiggleverse-mark.svg` (lilac→gold gradient line, starlight nodes),
`mark-mono-gold.svg` (footer), `mark-on-light.svg` (ink on paper), and
`favicon.svg` (gold line + starlight nodes on a rounded-26px midnight tile).
Raster favicons (`favicon-32.png`, `favicon-180.png`) are provided for tabs and
iOS. All are in `assets/`.
- **"Icons" are geometric unicode glyphs.** The audience router uses
**✧ ◇ ◈ ❍ ◐** as quiet, abstract door markers — never pictographic icons,
never emoji. Treat these as the sanctioned glyph set when a small mark is
needed. Arrows are literal `→` characters in link/button text.
- **No emoji, ever.** (Reconfirmed under Content fundamentals.)
- **If you need UI icons** (e.g. a richer product surface that the marketing site
doesn't cover), there is no house set to match — keep them to a thin,
geometric, single-weight line style consistent with the mark, and **flag the
addition** so it can be folded into the brand properly. Do not hand-draw new
brand illustrations.
---
## Index / manifest
Root files:
- `styles.css` — the global entry point (consumers link this one file). `@import`
manifest only.
- `tokens/colors.css` · `tokens/typography.css` · `tokens/spacing.css` — CSS
custom properties (base values + semantic aliases).
- `assets/` — the Circle-of-Equals marks (4 cuts), favicons, and the three
vendored webfont families in `assets/fonts/` (+ `fonts.css` `@font-face`).
- `guidelines/` — foundation specimen cards (Colors, Type, Spacing, Brand).
- `SKILL.md` — Agent-Skills-compatible entry point.
Components (`window.WiggleverseDesignSystem_*`):
- **core/** — `Button`, `Tag`, `Notice`, `Eyebrow`, `Soul`, `Callout`
- **cards/** — `PathCard`, `BuildCard`
- **brand/** — `BrandLockup`
- **navigation/** — `SiteHeader`, `SiteFooter`
UI kits:
- **ui_kits/wiggleverse-www/** — high-fidelity recreation of the marketing site
(Home, About, Ecomm, Finances/open-book), composing the components above.
Each component directory carries `<Name>.jsx`, `<Name>.d.ts`, `<Name>.prompt.md`,
and one `@dsCard` HTML thumbnail. Mount components in card/kit HTML via
`const { X } = window.WiggleverseDesignSystem_94cd80` after loading
`_ds_bundle.js` (generated automatically — do not edit).
@@ -0,0 +1,8 @@
/* Wiggleverse Design System — global entry point.
Consumers link THIS one file. It is an @import manifest only — no rules here.
Everything reachable from here ships to consumers (tokens + @font-face webfonts). */
@import url("./assets/fonts/fonts.css"); /* Space Grotesk · Inter · Fraunces (@font-face) */
@import url("./tokens/colors.css"); /* palette + semantic color aliases */
@import url("./tokens/typography.css"); /* families, scale, weights, tracking */
@import url("./tokens/spacing.css"); /* spacing, radii, borders, motion, layout */
@@ -0,0 +1,60 @@
/* Wiggleverse — Color tokens
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §89).
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
:root {
/* ---- Brand palette (base values) ---- */
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
--wv-gold-hi: #F7D488; /* gold hover */
--wv-starlight:#EDEAFF; /* nodes / text on dark */
--wv-paper: #F6F4FB; /* light-mode background */
--wv-ink: #3B2F7A; /* text on light */
--wv-night: #090C22; /* footer / deepest ground */
/* CTA text-on-gold (very dark gold-brown, not pure black) */
--wv-gold-ink: #2A2003;
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
--wv-lilac-08: rgba(155, 140, 255, .08);
--wv-lilac-12: rgba(155, 140, 255, .12);
--wv-lilac-16: rgba(155, 140, 255, .16);
--wv-lilac-18: rgba(155, 140, 255, .18);
--wv-lilac-32: rgba(155, 140, 255, .32);
--wv-gold-28: rgba(244, 199, 107, .28);
--wv-gold-40: rgba(244, 199, 107, .40);
--wv-starlight-85: rgba(237, 234, 255, .85);
--wv-starlight-78: rgba(237, 234, 255, .78);
--wv-starlight-60: rgba(237, 234, 255, .60);
--wv-starlight-55: rgba(237, 234, 255, .55);
/* ---- Semantic aliases ---- */
--surface-sky: var(--wv-midnight); /* page ground (dark) */
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
--surface-paper: var(--wv-paper); /* long-reading light sections */
--surface-card-light:#FFFFFF; /* build-cards on paper */
--surface-footer: var(--wv-night);
--text-on-dark: var(--wv-starlight);
--text-on-dark-soft: var(--wv-starlight-78);
--text-on-dark-mute: var(--wv-starlight-60);
--text-on-light: var(--wv-ink);
--text-on-light-soft:#4B4170;
--accent: var(--wv-lilac); /* links + nodes on dark */
--accent-on-light: var(--wv-violet); /* links + strokes on light */
--cta: var(--wv-gold); /* primary action / horizon */
--cta-hover: var(--wv-gold-hi);
--cta-text: var(--wv-gold-ink);
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
--border-card: var(--wv-lilac-18);
--border-strong: var(--wv-lilac-32);
--focus-ring: var(--wv-gold);
}
@@ -0,0 +1,55 @@
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
Derived from the marketing-site CSS. The brand has almost no shadow system —
depth is carried by surface color + hairline borders, not drop shadows. */
:root {
/* ---- Spacing scale (rem) ---- */
--space-0: 0;
--space-1: .25rem;
--space-2: .5rem;
--space-3: .7rem;
--space-4: 1rem;
--space-5: 1.2rem;
--space-6: 1.6rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Section rhythm — fluid vertical padding for page bands */
--section-pad: clamp(3rem, 7vw, 5.5rem);
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
/* ---- Layout ---- */
--wrap-max: 1080px; /* content column */
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
--measure-prose: 68ch; /* reading measure for prose blocks */
/* ---- Radii ---- */
--radius-card: 14px; /* path-cards, build-cards */
--radius-panel: 12px; /* principle tiles */
--radius-sm: 4px; /* focus ring rounding */
--radius-pill: 999px; /* buttons, tags, notices */
--radius-mark: 26px; /* favicon tile rounding */
/* ---- Borders ---- */
--border-hair: 1px; /* default hairline */
--border-card-w: 1px;
--btn-border-w: 1.5px; /* ghost button / focus weight */
/* ---- Elevation ---- *
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
* not a shadow. These tokens exist for the rare card that needs real elevation. */
--shadow-none: none;
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
--lift-3: translateY(-3px); /* @kind other */ /* cards */
/* ---- Glass (sticky header) ---- */
--glass-sky: rgba(14, 18, 48, .82);
--glass-blur: 10px;
/* ---- Motion ---- */
--ease: ease; /* @kind other */
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
}
@@ -0,0 +1,53 @@
/* Wiggleverse — Typography tokens
Two registers, one meaning (BRAND.md):
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
for the lines that carry conscience ("the soul").
@font-face rules live in assets/fonts/fonts.css. */
:root {
/* ---- Families ---- */
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
/* semantic aliases */
--font-display: var(--wv-font-display);
--font-body: var(--wv-font-body);
--font-soul: var(--wv-font-human);
/* ---- Weights ---- */
--weight-regular: 400; /* Inter body */
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
--weight-semibold:600; /* Inter emphasis, ledger totals */
--weight-bold: 700; /* Space Grotesk headings, wordmark */
--weight-soul: 500; /* Fraunces italic pull-quotes */
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
--text-h3: 1.2rem;
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
/* ---- Body / UI scale ---- */
--text-body: 1rem; /* 16px base */
--text-small: .95rem;
--text-fine: .92rem;
--text-eyebrow: .8rem; /* uppercase label */
--text-tag: .72rem; /* pill tags */
/* ---- Line heights ---- */
--leading-tight: 1.12; /* headings */
--leading-body: 1.6; /* paragraphs */
/* ---- Letter spacing ---- */
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
--tracking-tag: 0.08em;
--tracking-notice: 0.06em;
/* ---- Measure ---- */
--measure-lead: 60ch;
--measure-prose:68ch;
}
@@ -0,0 +1,170 @@
/* app.jsx — wires the state machine, Tweaks, and mounts. */
/* Pull every cross-file dependency off window with `var` (safe under both the
shared-global and isolated babel-script models — no redeclaration error). */
var { AdminShell, ProductsPage, UploadScreen, PreviewScreen, RunDetailScreen } = window;
var { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakButton, useTweaks } = window;
const { useState: _aState, useEffect: _aEffect, useRef: _aRef } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"catalog": "populated",
"density": "comfortable",
"scenario": "canonical"
}/*EDITMODE-END*/;
/* ---- synthesize image problems / errors for historical runs ---- */
function synthProblems(rejected, failed) {
const out = [];
const handles = ['oxford-button-down', 'weekender-duffel', 'camp-mug-enamel', 'aspen-knit-sweater', 'lantern-flannel', 'river-stone-belt', 'cedar-beanie', 'driftwood-henley'];
for (let i = 0; i < rejected; i++) out.push({ handle: handles[i % handles.length], variant: i % 2 ? 'M / Slate' : '—', url: `https://cdn.northfield.example/${handles[i % handles.length]}-${i}.jpg`, outcome: 'rejected', reason: 'Below the resolution bar — needs at least 800 × 800.' });
for (let i = 0; i < failed; i++) out.push({ handle: handles[(i + rejected) % handles.length], variant: '—', url: `https://oldhost.example/${handles[(i + rejected) % handles.length]}.jpg`, outcome: 'failed', reason: 'Unreachable — host timed out.' });
return out;
}
function synthErrors(n) {
const base = [
{ line: 88, column: 'Type', title: 'Gift Card', message: "Shopify 'Gift Card' products aren't catalog products here — skipped." },
{ line: 1042, column: 'Type', title: 'Essentials Bundle', message: 'Looks like a bundle — kits arrive in a coming release.' },
{ line: 1188, column: 'Variant Price', title: 'Trail Mug', message: "'9,00' is not a price — use a dot for decimals." },
{ line: 1455, column: 'Handle', title: '(row 1455)', message: 'Empty Handle — every row needs one.' },
];
return base.slice(0, n);
}
function buildRunView(run, data) {
if (run.id === 'run-0007') {
return { scenario: data.SCENARIOS.canonical, images: data.IMAGE_OUTCOMES, run };
}
const isShopify = run.dialect.includes('Shopify');
const scenario = {
file: run.file,
dialect: run.dialect + (isShopify ? ' — mapped to canonical' : ' format'),
dialectKind: isShopify ? 'shopify' : 'canonical',
summary: { add: run.added, update: run.updated, unchanged: 0, error: run.errors },
records: { error: synthErrors(run.errors), add: [], update: [], unchanged: [] },
extra: {},
unknownColumns: [],
};
const total = run.images.fetched + run.images.rejected + run.images.failed;
const images = { total, fetched: run.images.fetched, problems: synthProblems(run.images.rejected, run.images.failed) };
return { scenario, images, run };
}
function Toast({ toast }) {
if (!toast) return null;
const color = toast.kind === 'success' ? 'var(--st-add)' : 'var(--wv-violet)';
return (
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', zIndex: 9000,
display: 'flex', alignItems: 'center', gap: '.55rem', padding: '.7rem 1.2rem',
background: 'var(--wv-midnight)', color: 'var(--wv-starlight)', borderRadius: 999,
border: '1px solid var(--wv-lilac-32)', boxShadow: '0 10px 30px rgba(9,12,34,.4)',
fontSize: '.9rem', fontFamily: 'var(--font-body)', animation: 'ie-toast .25s ease',
}}>
<span style={{ width: 8, height: 8, borderRadius: 999, background: color }} />
{toast.msg}
</div>
);
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [screen, setScreen] = _aState('products');
const [view, setView] = _aState(null);
const [toast, setToast] = _aState(null);
const toastTimer = _aRef(null);
const data = window.IEData;
const catalogEmpty = t.catalog === 'empty';
_aEffect(() => { document.documentElement.dataset.density = t.density; }, [t.density]);
const showToast = (msg, kind) => {
setToast({ msg, kind, id: Date.now() });
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2600);
};
const getImages = (scenario) => {
const m = {
canonical: data.IMAGE_OUTCOMES,
shopify: { total: 519, fetched: 511, problems: synthProblems(6, 2) },
roundtrip: { total: 0, fetched: 0, problems: [] },
};
return m[scenario.key] || { total: 0, fetched: 0, problems: [] };
};
const goProducts = () => { setScreen('products'); setView(null); };
const goImport = () => setScreen('upload');
const onValidated = (key) => { setView({ scenario: data.SCENARIOS[key] }); setScreen('preview'); };
const onConfirm = (scenario) => {
setView({ scenario, images: getImages(scenario), run: null, live: true });
setScreen('rundetail');
if (catalogEmpty) setTweak('catalog', 'populated');
};
const onOpenRun = (runId) => {
const run = data.HISTORY.find((r) => r.id === runId);
if (!run) return;
setView({ ...buildRunView(run, data), live: false });
setScreen('rundetail');
};
// tweak jump helpers
const jumpPreview = () => { setView({ scenario: data.SCENARIOS[t.scenario] }); setScreen('preview'); };
const jumpRun = () => { const sc = data.SCENARIOS[t.scenario === 'roundtrip' ? 'canonical' : t.scenario]; onConfirm(sc); };
let body;
if (screen === 'products') {
body = <ProductsPage data={data} catalogEmpty={catalogEmpty} onImport={goImport} onOpenRun={onOpenRun} toast={showToast} />;
} else if (screen === 'upload') {
body = <UploadScreen onBack={goProducts} onValidated={onValidated} toast={showToast} />;
} else if (screen === 'preview' && view) {
body = <PreviewScreen scenario={view.scenario} onCancel={goProducts} onConfirm={onConfirm} />;
} else if (screen === 'rundetail' && view) {
body = <RunDetailScreen run={view.run} scenario={view.scenario} images={view.images} onBack={goProducts} live={view.live} />;
} else {
body = <ProductsPage data={data} catalogEmpty={catalogEmpty} onImport={goImport} onOpenRun={onOpenRun} toast={showToast} />;
}
return (
<React.Fragment>
<AdminShell current="products" onNav={() => goProducts()} storefront={data.STOREFRONT}>
{body}
</AdminShell>
<Toast toast={toast} />
<TweaksPanel>
<TweakSection label="Prototype state" />
<TweakRadio label="Catalog" value={t.catalog} options={['populated', 'empty']} onChange={(v) => { setTweak('catalog', v); setScreen('products'); setView(null); }} />
<TweakRadio label="Density" value={t.density} options={['comfortable', 'compact']} onChange={(v) => setTweak('density', v)} />
<TweakSection label="Jump to a step" />
<TweakSelect label="Import file" value={t.scenario}
options={[['canonical', 'Canonical — seasonal pricing'], ['shopify', 'Shopify export — mapped'], ['roundtrip', 'Round-trip — no-op']].map(([v, l]) => ({ value: v, label: l }))}
onChange={(v) => setTweak('scenario', v)} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '.4rem', marginTop: '.2rem' }}>
<TweakButton label="Products" onClick={goProducts} />
<TweakButton label="Upload" onClick={goImport} />
<TweakButton label="Preview" onClick={jumpPreview} />
<TweakButton label="Run detail" onClick={jumpRun} />
</div>
</TweaksPanel>
</React.Fragment>
);
}
/* Mount once everything is on window (guards against script-eval ordering). */
function ieMount() {
const ready = window.AdminShell && window.ProductsPage && window.UploadScreen &&
window.PreviewScreen && window.RunDetailScreen && window.TweaksPanel &&
window.TweakSection && window.TweakRadio && window.TweakSelect && window.TweakButton &&
window.useTweaks && window.IEData;
if (!ready) { setTimeout(ieMount, 20); return; }
// refresh local bindings in case they were read before the producer ran
AdminShell = window.AdminShell; ProductsPage = window.ProductsPage;
UploadScreen = window.UploadScreen; PreviewScreen = window.PreviewScreen;
RunDetailScreen = window.RunDetailScreen; TweaksPanel = window.TweaksPanel;
TweakSection = window.TweakSection; TweakRadio = window.TweakRadio;
TweakSelect = window.TweakSelect; TweakButton = window.TweakButton; useTweaks = window.useTweaks;
if (!window.__ieRoot) window.__ieRoot = ReactDOM.createRoot(document.getElementById('ie-root'));
window.__ieRoot.render(<App />);
}
ieMount();
@@ -0,0 +1,435 @@
/* data.jsx — sample catalog + import scenarios for the bulk CSV import/export prototype.
A boutique apparel store, "Wander & Wool". Numbers chosen so every count is backed
by a concrete row the merchant can drill into. Exported to window.IEData. */
const STOREFRONT = {
name: 'Wander & Wool',
handle: 'wander-and-wool',
email: 'mara@wanderandwool.com',
productCount: 412,
imageProblemCount: 9,
latestRunId: 'run-0007',
};
/* ---- Import history (newest first). Statuses mirror §5.2 ---- */
const HISTORY = [
{
id: 'run-0007', date: '2026-06-11T12:40:00', rel: 'just now',
file: 'spring-2026-pricing.csv', dialect: 'Canonical',
added: 8, updated: 14, errors: 3,
images: { fetched: 18, rejected: 2, failed: 1 },
status: 'complete_with_problems',
},
{
id: 'run-0006', date: '2026-05-02T09:12:00', rel: '5 weeks ago',
file: 'shopify-store-export.csv', dialect: 'Shopify product CSV',
added: 286, updated: 0, errors: 4,
images: { fetched: 511, rejected: 6, failed: 2 },
status: 'complete_with_problems',
},
{
id: 'run-0005', date: '2026-04-18T16:30:00', rel: '8 weeks ago',
file: 'restock-april.csv', dialect: 'Canonical',
added: 0, updated: 63, errors: 0,
images: { fetched: 0, rejected: 0, failed: 0 },
status: 'complete',
},
{
id: 'run-0004', date: '2026-03-30T11:05:00', rel: '10 weeks ago',
file: 'initial-catalog.csv', dialect: 'Canonical',
added: 118, updated: 0, errors: 0,
images: { fetched: 402, rejected: 0, failed: 0 },
status: 'complete',
},
];
/* =====================================================================
SCENARIO A — canonical seasonal pricing file (the default happy path).
8 to add · 14 to update · 26 unchanged · 3 errors. (48 product rows.)
===================================================================== */
const A_ADDS = [
{
handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'add', variants: 8,
detail: {
product: [
['Title', 'Alpine Fleece Pullover'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'outerwear, fleece, spring-26'],
['Status', 'active'],
['Options', 'Size (S·M·L·XL) × Color (Heather Grey · Forest)'],
],
variantsPreview: [
['S / Heather Grey', 'SKU AFP-S-HG', '$88.00'],
['M / Heather Grey', 'SKU AFP-M-HG', '$88.00'],
['L / Forest', 'SKU AFP-L-FO', '$88.00'],
],
images: 2,
},
},
{
handle: 'coastal-linen-shirt', title: 'Coastal Linen Shirt', kind: 'add', variants: 6,
detail: {
product: [
['Title', 'Coastal Linen Shirt'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'shirts, linen, spring-26'],
['Status', 'active'],
['Options', 'Size (S·M·L) × Color (Sand · Sky)'],
],
variantsPreview: [
['S / Sand', 'SKU CLS-S-SA', '$72.00'],
['M / Sky', 'SKU CLS-M-SK', '$72.00'],
],
images: 3,
},
},
{
handle: 'trailhead-cap', title: 'Trailhead Cap', kind: 'add', variants: 3,
detail: {
product: [
['Title', 'Trailhead Cap'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'accessories, hats, spring-26'],
['Status', 'active'],
['Options', 'Color (Black · Olive · Rust)'],
],
variantsPreview: [
['Black', 'SKU THC-BK', '$34.00'],
['Olive', 'SKU THC-OL', '$34.00'],
['Rust', 'SKU THC-RU', '$34.00'],
],
images: 1,
},
},
{
handle: 'merino-base-tee', title: 'Merino Base Tee', kind: 'add', variants: 8,
detail: {
product: [
['Title', 'Merino Base Tee'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'base-layer, merino, spring-26'],
['Status', 'active'],
['Options', 'Size (XS·S·M·L) × Color (Charcoal · Bone)'],
],
variantsPreview: [
['XS / Charcoal', 'SKU MBT-XS-CH', '$58.00'],
['M / Bone', 'SKU MBT-M-BO', '$58.00'],
],
images: 2,
},
},
{
handle: 'dune-chino-short', title: 'Dune Chino Short', kind: 'add', variants: 5,
detail: {
product: [
['Title', 'Dune Chino Short'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'shorts, spring-26'],
['Status', 'draft'],
['Options', 'Size (28·30·32·34·36)'],
],
variantsPreview: [
['28', 'SKU DCS-28', '$64.00'],
['34', 'SKU DCS-34', '$64.00'],
],
images: 1,
},
},
{
handle: 'harbor-tote', title: 'Harbor Canvas Tote', kind: 'add', variants: 1,
detail: {
product: [
['Title', 'Harbor Canvas Tote'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'bags, accessories, spring-26'],
['Status', 'active'],
['Options', '— (single variant)'],
],
variantsPreview: [
['Default', 'SKU HCT-01', '$48.00'],
],
images: 2,
},
},
{
handle: 'cirrus-rain-shell', title: 'Cirrus Rain Shell', kind: 'add', variants: 6,
detail: {
product: [
['Title', 'Cirrus Rain Shell'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'outerwear, rain, spring-26'],
['Status', 'active'],
['Options', 'Size (S·M·L) × Color (Slate · Marigold)'],
],
variantsPreview: [
['S / Slate', 'SKU CRS-S-SL', '$186.00'],
['L / Marigold', 'SKU CRS-L-MA', '$186.00'],
],
images: 3,
},
},
{
handle: 'wildflower-scarf', title: 'Wildflower Wool Scarf', kind: 'add', variants: 2,
detail: {
product: [
['Title', 'Wildflower Wool Scarf'],
['Vendor', 'Wander & Wool'],
['Type', 'standalone'],
['Tags', 'accessories, wool, spring-26'],
['Status', 'active'],
['Options', 'Color (Meadow · Dusk)'],
],
variantsPreview: [
['Meadow', 'SKU WWS-ME', '$42.00'],
['Dusk', 'SKU WWS-DU', '$42.00'],
],
images: 1,
},
},
];
const A_UPDATES = [
{
handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'update', variants: 6,
detail: {
changes: [
{ field: 'Variant Price', scope: 'all 6 variants', before: '$118.00', after: '$98.00' },
{ field: 'Tags', scope: 'product', before: 'dresses, summer', after: 'dresses, summer, sale' },
{ field: 'Status', scope: 'product', before: 'active', after: 'active', noop: true },
],
},
},
{
handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'update', variants: 9,
detail: {
changes: [
{ field: 'Variant Price', scope: 'all 9 variants', before: '$240.00', after: '$264.00' },
{ field: 'Variant Cost', scope: 'all 9 variants', before: '$96.00', after: '$112.00' },
{ field: 'Variant Inventory Qty', scope: 'S / Slate', before: '4', after: '22' },
],
},
},
{
handle: 'field-canvas-pant', title: 'Field Canvas Pant', kind: 'update', variants: 10,
detail: {
changes: [
{ field: 'Variant Price', scope: 'all 10 variants', before: '$92.00', after: '$84.00' },
{ field: 'Description', scope: 'product', before: 'Durable canvas work pant.', after: 'Durable organic-canvas work pant, garment-dyed.' },
],
},
},
{
handle: 'aspen-knit-sweater', title: 'Aspen Knit Sweater', kind: 'update', variants: 8,
detail: {
changes: [
{ field: 'Variant Price', scope: 'all 8 variants', before: '$134.00', after: '$118.00' },
{ field: 'Status', scope: 'product', before: 'draft', after: 'active' },
{ field: 'Tags', scope: 'product', before: 'knitwear, fall', after: 'knitwear, fall, spring-26' },
],
},
},
{
handle: 'river-stone-belt', title: 'River Stone Belt', kind: 'update', variants: 4,
detail: {
changes: [
{ field: 'Variant Cost', scope: 'all 4 variants', before: '$11.00', after: '$14.50' },
{ field: 'Variant Barcode', scope: 'M / Tan', before: '— (empty)', after: '7 290 1148 22 901' },
],
},
},
{
handle: 'lantern-flannel', title: 'Lantern Flannel', kind: 'update', variants: 12,
detail: {
changes: [
{ field: 'Variant Price', scope: 'all 12 variants', before: '$78.00', after: '$68.00' },
{ field: 'Tags', scope: 'product', before: 'shirts, flannel', after: 'shirts, flannel, sale' },
],
},
},
];
const A_UPDATES_EXTRA = 8; // 14 total updates; 6 shown above, 8 more collapsed into the count
const A_UNCHANGED = [
{ handle: 'sandpiper-sock-3pk', title: 'Sandpiper Sock 3-Pack', kind: 'unchanged', variants: 3 },
{ handle: 'cedar-beanie', title: 'Cedar Ribbed Beanie', kind: 'unchanged', variants: 4 },
{ handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
];
const A_UNCHANGED_EXTRA = 23; // 26 total
const A_ERRORS = [
{
handle: 'gardenia-sundress', title: 'Gardenia Sundress', kind: 'error', variants: 5,
line: 214, column: 'Variant Price',
message: "'12,50' is not a price — use a dot for decimals (12.50), no thousands separators.",
},
{
handle: 'pathfinder-vest', title: 'Pathfinder Vest', kind: 'error', variants: 6,
line: 327, column: 'Option1 Value',
message: "Variant has value 'Medium' but the product never declares 'Option1 Name'. Add the option name, or remove the value.",
},
{
handle: 'trail-kit-starter', title: 'Trail Starter Kit', kind: 'error', variants: 1,
line: 402, column: 'Type',
message: "'kit_assembled' is reserved — bundled kits arrive in a coming release. Use 'standalone' for now.",
},
];
const SCENARIO_CANONICAL = {
key: 'canonical',
file: 'spring-2026-pricing.csv',
dialect: 'Canonical format',
dialectKind: 'canonical',
rows: 48,
summary: { add: 8, update: 14, unchanged: 26, error: 3 },
unknownColumns: ['Season Note', 'Internal Ref'],
records: { add: A_ADDS, update: A_UPDATES, unchanged: A_UNCHANGED, error: A_ERRORS },
extra: { update: A_UPDATES_EXTRA, unchanged: A_UNCHANGED_EXTRA },
noop: false,
};
/* =====================================================================
SCENARIO B — an unmodified Shopify product export (the migration path).
Recognized + mapped. Big add, many not-imported columns warned.
===================================================================== */
const B_ADDS = [
{
handle: 'oxford-button-down', title: 'Oxford Button-Down', kind: 'add', variants: 8, mapped: true,
detail: {
product: [
['Title', 'Oxford Button-Down'],
['Vendor', 'Northfield Supply'],
['Type', 'standalone', 'mapped from Shopify "Shirts" → warned, see below'],
['Tags', 'shirts, oxford'],
['Status', 'active'],
['Options', 'Size (S·M·L·XL) × Fit (Slim · Classic)'],
],
variantsPreview: [
['S / Slim', 'SKU OBD-S-SL', '$74.00'],
['L / Classic', 'SKU OBD-L-CL', '$74.00'],
],
images: 4,
},
},
{
handle: 'weekender-duffel', title: 'Weekender Duffel', kind: 'add', variants: 2, mapped: true,
detail: {
product: [
['Title', 'Weekender Duffel'],
['Vendor', 'Northfield Supply'],
['Type', 'standalone'],
['Tags', 'bags, travel'],
['Status', 'active'],
['Options', 'Color (Tan · Charcoal)'],
],
variantsPreview: [
['Tan', 'SKU WKD-TN', '$148.00'],
['Charcoal', 'SKU WKD-CH', '$148.00'],
],
images: 5,
},
},
{
handle: 'camp-mug-enamel', title: 'Enamel Camp Mug', kind: 'add', variants: 3, mapped: true,
detail: {
product: [
['Title', 'Enamel Camp Mug'],
['Vendor', 'Northfield Supply'],
['Type', 'standalone'],
['Tags', 'home, camp'],
['Status', 'active'],
['Options', 'Color (Red · Navy · Cream)'],
],
variantsPreview: [
['Red', 'SKU ECM-RD', '$18.00'],
['Navy', 'SKU ECM-NV', '$18.00'],
],
images: 2,
},
},
];
const B_ADDS_EXTRA = 283; // 286 total adds
const B_ERRORS = [
{
handle: 'gift-card', title: 'Gift Card', kind: 'error', variants: 4,
line: 88, column: 'Type',
message: "Shopify 'Gift Card' products aren't catalog products here — this row is skipped, not imported.",
},
{
handle: 'bundle-essentials', title: 'Essentials Bundle', kind: 'error', variants: 1,
line: 1042, column: 'Type',
message: "Looks like a Shopify bundle — bundled kits arrive in a coming release. Skipped for now.",
},
];
const B_ERRORS_EXTRA = 2; // 4 total
const SCENARIO_SHOPIFY = {
key: 'shopify',
file: 'shopify-store-export.csv',
dialect: 'Shopify product CSV — mapped to canonical',
dialectKind: 'shopify',
rows: 1894,
summary: { add: 286, update: 0, unchanged: 0, error: 4 },
unknownColumns: [
'Compare At Price', 'SEO Title', 'SEO Description', 'Google Shopping / Condition',
'Google Shopping / Gender', 'Variant Fulfillment Service', 'Variant Tax Code', 'Gift Card',
],
records: { add: B_ADDS, update: [], unchanged: [], error: B_ERRORS },
extra: { add: B_ADDS_EXTRA, error: B_ERRORS_EXTRA },
noop: false,
};
/* =====================================================================
SCENARIO C — re-importing our own export (the round-trip no-op).
Everything unchanged → the import action is disabled (PUC-10).
===================================================================== */
const C_UNCHANGED = [
{ handle: 'alpine-fleece-pullover', title: 'Alpine Fleece Pullover', kind: 'unchanged', variants: 8 },
{ handle: 'meadow-wrap-dress', title: 'Meadow Wrap Dress', kind: 'unchanged', variants: 6 },
{ handle: 'summit-rain-jacket', title: 'Summit Rain Jacket', kind: 'unchanged', variants: 9 },
{ handle: 'driftwood-henley', title: 'Driftwood Henley', kind: 'unchanged', variants: 8 },
];
const C_UNCHANGED_EXTRA = 408; // 412 total
const SCENARIO_ROUNDTRIP = {
key: 'roundtrip',
file: 'wander-wool-catalog-export.csv',
dialect: 'Canonical format',
dialectKind: 'canonical',
rows: 1620,
summary: { add: 0, update: 0, unchanged: 412, error: 0 },
unknownColumns: [],
records: { add: [], update: [], unchanged: C_UNCHANGED, error: [] },
extra: { unchanged: C_UNCHANGED_EXTRA },
noop: true,
};
/* ---- Image outcomes for the canonical run's post-commit phase (PUC-7) ---- */
const IMAGE_OUTCOMES = {
total: 21,
fetched: 18,
problems: [
{ handle: 'cirrus-rain-shell', variant: 'L / Marigold', url: 'https://cdn.northfield.example/cirrus-marigold-detail.jpg', outcome: 'rejected', reason: 'Below the resolution bar (320 × 480) — needs at least 800 × 800.' },
{ handle: 'coastal-linen-shirt', variant: 'M / Sky', url: 'https://images.example.org/linen/sky.gif', outcome: 'rejected', reason: 'Not an image we can host (animated GIF).' },
{ handle: 'harbor-tote', variant: '—', url: 'https://oldhost.example/harbor-tote-2.jpg', outcome: 'failed', reason: 'Unreachable — host returned 404.' },
],
};
const SCENARIOS = {
canonical: SCENARIO_CANONICAL,
shopify: SCENARIO_SHOPIFY,
roundtrip: SCENARIO_ROUNDTRIP,
};
window.IEData = { STOREFRONT, HISTORY, SCENARIOS, IMAGE_OUTCOMES };
@@ -0,0 +1,556 @@
/* importflow.jsx — the three import screens.
UploadScreen (§5.3) · PreviewScreen (§5.4, the consent gate) · RunDetailScreen (§5.5).
Exported to window. */
var { Button, Eyebrow } = window.WiggleverseDesignSystem_94cd80 || {};
const { useState: _ifState, useEffect: _ifEffect, useRef: _ifRef } = React;
/* ============================ small shared bits ============================ */
function ScreenHeader({ eyebrow, title, onBack, backLabel = 'Products', right }) {
return (
<div style={{ marginBottom: '1.6rem' }}>
<button onClick={onBack} style={{
display: 'inline-flex', alignItems: 'center', gap: '.35em', font: 'inherit',
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.85rem',
color: 'var(--wv-violet)', background: 'transparent', border: 'none', cursor: 'pointer',
padding: 0, marginBottom: '.9rem',
}}>
<Icon name="arrowLeft" size={15} /> Back to {backLabel}
</button>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
{eyebrow && <Eyebrow onLight>{eyebrow}</Eyebrow>}
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.9rem', letterSpacing: '-0.015em', color: 'var(--text-on-light)', margin: '.35rem 0 0', overflowWrap: 'anywhere' }}>{title}</h1>
</div>
{right}
</div>
</div>
);
}
/* ================================ 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 (
<div>
<ScreenHeader eyebrow="Import" title="Import products" onBack={onBack} />
{phase === 'validating' ? (
<Card style={{ padding: '2.4rem 2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', marginBottom: '1rem' }}>
<Spinner size={20} />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.1rem', color: 'var(--text-on-light)' }}>Validating</span>
</div>
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginBottom: '.7rem' }}>
{progress.file} {progress.done.toLocaleString()} of {progress.total.toLocaleString()} rows
</div>
<ProgressBar value={progress.done} max={progress.total} />
<div style={{ marginTop: '1.4rem', display: 'flex', alignItems: 'center', gap: '.6rem' }}>
<Button variant="ghost" onLight onClick={() => { clearInterval(timer.current); setPhase('idle'); }}>Cancel</Button>
<span style={{ fontSize: '.85rem', color: 'var(--text-on-light-soft)' }}>Canceling is free nothing has been written.</span>
</div>
</Card>
) : (
<>
{/* drop zone */}
<div
onDragOver={(e) => { 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)',
}}>
<input ref={fileInput} type="file" accept=".csv" style={{ display: 'none' }}
onChange={() => { pick('canonical', 'spring-2026-pricing.csv'); }} />
<div style={{ width: 54, height: 54, margin: '0 auto 1rem', borderRadius: 14, background: 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
<Icon name="upload" size={26} style={{ color: 'var(--wv-violet)' }} />
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.15rem', color: 'var(--text-on-light)' }}>Drop a CSV here, or click to choose</div>
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.4rem' }}>CSV, up to 5,000 rows · 10 MB. Selecting a file starts validation right away.</div>
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '1.1rem', borderTop: '1px solid var(--paper-line)', paddingTop: '1.1rem', maxWidth: 460, margin: '1.1rem auto 0' }}>
Works with the <strong style={{ color: 'var(--text-on-light)' }}>canonical format</strong> or a <strong style={{ color: 'var(--text-on-light)' }}>Shopify product export</strong> we detect which.
</div>
<div style={{ display: 'flex', gap: '1.4rem', justifyContent: 'center', marginTop: '.9rem' }} onClick={(e) => e.stopPropagation()}>
<a onClick={(e) => { e.preventDefault(); IE_downloadText('wander-wool-sample.csv', IE_SAMPLE_CSV); toast('Sample CSV downloaded', 'success'); }} href="#" style={linkStyle}><Icon name="download" size={14} /> Sample CSV</a>
<a onClick={(e) => e.preventDefault()} href="#" style={linkStyle}><Icon name="link" size={14} /> Column reference</a>
</div>
</div>
{/* file-level rejection */}
{phase === 'rejected' && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '.7rem', marginTop: '1.2rem', padding: '1rem 1.2rem', borderRadius: '12px', background: 'var(--st-error-tint)', border: '1px solid var(--st-error-line)' }}>
<Icon name="alert" size={19} style={{ color: 'var(--st-error)', marginTop: '.1rem' }} />
<div>
<div style={{ fontWeight: 600, color: 'var(--text-on-light)' }}>This file can't be imported as a catalog.</div>
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.2rem' }}>It's missing the required column <code style={codeStyle}>Title</code> and matches no known dialect. Nothing was imported and no import run was recorded. Fix the file and try again.</div>
</div>
</div>
)}
{/* sample files */}
<div style={{ marginTop: '2rem' }}>
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--text-on-light-soft)', marginBottom: '.8rem' }}>Or try a sample file</div>
<div style={{ display: 'grid', gap: '.7rem' }}>
{[...SAMPLE_FILES, REJECT_FILE].map((f) => (
<button key={f.key} onClick={() => start({ ...f, rows: ROWS[f.key] })} className="ie-samplefile" style={{
display: 'flex', alignItems: 'center', gap: '1rem', width: '100%', textAlign: 'left',
background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)',
borderRadius: '12px', padding: '.9rem 1.1rem', cursor: 'pointer', font: 'inherit',
transition: 'border-color var(--dur-fast), transform var(--dur-fast)',
}}>
<span style={{ width: 38, height: 38, flexShrink: 0, borderRadius: 10, background: f.key === 'reject' ? 'var(--st-error-tint)' : 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
<Icon name={f.icon} size={18} style={{ color: f.key === 'reject' ? 'var(--st-error)' : 'var(--wv-violet)' }} />
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'flex', alignItems: 'baseline', gap: '.6rem', minWidth: 0 }}>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.96rem', color: 'var(--text-on-light)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<span style={{ fontSize: '.78rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap', flexShrink: 0 }}>{f.size}</span>
</span>
<span style={{ display: 'block', fontSize: '.88rem', color: 'var(--text-on-light-soft)', marginTop: '.15rem' }}>{f.desc}</span>
</span>
<Icon name="arrowRight" size={16} style={{ color: 'var(--wv-violet)' }} />
</button>
))}
</div>
</div>
</>
)}
</div>
);
}
/* ================================ PREVIEW ================================= */
function FieldRow({ label, value, note }) {
return (
<div style={{ display: 'flex', gap: '1rem', padding: '.4rem 0', borderBottom: '1px solid var(--paper-line)' }}>
<div style={{ width: 130, flexShrink: 0, fontSize: '.82rem', color: 'var(--text-on-light-soft)', fontWeight: 500 }}>{label}</div>
<div style={{ flex: 1, fontSize: '.9rem', color: 'var(--text-on-light)' }}>
{value}
{note && <span style={{ display: 'block', fontSize: '.78rem', color: 'var(--st-update)', marginTop: '.2rem' }}> {note}</span>}
</div>
</div>
);
}
function RecordDetail({ rec }) {
const d = rec.detail || {};
if (rec.kind === 'add') {
return (
<div style={{ padding: '.4rem 0 .2rem' }}>
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--st-add)', marginBottom: '.5rem' }}>New product fields to be set</div>
<div style={{ marginBottom: '1rem' }}>
{d.product.map((row, i) => <FieldRow key={i} label={row[0]} value={row[1]} note={row[2]} />)}
</div>
<div style={{ display: 'flex', gap: '1.4rem', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 280px' }}>
<div style={{ fontSize: '.78rem', fontWeight: 600, color: 'var(--text-on-light-soft)', marginBottom: '.4rem' }}>Variants ({rec.variants}) · sample</div>
<div style={{ display: 'grid', gap: '.3rem' }}>
{d.variantsPreview.map((v, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '.6rem', fontSize: '.86rem', color: 'var(--text-on-light)' }}>
<span style={{ minWidth: 130, fontWeight: 500 }}>{v[0]}</span>
<span style={{ color: 'var(--text-on-light-soft)', flex: 1 }}>{v[1]}</span>
<span style={{ fontWeight: 600 }}>{v[2]}</span>
</div>
))}
{rec.variants > d.variantsPreview.length && <div style={{ fontSize: '.82rem', color: 'var(--text-on-light-soft)' }}>+ {rec.variants - d.variantsPreview.length} more variants</div>}
</div>
</div>
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: '.45em', color: 'var(--text-on-light-soft)', fontSize: '.86rem' }}>
<Icon name="image" size={15} /> {d.images} image{d.images === 1 ? '' : 's'} to fetch
</div>
</div>
</div>
);
}
if (rec.kind === 'update') {
return (
<div style={{ padding: '.4rem 0 .2rem' }}>
<div style={{ fontSize: '.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--st-update)', marginBottom: '.5rem' }}>Changes before after</div>
<div style={{ display: 'grid', gap: '.1rem' }}>
{d.changes.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: '1rem', padding: '.55rem 0', borderBottom: '1px solid var(--paper-line)', alignItems: 'baseline', opacity: c.noop ? .6 : 1 }}>
<div style={{ width: 170, flexShrink: 0 }}>
<div style={{ fontSize: '.88rem', fontWeight: 600, color: 'var(--text-on-light)' }}>{c.field}</div>
<div style={{ fontSize: '.76rem', color: 'var(--text-on-light-soft)' }}>{c.scope}</div>
</div>
<div style={{ flex: 1 }}><DiffPair before={c.before} after={c.after} noop={c.noop} /></div>
</div>
))}
</div>
</div>
);
}
if (rec.kind === 'error') {
return (
<div style={{ padding: '.5rem 0 .2rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '.6rem', padding: '.8rem 1rem', borderRadius: 10, background: 'var(--st-error-tint)', border: '1px solid var(--st-error-line)' }}>
<Icon name="alert" size={17} style={{ color: 'var(--st-error)', marginTop: '.1rem' }} />
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light)' }}>
<span style={{ fontWeight: 600 }}>Row {rec.line} · {rec.column}</span>
<div style={{ color: 'var(--text-on-light-soft)', marginTop: '.2rem' }}>{rec.message}</div>
<div style={{ marginTop: '.4rem', fontSize: '.82rem', color: 'var(--text-on-light-soft)' }}>This product will not be imported. Correct the row and re-import the valid products still go through.</div>
</div>
</div>
</div>
);
}
return <div style={{ padding: '.6rem 0', fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>This product matches your catalog exactly nothing to change.</div>;
}
function PreviewRow({ rec, expanded, onToggle }) {
return (
<div style={{ borderBottom: '1px solid var(--paper-line)' }}>
<button onClick={onToggle} className="ie-prow" style={{
display: 'flex', alignItems: 'center', gap: '1rem', width: '100%', textAlign: 'left',
padding: '.85rem 1.2rem', background: expanded ? 'var(--violet-tint)' : 'transparent',
border: 'none', cursor: 'pointer', font: 'inherit', transition: 'background var(--dur-fast)',
}}>
<Icon name="chevron" size={15} style={{ color: 'var(--text-on-light-soft)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.96rem', color: 'var(--text-on-light)' }}>{rec.title}</div>
<div style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', fontFamily: 'var(--font-body)' }}>{rec.handle}</div>
</div>
<span style={{ fontSize: '.82rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>{rec.variants} variant{rec.variants === 1 ? '' : 's'}</span>
<KindBadge kind={rec.kind} />
</button>
{expanded && <div style={{ padding: '0 1.2rem 1.1rem 2.4rem' }}><RecordDetail rec={rec} /></div>}
</div>
);
}
function ErrorTable({ errors }) {
return (
<Card pad="0" style={{ overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '.88rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text-on-light-soft)', fontSize: '.72rem', textTransform: 'uppercase', letterSpacing: '.06em' }}>
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600, width: 70 }}>Line</th>
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600, width: 150 }}>Column</th>
<th style={{ padding: '.75rem 1.2rem', fontWeight: 600 }}>What's wrong</th>
</tr>
</thead>
<tbody>
{errors.map((e, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--paper-line)' }}>
<td style={{ padding: '.8rem 1.2rem', fontVariantNumeric: 'tabular-nums', color: 'var(--st-error)', fontWeight: 600 }}>{e.line}</td>
<td style={{ padding: '.8rem 1.2rem' }}><code style={codeStyle}>{e.column}</code></td>
<td style={{ padding: '.8rem 1.2rem', color: 'var(--text-on-light)' }}>
<span style={{ fontWeight: 600 }}>{e.title}</span> — {e.message}
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}
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 (
<div style={{ paddingBottom: 90 }}>
<ScreenHeader
eyebrow="Import preview"
title={scenario.file}
onBack={onCancel}
right={
<div style={{ textAlign: 'right' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '.45em', padding: '.4rem .85rem', borderRadius: 999, background: scenario.dialectKind === 'shopify' ? 'var(--st-update-tint)' : 'var(--violet-tint)', border: `1px solid ${scenario.dialectKind === 'shopify' ? 'var(--st-update-line)' : 'var(--violet-line)'}` }}>
<Icon name={scenario.dialectKind === 'shopify' ? 'refresh' : 'check'} size={14} style={{ color: scenario.dialectKind === 'shopify' ? 'var(--st-update)' : 'var(--wv-violet)' }} />
<span style={{ fontSize: '.84rem', fontWeight: 600, color: 'var(--text-on-light)' }}>Recognized: {scenario.dialect}</span>
</div>
</div>
}
/>
{/* nothing-written reassurance */}
<div style={{ display: 'flex', alignItems: 'center', gap: '.5em', fontSize: '.86rem', color: 'var(--text-on-light-soft)', marginBottom: '1.3rem', marginTop: '-.6rem' }}>
<Icon name="check" size={14} style={{ color: 'var(--st-add)' }} /> Nothing has been written to your catalog yet. This is a preview.
</div>
{/* warnings band */}
{scenario.unknownColumns.length > 0 && (
<div style={{ marginBottom: '1.3rem', borderRadius: '12px', background: 'var(--st-update-tint)', border: '1px solid var(--st-update-line)', overflow: 'hidden' }}>
<button onClick={() => setWarnOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: '.6rem', width: '100%', padding: '.8rem 1.1rem', background: 'transparent', border: 'none', cursor: 'pointer', font: 'inherit', textAlign: 'left' }}>
<Icon name="alert" size={17} style={{ color: 'var(--st-update)' }} />
<span style={{ flex: 1, fontSize: '.92rem', color: 'var(--text-on-light)' }}>
<strong style={{ fontWeight: 600 }}>{scenario.unknownColumns.length} columns won't be imported.</strong>
<span style={{ color: 'var(--text-on-light-soft)' }}> They're recognized but have no home in your catalog — warned, never silently dropped.</span>
</span>
<Icon name="chevronDown" size={15} style={{ color: 'var(--text-on-light-soft)', transform: warnOpen ? 'none' : 'rotate(-90deg)', transition: 'transform var(--dur-fast)' }} />
</button>
{warnOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '.4rem', padding: '0 1.1rem 1rem 2.7rem' }}>
{scenario.unknownColumns.map((c) => (
<span key={c} style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', background: 'var(--surface-card-light)', border: '1px solid var(--paper-line-strong)', borderRadius: 999, padding: '.18rem .6rem', fontFamily: 'var(--font-body)', whiteSpace: 'nowrap' }}>{c}</span>
))}
</div>
)}
</div>
)}
{/* summary tiles */}
<div style={{ display: 'flex', gap: '.8rem', marginBottom: '1.6rem', flexWrap: 'wrap' }}>
<StatTile n={scenario.summary.add} label="To add" kind="add" active={active === 'add'} onClick={() => setActive('add')} disabled={scenario.summary.add === 0} />
<StatTile n={scenario.summary.update} label="To update" kind="update" active={active === 'update'} onClick={() => setActive('update')} disabled={scenario.summary.update === 0} />
<StatTile n={scenario.summary.unchanged} label="Unchanged" kind="unchanged" active={active === 'unchanged'} onClick={() => setActive('unchanged')} disabled={scenario.summary.unchanged === 0} />
<StatTile n={scenario.summary.error} label="In error" kind="error" active={active === 'error'} onClick={() => setActive('error')} disabled={scenario.summary.error === 0} />
</div>
{/* detail list / error table */}
<div style={{ display: 'flex', alignItems: 'center', gap: '.5rem', marginBottom: '.7rem' }}>
<KindBadge kind={active} size="sm" />
<span style={{ fontSize: '.86rem', color: 'var(--text-on-light-soft)' }}>
{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.'}
</span>
</div>
{active === 'error'
? <ErrorTable errors={records} />
: (
<Card pad="0" style={{ overflow: 'hidden' }}>
{records.length === 0
? <div style={{ padding: '1.6rem', textAlign: 'center', color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>Nothing in this group.</div>
: records.map((rec) => <PreviewRow key={rec.handle} rec={rec} expanded={expanded.has(rec.handle)} onToggle={() => toggle(rec.handle)} />)}
{extra > 0 && (
<div style={{ padding: '.85rem 1.2rem', fontSize: '.86rem', color: 'var(--text-on-light-soft)', borderTop: '1px solid var(--paper-line)', textAlign: 'center' }}>
+ {extra.toLocaleString()} more {active} {extra === 1 ? 'product' : 'products'} in this file
</div>
)}
</Card>
)}
{/* sticky footer */}
<div style={{
position: 'sticky', bottom: 0, marginTop: '1.6rem', marginLeft: -8, marginRight: -8,
padding: '1rem 1.2rem', borderRadius: '14px',
background: 'rgba(255,255,255,.9)', backdropFilter: 'blur(8px)',
border: '1px solid var(--paper-line-strong)', boxShadow: '0 -6px 20px rgba(14,18,48,.06)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap',
}}>
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light-soft)' }}>
{scenario.noop
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: '.4em' }}><Icon name="check" size={15} style={{ color: 'var(--st-add)' }} /> Nothing to change — your catalog already matches this file.</span>
: <span><strong style={{ color: 'var(--text-on-light)', fontWeight: 600 }}>{applyCount.toLocaleString()} products</strong> will be added or updated{scenario.summary.error ? <span>, <strong style={{ color: 'var(--st-error)' }}>{scenario.summary.error} rows</strong> left in error</span> : ''}.</span>}
</div>
<div style={{ display: 'flex', gap: '.7rem' }}>
<Button variant="ghost" onLight onClick={onCancel}>Cancel</Button>
<Button variant="primary" disabled={scenario.noop || applyCount === 0} onClick={() => onConfirm(scenario)}>
<Icon name="check" size={16} /> Import {applyCount.toLocaleString()} products
</Button>
</div>
</div>
</div>
);
}
/* ============================== 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 (
<div>
<ScreenHeader
eyebrow="Import run"
title={scenario.file}
onBack={onBack}
right={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.5em', fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.95rem', color: statusColor }}>
{phase === 'done' ? <span style={{ width: 9, height: 9, borderRadius: 999, background: statusColor }} /> : <Spinner size={15} />}
{statusLabel}
</span>
}
/>
<div style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)', marginTop: '-.7rem', marginBottom: '1.4rem' }}>
Imported {run ? run.rel : 'just now'} by {window.IEData.STOREFRONT.email} · {scenario.dialect}
</div>
{/* result summary */}
<div style={{ display: 'flex', gap: '.8rem', marginBottom: '1.8rem', flexWrap: 'wrap' }}>
<ResultTile n={counts.added} label="Products added" kind="add" />
<ResultTile n={counts.updated} label="Products updated" kind="update" />
<ResultTile n={counts.errors} label="Rows in error" kind="error" />
</div>
{/* images section */}
<section style={{ marginBottom: '1.8rem' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Images</h2>
<Card>
{phase !== 'done' && phase === 'applying' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', color: 'var(--text-on-light-soft)', fontSize: '.92rem' }}>
<Spinner size={16} /> Applying catalog rows — the image fetch begins once products commit.
</div>
) : phase === 'fetching' ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.7rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '.5em', fontWeight: 600, color: 'var(--text-on-light)' }}><Spinner size={15} /> Fetching images</span>
<span style={{ fontVariantNumeric: 'tabular-nums', color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>{fetched.toLocaleString()} of {total.toLocaleString()}</span>
</div>
<ProgressBar value={fetched} max={total} />
<div style={{ fontSize: '.84rem', color: 'var(--text-on-light-soft)', marginTop: '.7rem' }}>Running in the background — your catalog is already live. You can leave this page and come back.</div>
</div>
) : (
<div>
<div style={{ display: 'flex', gap: '1.4rem', flexWrap: 'wrap', marginBottom: images.problems.length ? '1.2rem' : 0 }}>
<SummaryStat label="Fetched" n={images.fetched} color="var(--st-add)" glyph="check" />
<SummaryStat label="Rejected" n={images.problems.filter((p) => p.outcome === 'rejected').length} color="var(--st-error)" glyph="x" />
<SummaryStat label="Failed" n={images.problems.filter((p) => p.outcome === 'failed').length} color="var(--st-error)" glyph="alert" />
</div>
{images.problems.length > 0 ? (
<div>
<div style={{ fontSize: '.82rem', fontWeight: 600, color: 'var(--text-on-light-soft)', marginBottom: '.5rem' }}>Only problems are listed — these products show a placeholder until you re-import a corrected URL.</div>
<div style={{ border: '1px solid var(--paper-line)', borderRadius: 10, overflow: 'hidden' }}>
{images.problems.map((p, i) => (
<div key={i} style={{ display: 'flex', gap: '1rem', padding: '.8rem 1rem', borderTop: i ? '1px solid var(--paper-line)' : 'none', alignItems: 'flex-start' }}>
<span style={{ marginTop: '.1rem' }}><KindBadge kind="error" size="sm" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '.9rem', color: 'var(--text-on-light)', fontWeight: 600 }}>{p.handle} <span style={{ color: 'var(--text-on-light-soft)', fontWeight: 400 }}>· {p.variant}</span></div>
<div style={{ fontSize: '.8rem', color: 'var(--text-on-light-soft)', fontFamily: 'var(--font-body)', wordBreak: 'break-all', margin: '.1rem 0' }}>{p.url}</div>
<div style={{ fontSize: '.84rem', color: 'var(--st-error)' }}>{p.outcome === 'rejected' ? 'Rejected' : 'Failed'} — {p.reason}</div>
</div>
</div>
))}
</div>
</div>
) : <div style={{ fontSize: '.9rem', color: 'var(--st-add)' }}>All images fetched cleanly.</div>}
</div>
)}
</Card>
</section>
{/* error table */}
{scenario.records.error.length > 0 && (
<section>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Rows in error <span style={{ fontWeight: 500, color: 'var(--text-on-light-soft)', fontSize: '1rem' }}>· unapplied</span></h2>
<ErrorTable errors={scenario.records.error} />
</section>
)}
</div>
);
}
function ResultTile({ n, label, kind }) {
const m = KIND_META[kind];
return (
<div style={{ flex: '1 1 0', minWidth: 120, background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)', borderRadius: 12, padding: '1rem 1.2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.4em', marginBottom: '.3rem' }}>
<span style={{ width: 7, height: 7, borderRadius: 999, background: `var(--st-${m.cssvar})` }} />
<span style={{ fontSize: '.74rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)' }}>{label}</span>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.8rem', color: kind === 'error' && n === 0 ? 'var(--text-on-light)' : `var(--st-${m.cssvar})` }}>{n.toLocaleString()}</div>
</div>
);
}
function SummaryStat({ label, n, color, glyph }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '.55rem' }}>
<span style={{ width: 30, height: 30, borderRadius: 8, background: color, opacity: .12, position: 'absolute' }} />
<span style={{ width: 30, height: 30, borderRadius: 8, display: 'grid', placeItems: 'center', background: 'color-mix(in srgb, ' + color + ' 12%, transparent)' }}>
<Icon name={glyph} size={16} style={{ color }} />
</span>
<span>
<span style={{ display: 'block', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem', color: 'var(--text-on-light)', lineHeight: 1 }}>{n.toLocaleString()}</span>
<span style={{ fontSize: '.78rem', color: 'var(--text-on-light-soft)' }}>{label}</span>
</span>
</div>
);
}
/* 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 });
@@ -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,"<p>Brushed recycled fleece, cut for layering.</p>",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,"<p>Heavyweight organic-canvas tote.</p>",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 (
<div ref={ref} style={{
position: 'absolute', top: 'calc(100% + .5rem)', right: 0, zIndex: 40, width: 246,
background: 'var(--surface-card-light)', border: '1px solid var(--paper-line-strong)',
borderRadius: '12px', boxShadow: '0 12px 32px rgba(14,18,48,.16)', padding: '.7rem',
}}>
<div style={{ fontSize: '.72rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)', padding: '.3rem .4rem .5rem' }}>Export filter by status</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '.1rem' }}>
{opts.map(([v, label]) => (
<button key={v} onClick={() => setStatus(v)} style={{
display: 'flex', alignItems: 'center', gap: '.6rem', padding: '.5rem .5rem', borderRadius: 8,
border: 'none', background: status === v ? 'var(--violet-tint)' : 'transparent', cursor: 'pointer',
font: 'inherit', fontSize: '.92rem', color: 'var(--text-on-light)', textAlign: 'left',
}}>
<span style={{
width: 16, height: 16, borderRadius: 999, flexShrink: 0,
border: `1.5px solid ${status === v ? 'var(--wv-violet)' : 'var(--paper-line-strong)'}`,
display: 'grid', placeItems: 'center',
}}>
{status === v && <span style={{ width: 8, height: 8, borderRadius: 999, background: 'var(--wv-violet)' }} />}
</span>
{label}
</button>
))}
</div>
<div style={{ borderTop: '1px solid var(--paper-line)', marginTop: '.5rem', paddingTop: '.6rem' }}>
<button onClick={() => onExport(status)} style={{
width: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '.45em',
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.92rem', color: 'var(--cta-text)',
background: 'var(--cta)', border: 'none', borderRadius: 999, padding: '.55rem 1rem', cursor: 'pointer', whiteSpace: 'nowrap',
}}>
<Icon name="download" size={15} /> Download CSV
</button>
</div>
</div>
);
}
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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.4em', fontSize: '.84rem', fontWeight: 600, color }}>
{spin ? <Spinner size={13} /> : <span style={{ width: 7, height: 7, borderRadius: 999, background: color }} />}
{label}
</span>
);
}
function ImportHistory({ runs, onOpenRun }) {
return (
<section style={{ marginTop: '2.4rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '.7rem', marginBottom: '.9rem' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.25rem', color: 'var(--text-on-light)', margin: 0 }}>Import history</h2>
<span style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>{runs.length} runs · newest first</span>
</div>
<Card pad="0" style={{ overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '.9rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text-on-light-soft)', fontSize: '.74rem', textTransform: 'uppercase', letterSpacing: '.06em' }}>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>When</th>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>File</th>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Dialect</th>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600, textAlign: 'right' }}>Added / Updated / Errors</th>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Images</th>
<th style={{ padding: '.85rem 1.2rem', fontWeight: 600 }}>Status</th>
</tr>
</thead>
<tbody>
{runs.map((r, i) => (
<tr key={r.id} onClick={() => onOpenRun(r.id)} className="ie-hrow" style={{
borderTop: '1px solid var(--paper-line)', cursor: 'pointer',
}}>
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>{r.rel}</td>
<td style={{ padding: '.95rem 1.2rem' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.45em', color: 'var(--text-on-light)', fontWeight: 500 }}>
<Icon name="file" size={15} style={{ color: 'var(--wv-violet)' }} /> {r.file}
</span>
</td>
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)' }}>{r.dialect}</td>
<td style={{ padding: '.95rem 1.2rem', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
<span style={{ color: 'var(--st-add)', fontWeight: 600 }}>+{r.added}</span>
<span style={{ color: 'var(--text-on-light-soft)', margin: '0 .35em' }}>·</span>
<span style={{ color: 'var(--st-update)', fontWeight: 600 }}>±{r.updated}</span>
<span style={{ color: 'var(--text-on-light-soft)', margin: '0 .35em' }}>·</span>
<span style={{ color: r.errors ? 'var(--st-error)' : 'var(--text-on-light-soft)', fontWeight: 600 }}>!{r.errors}</span>
</td>
<td style={{ padding: '.95rem 1.2rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>
{(r.images.fetched + r.images.rejected + r.images.failed) > 0
? <span><span style={{ color: 'var(--st-add)' }}>{r.images.fetched} </span> · <span style={{ color: 'var(--st-error)' }}>{r.images.rejected + r.images.failed} </span></span>
: <span style={{ opacity: .6 }}></span>}
</td>
<td style={{ padding: '.95rem 1.2rem' }}><HistoryStatus status={r.status} /></td>
</tr>
))}
</tbody>
</table>
</Card>
</section>
);
}
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 (
<div>
{/* header row */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<Eyebrow onLight>Catalog</Eyebrow>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '2.1rem', letterSpacing: '-0.015em', color: 'var(--text-on-light)', margin: '.35rem 0 0' }}>
Products <span style={{ color: 'var(--text-on-light-soft)', fontWeight: 500 }}>· {count.toLocaleString()}</span>
</h1>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '.7rem', position: 'relative' }}>
<div style={{ position: 'relative' }}>
<button onClick={() => !catalogEmpty && setMenuOpen((o) => !o)} disabled={catalogEmpty} title={catalogEmpty ? 'Nothing to export yet' : ''} style={{
display: 'inline-flex', alignItems: 'center', gap: '.45em', fontFamily: 'var(--font-display)',
fontWeight: 500, fontSize: '.92rem', color: 'var(--text-on-light)', background: 'var(--surface-card-light)',
border: '1.5px solid var(--paper-line-strong)', borderRadius: 999, padding: '.55rem 1.1rem',
cursor: catalogEmpty ? 'not-allowed' : 'pointer', opacity: catalogEmpty ? .5 : 1,
}}>
<Icon name="download" size={16} /> Export <Icon name="chevronDown" size={14} />
</button>
{menuOpen && <StatusFilterMenu onExport={doExport} onClose={() => setMenuOpen(false)} />}
</div>
<Button variant="primary" onClick={onImport}>
<Icon name="upload" size={16} /> Import products
</Button>
</div>
</div>
{catalogEmpty
? <span style={{ display: 'block', fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '.5rem' }}>An honest empty shell nothing to manage yet.</span>
: <span style={{ display: 'block', fontSize: '.9rem', color: 'var(--text-on-light-soft)', marginTop: '.5rem' }}>One canonical CSV in, your whole catalog out any time.</span>}
{/* notices band */}
{!catalogEmpty && STOREFRONT.imageProblemCount > 0 && (
<button onClick={() => onOpenRun(STOREFRONT.latestRunId)} style={{
display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%', textAlign: 'left',
marginTop: '1.5rem', padding: '.85rem 1.1rem', borderRadius: '12px', cursor: 'pointer',
background: 'var(--st-update-tint)', border: '1px solid var(--st-update-line)', font: 'inherit',
}}>
<Icon name="image" size={18} style={{ color: 'var(--st-update)' }} />
<span style={{ flex: 1, color: 'var(--text-on-light)', fontSize: '.92rem' }}>
<strong style={{ fontWeight: 600 }}>{STOREFRONT.imageProblemCount} products have image problems.</strong>
<span style={{ color: 'var(--text-on-light-soft)' }}> Some images were rejected or unreachable in your latest import.</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.3em', color: 'var(--st-update)', fontWeight: 600, fontSize: '.88rem' }}>
View run <Icon name="arrowRight" size={14} />
</span>
</button>
)}
{/* primary content */}
{catalogEmpty ? (
<Card style={{ marginTop: '1.8rem', padding: '3rem 2rem', textAlign: 'center' }}>
<div style={{ width: 56, height: 56, margin: '0 auto 1.1rem', borderRadius: 16, background: 'var(--violet-tint)', display: 'grid', placeItems: 'center' }}>
<Icon name="products" size={26} style={{ color: 'var(--wv-violet)' }} />
</div>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.4rem', color: 'var(--text-on-light)', margin: '0 0 .4rem' }}>No products yet</h2>
<p style={{ fontSize: '1rem', color: 'var(--text-on-light-soft)', maxWidth: '40ch', margin: '0 auto 1.5rem', lineHeight: 1.55 }}>
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.
</p>
<Button variant="primary" onClick={onImport}>
<Icon name="upload" size={16} /> Import products
</Button>
<div style={{ display: 'flex', gap: '1.6rem', justifyContent: 'center', marginTop: '1.5rem' }}>
<a onClick={(e) => { e.preventDefault(); downloadText('wander-wool-sample.csv', SAMPLE_CSV); toast('Sample CSV downloaded', 'success'); }} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
<Icon name="download" size={14} /> Download sample CSV
</a>
<a onClick={(e) => e.preventDefault()} href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: '.35em', color: 'var(--wv-violet)', fontWeight: 500, fontSize: '.9rem', textDecoration: 'none' }}>
<Icon name="link" size={14} /> Column reference
</a>
</div>
</Card>
) : (
<Card style={{ marginTop: '1.8rem', display: 'flex', alignItems: 'center', gap: '1.2rem', padding: '1.4rem 1.6rem' }}>
<div style={{ width: 46, height: 46, flexShrink: 0, borderRadius: 12, background: 'var(--add-tint)', display: 'grid', placeItems: 'center' }}>
<Icon name="check" size={22} style={{ color: 'var(--st-add)' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '1.05rem', color: 'var(--text-on-light)' }}>Your catalog is loaded {count.toLocaleString()} products.</div>
<div style={{ fontSize: '.92rem', color: 'var(--text-on-light-soft)', marginTop: '.15rem' }}>The browsable product list arrives with an upcoming release. For now, manage it in bulk: import to change it, export to take it.</div>
</div>
<span style={{ whiteSpace: 'nowrap' }}><Notice>List · soon</Notice></span>
</Card>
)}
{/* history */}
{runs.length > 0
? <ImportHistory runs={runs} onOpenRun={onOpenRun} />
: !catalogEmpty ? null : (
<section style={{ marginTop: '2.4rem' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.25rem', color: 'var(--text-on-light)', margin: '0 0 .9rem' }}>Import history</h2>
<Card style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-on-light-soft)' }}>No imports yet.</Card>
</section>
)}
</div>
);
}
window.ProductsPage = ProductsPage;
window.IE_downloadText = downloadText;
window.IE_SAMPLE_CSV = SAMPLE_CSV;
@@ -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 (
<button
onClick={() => !item.soon && onClick(item.id)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
disabled={item.soon}
style={{
display: 'flex', alignItems: 'center', gap: '.7rem', width: '100%',
padding: '.6rem .8rem', borderRadius: '10px', border: '1px solid transparent',
font: 'inherit', textAlign: 'left', cursor: item.soon ? 'default' : 'pointer',
fontFamily: 'var(--font-body)', fontSize: '.95rem', fontWeight: active ? 600 : 500,
color: active ? 'var(--wv-starlight)' : item.soon ? 'var(--wv-starlight-55)' : 'var(--wv-starlight-78)',
background: active ? 'var(--wv-lilac-16)' : hover && !item.soon ? 'var(--wv-lilac-08)' : 'transparent',
borderColor: active ? 'var(--wv-lilac-32)' : 'transparent',
transition: 'background var(--dur-fast), color var(--dur-fast)',
}}>
<Icon name={item.icon} size={18} stroke={active ? 1.9 : 1.6}
style={{ color: active ? 'var(--wv-lilac)' : 'inherit' }} />
<span style={{ flex: 1 }}>{item.label}</span>
{item.soon && <Notice>Soon</Notice>}
</button>
);
}
function AdminShell({ current, onNav, storefront, children }) {
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--surface-paper)' }}>
{/* ---- Sidebar ---- */}
<aside style={{
width: 248, flexShrink: 0, position: 'sticky', top: 0, alignSelf: 'flex-start',
height: '100vh', display: 'flex', flexDirection: 'column',
background: 'var(--wv-midnight)',
backgroundImage:
'radial-gradient(1px 1px at 38px 60px, rgba(237,234,255,.5), transparent),' +
'radial-gradient(1px 1px at 180px 130px, rgba(237,234,255,.35), transparent),' +
'radial-gradient(1.4px 1.4px at 90px 320px, rgba(244,199,107,.45), transparent),' +
'radial-gradient(1px 1px at 200px 460px, rgba(237,234,255,.4), transparent),' +
'radial-gradient(1px 1px at 50px 540px, rgba(155,140,255,.5), transparent)',
borderRight: '1px solid var(--wv-lilac-16)',
}}>
{/* brand lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', padding: '1.25rem 1.25rem 1.1rem' }}>
<CircleMark size={30} />
<div style={{ lineHeight: 1.15 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '.98rem', color: 'var(--wv-starlight)', letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{storefront.name}</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '.6rem', fontWeight: 500, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--wv-starlight-55)', marginTop: '.12rem' }}>ecomm · admin</div>
</div>
</div>
<nav style={{ display: 'flex', flexDirection: 'column', gap: '.2rem', padding: '0 .7rem', marginTop: '.3rem' }}>
{NAV_ITEMS.map((it) => (
<SidebarItem key={it.id} item={it} active={current === it.id} onClick={onNav} />
))}
</nav>
<div style={{ marginTop: 'auto', padding: '1.1rem 1.25rem 1.25rem', borderTop: '1px solid var(--wv-lilac-12)' }}>
<Soul size="sm">Your catalog is yours it can leave whole, any time.</Soul>
</div>
</aside>
{/* ---- Main column ---- */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
{/* top header */}
<header style={{
position: 'sticky', top: 0, zIndex: 20,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '.85rem 2rem', background: 'rgba(246,244,251,.86)', backdropFilter: 'blur(10px)',
borderBottom: '1px solid var(--paper-line)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.6rem', minWidth: 0 }}>
<span style={{ width: 9, height: 9, borderRadius: 999, background: 'var(--st-add)', boxShadow: '0 0 0 3px rgba(31,138,91,.15)' }} />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: '.98rem', color: 'var(--text-on-light)', whiteSpace: 'nowrap' }}>{storefront.name}</span>
<span style={{ fontSize: '.85rem', color: 'var(--text-on-light-soft)', whiteSpace: 'nowrap' }}>· live storefront</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span style={{ fontSize: '.88rem', color: 'var(--text-on-light-soft)' }}>{storefront.email}</span>
<button style={{
display: 'inline-flex', alignItems: 'center', gap: '.4em', font: 'inherit',
fontFamily: 'var(--font-display)', fontWeight: 500, fontSize: '.85rem',
color: 'var(--text-on-light)', background: 'transparent',
border: '1.5px solid var(--paper-line-strong)', borderRadius: 999,
padding: '.4rem .9rem', cursor: 'pointer',
}}>
<Icon name="signout" size={15} /> Sign out
</button>
</div>
</header>
<main style={{ flex: 1, padding: '2.2rem 2rem 4rem' }}>
<div style={{ maxWidth: 1080, margin: '0 auto' }}>
{children}
</div>
</main>
</div>
</div>
);
}
const { useState: _shUseState } = React;
window.AdminShell = AdminShell;
@@ -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 <input type="range">, 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 (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 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 25 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
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 (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
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) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
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 (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// 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 }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// 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 (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> 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 (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{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 (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
@@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={stroke} strokeLinecap="round"
strokeLinejoin="round" style={{ flexShrink: 0, display: 'block', ...style }} {...rest}>
<path d={ICON_PATHS[name]} />
</svg>
);
}
/* ---------- 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 (
<svg width={size} height={size} viewBox="0 0 36 36" fill="none" style={{ display: 'block' }}>
<defs>
<linearGradient id={id} x1="4" y1="6" x2="32" y2="30" gradientUnits="userSpaceOnUse">
<stop stopColor="#9B8CFF" />
<stop offset="1" stopColor="#F4C76B" />
</linearGradient>
</defs>
<path d={d} stroke={`url(#${id})`} strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
{pts.map((p, i) => <circle key={i} cx={p[0]} cy={p[1]} r="2.1" fill="#EDEAFF" />)}
</svg>
);
}
/* ---------- 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 (
<span className="ie-kind" style={{
display: 'inline-flex', alignItems: 'center', gap: '.34em',
fontFamily: 'var(--font-display)', fontWeight: 600,
fontSize: size === 'sm' ? '.72rem' : '.78rem',
letterSpacing: '.01em', padding: pad, borderRadius: '999px',
color: `var(--st-${m.cssvar})`, background: `var(--st-${m.cssvar}-tint)`,
border: `1px solid var(--st-${m.cssvar}-line)`, whiteSpace: 'nowrap',
}}>
<Icon name={m.glyph} size={size === 'sm' ? 11 : 12} stroke={2.1} />
{m.label}
</span>
);
}
/* ---------- Diff value: before → after ---------- */
function DiffPair({ before, after, noop }) {
if (noop) {
return <span style={{ color: 'var(--text-on-light-soft)', fontSize: '.9rem' }}>{after} <span style={{ opacity: .6 }}>(unchanged)</span></span>;
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '.5em', flexWrap: 'wrap', fontSize: '.9rem' }}>
<span style={{ color: 'var(--text-on-light-soft)', textDecoration: 'line-through', textDecorationColor: 'var(--st-error)', textDecorationThickness: '1px', opacity: .8 }}>{before}</span>
<Icon name="arrowRight" size={13} stroke={1.8} style={{ color: 'var(--text-on-light-soft)', opacity: .7 }} />
<span style={{ color: 'var(--st-update)', fontWeight: 600 }}>{after}</span>
</span>
);
}
/* ---------- Stat tile (summary filter) ---------- */
function StatTile({ n, label, kind, active, onClick, disabled }) {
const m = KIND_META[kind];
const accent = `var(--st-${m.cssvar})`;
return (
<button onClick={onClick} disabled={disabled} className="ie-stat" data-active={active ? '1' : '0'} style={{
flex: '1 1 0', minWidth: 0, textAlign: 'left', cursor: disabled ? 'default' : 'pointer',
background: active ? `var(--st-${m.cssvar}-tint)` : 'var(--surface-card-light)',
border: `1.5px solid ${active ? `var(--st-${m.cssvar}-line)` : 'var(--paper-line)'}`,
borderRadius: '12px', padding: '.85rem 1rem .9rem', position: 'relative',
transition: 'transform var(--dur-fast) var(--ease), border-color var(--dur-fast), background var(--dur-fast)',
opacity: disabled ? .55 : 1, font: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.4em' }}>
<span style={{ width: 7, height: 7, borderRadius: 999, background: accent, display: 'inline-block' }} />
<span style={{ fontSize: '.74rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-on-light-soft)' }}>{label}</span>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.9rem', lineHeight: 1.05, marginTop: '.3rem', color: kind === 'unchanged' ? 'var(--text-on-light)' : accent }}>
{n.toLocaleString()}
</div>
</button>
);
}
/* ---------- Progress bar ---------- */
function ProgressBar({ value, max, accent = 'var(--wv-violet)', height = 8 }) {
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
return (
<div style={{ background: 'var(--paper-line)', borderRadius: 999, height, overflow: 'hidden', width: '100%' }}>
<div style={{ width: pct + '%', height: '100%', background: accent, borderRadius: 999, transition: 'width .3s ease' }} />
</div>
);
}
/* ---------- Generic paper card ---------- */
function Card({ children, style, pad = '1.5rem', ...rest }) {
return (
<div style={{ background: 'var(--surface-card-light)', border: '1px solid var(--paper-line)', borderRadius: '14px', padding: pad, ...style }} {...rest}>
{children}
</div>
);
}
/* ---------- Spinner (thin, geometric) ---------- */
function Spinner({ size = 16, stroke = 2 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ display: 'block', animation: 'ie-spin .8s linear infinite' }}>
<circle cx="12" cy="12" r="9" stroke="var(--paper-line)" strokeWidth={stroke} />
<path d="M12 3a9 9 0 0 1 9 9" stroke="var(--wv-violet)" strokeWidth={stroke} strokeLinecap="round" />
</svg>
);
}
Object.assign(window, {
Icon, CircleMark, KindBadge, DiffPair, StatTile, ProgressBar, Card, Spinner, KIND_META,
});