add ui design ./ui/designs/ecomm-login-and-create-storefront-designs/
Claude Design export for SD-0001 (login + create-storefront surfaces): wf-*/hf-* screens, design-system bundle, offline preview. First artifact in the ui/designs/ collection (ecomm#8). Also ignore .DS_Store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1 @@
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+180
File diff suppressed because one or more lines are too long
+378
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+959
@@ -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;
|
||||
|
||||
})();
|
||||
+1
File diff suppressed because one or more lines are too long
+10
@@ -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');}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+170
@@ -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` (§5–10). 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).
|
||||
+8
@@ -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 */
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
+55
@@ -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 */
|
||||
}
|
||||
+53
@@ -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,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
+19
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — Circle of Equals</title>
|
||||
<defs>
|
||||
<linearGradient id="wv-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#9B8CFF"></stop>
|
||||
<stop offset="1" stop-color="#F4C76B"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="url(#wv-grad)" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="4.5"></circle>
|
||||
<circle cx="79" cy="93" r="4.5"></circle>
|
||||
<circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle>
|
||||
<circle cx="41" cy="27" r="4.5"></circle>
|
||||
<circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 891 B |
@@ -0,0 +1,974 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
|
||||
/* BEGIN USAGE */
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
//
|
||||
// Artboards are static design frames, not scroll regions — never use
|
||||
// height: 100% + overflow: auto/scroll on inner elements; size each artboard
|
||||
// to fit its content (explicit pixel height, or let it grow).
|
||||
/* END USAGE */
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// right. Single flex row; when the artboard's on-screen width is too
|
||||
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||
// ~4ch via the container query) and the buttons stay on the row.
|
||||
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||
' display:flex;align-items:center;container-type:inline-size}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||
// until the card is moused.
|
||||
'@container (max-width: 110px){',
|
||||
' .dc-labeltext{display:none}',
|
||||
' .dc-grip{opacity:0}',
|
||||
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||
'}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||
' font:inherit;transition:background .12s,color .12s}',
|
||||
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||
// DCViewport on every transform update and inherits to all descendants —
|
||||
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||
// it the same way.
|
||||
//
|
||||
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||
// after counter-scaling its on-screen width exactly matches the card's —
|
||||
// that's what lets the container query + text-overflow behave against the
|
||||
// card's visible edge at every zoom level.
|
||||
//
|
||||
// The section head uses CSS zoom instead of transform so its layout box
|
||||
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||
// constant-screen-size title would overflow into the (shrinking) world-
|
||||
// space gap and overlap the artboard headers at low zoom.
|
||||
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
|
||||
// DCSection/DCArtboard children from the type-based walks below.
|
||||
function dcFlatten(children) {
|
||||
const out = [];
|
||||
React.Children.forEach(children, (c) => {
|
||||
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
|
||||
else out.push(c);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||
// .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Fragments are flattened; wrapping in other
|
||||
// elements still opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
dcFlatten(children).forEach((sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const abs = [];
|
||||
dcFlatten(sec.props.children).forEach((ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (aid) abs.push([aid, ab]);
|
||||
});
|
||||
// hidden is scoped to one source revision — when the agent regenerates
|
||||
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||
const srcIds = [];
|
||||
abs.forEach(([aid, ab]) => {
|
||||
if (hidden.includes(aid)) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
// Persist viewport across reloads so the user lands back where they were
|
||||
// after an agent edit or browser refresh. The sandbox origin is already
|
||||
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||
const tfKey = 'dc-viewport:' + location.pathname;
|
||||
const saveT = React.useRef(0);
|
||||
|
||||
const lastPostedScale = React.useRef();
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (!el) return;
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||
if (lastPostedScale.current !== scale) {
|
||||
lastPostedScale.current = scale;
|
||||
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||
}
|
||||
clearTimeout(saveT.current);
|
||||
saveT.current = setTimeout(() => {
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
}, 200);
|
||||
}, [tfKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const flush = () => {
|
||||
clearTimeout(saveT.current);
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
};
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||
apply();
|
||||
}
|
||||
} catch {}
|
||||
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||
// window doesn't drop the last pan/zoom.
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
|
||||
// marginBottom) reflow on every scale change, vertically shifting the
|
||||
// world layout — so a world point mathematically pinned under the cursor
|
||||
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
|
||||
// Anchor the DOM element under the cursor instead: record its screen Y,
|
||||
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
|
||||
// drift the reflow introduced so it stays put on screen.
|
||||
let marker = null, markerY0 = 0;
|
||||
if (k !== 1) {
|
||||
const hit = document.elementFromPoint(cx, cy);
|
||||
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
||||
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
||||
}
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
if (marker) {
|
||||
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
|
||||
// departure after the --dc-inv-zoom reflow is the layout drift.
|
||||
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
||||
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||
const onHostMsg = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||
const r = vp.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||
} else if (d && d.type === '__dc_probe') {
|
||||
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||
// fires on the iframe's native 'load', which for canvases with
|
||||
// images/fonts is after our mount-time announce, so re-announce.
|
||||
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||
// even if it's unchanged — the host just reset dcScale to 1.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onHostMsg);
|
||||
// Announce canvas mode so the host toolbar proxies its % control here
|
||||
// instead of scaling the iframe element (which would just shrink the
|
||||
// viewport window of an infinite canvas). The apply() that follows emits
|
||||
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||
// effect's restore-path apply() may already have posted the restored
|
||||
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(dcFlatten(children));
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||
const srcKey = allIds.join('\x1f');
|
||||
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||
// belonging to the section above. paddingBottom below is just enough for
|
||||
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||
// the title sits tight against its own row at every zoom.
|
||||
return (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||
srcKey,
|
||||
}))}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
const cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
const n = sectionOrder.length;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// hf-admin.jsx — Admin shell (PUC-8 / PUC-9) hi-fi. Honestly empty + states.
|
||||
const { HFScreen: AScreen, HFTopBar: ATopBar, HFAccountChip: AChip, HFBanner: ABanner,
|
||||
HFStateCard: AStateCard, HFStates: AStates, HF_H: A, HF_STORE: ASTORE, HF_DSC: ADS } = window;
|
||||
const { Eyebrow: AEyebrow, Soul: ASoul } = ADS;
|
||||
|
||||
// storefront identity lockup — the admin's anchor
|
||||
function StoreId({ size = 17 }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 11 }}>
|
||||
<img src={window.HF_ASSET('markTile', 'assets/mark-tile.svg')} width={size + 7} height={size + 7} style={{ borderRadius: 7, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.12 }}>
|
||||
<span style={{ fontFamily: A.disp, fontWeight: 700, fontSize: size, color: A.star, letterSpacing: '-0.01em' }}>{ASTORE}</span>
|
||||
<span style={{ fontFamily: A.body, fontSize: 11, color: A.mute }}>ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// the honest-empty centerpiece — intentional, not sparse
|
||||
function EmptyHome({ compact = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
textAlign: 'center', padding: compact ? '40px 24px' : '0 64px', position: 'relative',
|
||||
}}>
|
||||
{/* faint constellation seal — one decorative mark, low alpha */}
|
||||
<div style={{ width: compact ? 64 : 76, height: compact ? 64 : 76, borderRadius: '50%', border: `1px solid ${A.hairCard}`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 26, background: 'var(--wv-lilac-08)' }}>
|
||||
<img src={window.HF_ASSET('markGold', 'assets/mark-mono-gold.svg')} width={compact ? 30 : 36} height={compact ? 30 : 36} alt="" style={{ opacity: 0.9 }} />
|
||||
</div>
|
||||
<AEyebrow style={{ margin: '0 0 12px' }}>Your storefront</AEyebrow>
|
||||
<h1 style={{ fontFamily: A.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.04, fontSize: compact ? 30 : 42, color: A.star, margin: '0 0 16px' }}>{ASTORE}</h1>
|
||||
<p style={{ fontFamily: A.body, fontSize: compact ? 15 : 17, lineHeight: 1.6, color: A.soft, maxWidth: 460, margin: '0 0 28px', textWrap: 'pretty' }}>
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAdmin({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<AScreen stars={false}>
|
||||
<ATopBar pad={desktop ? '0 36px' : '0 20px'} left={<StoreId size={desktop ? 17 : 15} />}
|
||||
right={desktop ? <AChip /> : <button className="signout">Sign out</button>} />
|
||||
<EmptyHome compact={!desktop} />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAdminStates() {
|
||||
return (
|
||||
<AStates>
|
||||
<AStateCard label="Loading" note="skeleton while /me resolves">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 13 }}>
|
||||
<Skel w={56} h={56} r={28} />
|
||||
<Skel w={90} h={11} />
|
||||
<Skel w={180} h={26} />
|
||||
<Skel w={300} h={11} />
|
||||
<Skel w={260} h={11} />
|
||||
</div>
|
||||
</AStateCard>
|
||||
<AStateCard label="Session expired" note="PUC-9 · redirect to sign-in">
|
||||
<ABanner tone="info" title="Your session ended">
|
||||
For your security you've been signed out. <a className="lk" href="#">Sign in again →</a>
|
||||
</ABanner>
|
||||
</AStateCard>
|
||||
<AStateCard label="Account menu" note="PUC-9 · sign out">
|
||||
<div style={{ position: 'relative', height: 150, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<AChip menu />
|
||||
</div>
|
||||
</AStateCard>
|
||||
</AStates>
|
||||
);
|
||||
}
|
||||
function Skel({ w, h, r = 6 }) {
|
||||
return <div style={{ width: w, height: h, borderRadius: r, background: 'linear-gradient(90deg, rgba(237,234,255,.06), rgba(237,234,255,.12), rgba(237,234,255,.06))' }} />;
|
||||
}
|
||||
|
||||
Object.assign(window, { HFAdmin, HFAdminStates });
|
||||
@@ -0,0 +1,205 @@
|
||||
// hf-bootstrap.jsx — Bootstrap (PUC-10 / PUC-11) hi-fi. Terminals + runbook.
|
||||
const { HF_H: B, HF_STARS: B_STARS, HF_DSC: BDS } = window;
|
||||
const { Eyebrow: BEyebrow, Soul: BSoul, Callout: BCallout } = BDS;
|
||||
const BMONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
|
||||
|
||||
function BBay({ children, pad = 32 }) {
|
||||
return <div className="hf" style={{ height: '100%', background: `${B_STARS}, ${B.sky}`, padding: pad, boxSizing: 'border-box', overflow: 'hidden', fontFamily: B.body, display: 'flex', flexDirection: 'column' }}>{children}</div>;
|
||||
}
|
||||
function BHead({ kicker, title, sub }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<BEyebrow style={{ margin: '0 0 9px' }}>{kicker}</BEyebrow>
|
||||
<h2 style={{ fontFamily: B.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 23, color: B.star, margin: '0 0 7px', lineHeight: 1.1 }}>{title}</h2>
|
||||
{sub && <p style={{ fontFamily: B.body, fontSize: 13.5, lineHeight: 1.5, color: B.soft, margin: 0, maxWidth: 580 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BTerminal({ title, lines, style = {} }) {
|
||||
return (
|
||||
<div style={{ background: B.night, border: `1px solid ${B.hairCard}`, borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: B.shadow, ...style }}>
|
||||
<div style={{ flex: '0 0 auto', height: 42, padding: '0 15px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: `1px solid ${B.hair}`, background: 'rgba(155,140,255,.05)' }}>
|
||||
<span style={{ display: 'flex', gap: 7 }}>
|
||||
{['rgba(155,140,255,.55)', 'rgba(244,199,107,.55)', 'rgba(237,234,255,.32)'].map((c, i) => (
|
||||
<span key={i} style={{ width: 10, height: 10, borderRadius: 5, background: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 12, color: B.mute, marginLeft: 5 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '17px 19px', fontFamily: BMONO, fontSize: 12.5, lineHeight: 1.8, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
||||
{lines.map((ln, i) => <BLine key={i} ln={ln} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BLine({ ln }) {
|
||||
const [k, t] = ln;
|
||||
if (k === 'sp') return <div style={{ height: 10 }} />;
|
||||
if (k === 'cmd') return <div><span style={{ color: B.lilac }}>$ </span><span style={{ color: B.star }}>{t}</span></div>;
|
||||
if (k === 'step') return <div><span style={{ color: B.lilac }}>→ </span><span style={{ color: B.soft }}>{t}</span></div>;
|
||||
if (k === 'ok') return <div><span style={{ color: B.gold }}>✓ </span><span style={{ color: B.star }}>{t}</span></div>;
|
||||
if (k === 'hl') return <div style={{ color: B.gold, background: 'rgba(244,199,107,.09)', margin: '0 -9px', padding: '2px 9px', borderRadius: 5 }}>{t}</div>;
|
||||
if (k === 'note') return <div style={{ color: B.faint, fontStyle: 'italic' }}>{t}</div>;
|
||||
return <div style={{ color: B.mute }}>{t}</div>;
|
||||
}
|
||||
|
||||
// 1 · principle ----------------------------------------------------------------
|
||||
function HFBootPrinciple() {
|
||||
const steps = [
|
||||
['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
|
||||
['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
|
||||
['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
|
||||
['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
|
||||
['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
|
||||
];
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="INV-1 · the bootstrap property" title="Empty is a working state"
|
||||
sub="No seed step, ever. A fresh, empty database is a first-class state the product brings to life itself — which is what makes launching anywhere an ordinary, rehearsed act." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{steps.map(([t, d, c], i) => {
|
||||
const col = c === 'gold' ? B.gold : B.lilac;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 16, paddingBottom: i < steps.length - 1 ? 18 : 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '0 0 auto' }}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 14, border: `1.5px solid ${col}`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: B.disp, fontWeight: 700, fontSize: 13.5 }}>{i + 1}</span>
|
||||
{i < steps.length - 1 && <span style={{ width: 1.5, flex: 1, minHeight: 22, background: B.hair }} />}
|
||||
</div>
|
||||
<div style={{ paddingTop: 3 }}>
|
||||
<div style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 15, color: B.star }}>{t}</div>
|
||||
<div style={{ fontFamily: BMONO, fontSize: 11.5, color: B.mute, marginTop: 3 }}>{d}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 · runbook (Paper) ----------------------------------------------------------
|
||||
function HFBootRunbook() {
|
||||
const envs = [
|
||||
['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
|
||||
['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
|
||||
['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
|
||||
];
|
||||
return (
|
||||
<div style={{ height: '100%', background: 'var(--wv-paper)', padding: '36px 40px', boxSizing: 'border-box', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 12, color: 'var(--wv-violet)', background: 'rgba(124,111,224,.12)', padding: '4px 11px', borderRadius: 999 }}>docs/BOOTSTRAP.md</span>
|
||||
<span style={{ fontFamily: BMONO, fontSize: 11.5, color: '#8A7FB0' }}>the documented gesture</span>
|
||||
</div>
|
||||
<h2 style={{ fontFamily: B.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, color: 'var(--wv-ink)', margin: '0 0 8px', lineHeight: 1.08 }}>Bringing ecomm up from empty</h2>
|
||||
<p style={{ fontFamily: B.body, fontSize: 14, lineHeight: 1.55, color: '#4B4170', margin: '0 0 24px', maxWidth: 560 }}>
|
||||
One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{envs.map(([env, cmd, note]) => (
|
||||
<div key={env} style={{ display: 'grid', gridTemplateColumns: '132px 1fr', gap: 18, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 14, color: 'var(--wv-ink)' }}>{env}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: BMONO, fontSize: 12.5, color: 'var(--wv-ink)', background: '#ECE8F7', border: '1px solid #DCD5F0', borderRadius: 8, padding: '8px 12px', display: 'inline-block' }}>{cmd}</div>
|
||||
<div style={{ fontFamily: B.body, fontSize: 12.5, lineHeight: 1.5, color: '#6A5F90', marginTop: 7 }}>{note}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<BCallout onLight>Empty is a working state — there is no seed.</BCallout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 · localhost terminal -------------------------------------------------------
|
||||
function HFBootDev() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-10 · localhost" title="One command from clean checkout"
|
||||
sub="A developer reaches “first merchant, first storefront” with Docker as the only prerequisite — the app migrates an empty database itself." />
|
||||
<BTerminal title="scripts/dev.sh" style={{ flex: 1 }} lines={[
|
||||
['cmd', 'git clone …/wiggleverse-ecomm && cd wiggleverse-ecomm'],
|
||||
['cmd', './scripts/dev.sh'],
|
||||
['sp'],
|
||||
['step', 'datastore: starting postgres (docker compose up)…'],
|
||||
['ok', 'postgres healthy · ecomm_dev · :5432'],
|
||||
['step', 'migrations: applying pending…'],
|
||||
['ok', '0001_init applied · schema current'],
|
||||
['step', 'backend: uvicorn on :8000'],
|
||||
['step', 'web: vite on :5173 (proxying /api)'],
|
||||
['ok', '/healthz 200 {status:"ok", migrations:"current"}'],
|
||||
['sp'],
|
||||
['out', ' ecomm is up against an empty database.'],
|
||||
['hl', ' open http://localhost:5173'],
|
||||
['sp'],
|
||||
['note', 'one-time codes print to this log (LogMailer) — no real mail in dev.'],
|
||||
]} />
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
// 4 · the OTC channel ----------------------------------------------------------
|
||||
function HFBootChannel() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-10 / PUC-11 · the OTC channel" title="Where the one-time code lands"
|
||||
sub="Same issue/verify path everywhere — only the mailer differs by configuration (INV-8)." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
<div>
|
||||
<ChanLabel tone="lilac">Dev — the code prints to the backend log</ChanLabel>
|
||||
<BTerminal title="backend · LogMailer" lines={[
|
||||
['out', '[mailer] adapter=LogMailer'],
|
||||
['out', ' to: ben@bensbets.com'],
|
||||
['hl', ' subject: Your ecomm code: 419 204'],
|
||||
['out', ' code valid 10m · single-use · 5 tries'],
|
||||
]} />
|
||||
</div>
|
||||
<div>
|
||||
<ChanLabel tone="gold">Deployed — the code arrives as real email</ChanLabel>
|
||||
<div style={{ background: B.raised, border: `1px solid ${B.hairCard}`, borderRadius: 12, padding: 17 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: B.body, fontSize: 12, color: B.mute, marginBottom: 9, borderBottom: `1px solid ${B.hair}`, paddingBottom: 9 }}>
|
||||
<span>ecomm <no-reply@ecomm.wiggleverse.org></span><span>now</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: B.disp, fontWeight: 500, fontSize: 15.5, color: B.star, marginBottom: 7 }}>Your ecomm code: 419 204</div>
|
||||
<div style={{ fontFamily: B.body, fontSize: 12.5, lineHeight: 1.5, color: B.soft }}>
|
||||
Enter this code to sign in. It's good for 10 minutes. If you didn't request it, you can ignore this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
function ChanLabel({ children, tone }) {
|
||||
const c = tone === 'gold' ? B.gold : B.lilac;
|
||||
return <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, fontFamily: B.body, fontSize: 12.5, color: B.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c }}>{tone === 'gold' ? '◆' : '◇'}</span>{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// 5 · deploy terminal ----------------------------------------------------------
|
||||
function HFBootDeploy() {
|
||||
return (
|
||||
<BBay>
|
||||
<BHead kicker="PUC-11 · pre-prod, then prod" title="The same gesture, deployed"
|
||||
sub="The operator runs flotilla; PPE rehearses Prod with real email (BUC-5a). No environment is “later.”" />
|
||||
<BTerminal title="flotilla — operator front door" style={{ flex: 1 }} lines={[
|
||||
['cmd', 'flotilla deploy ecomm --env ppe'],
|
||||
['sp'],
|
||||
['step', 'provision: Cloud SQL (postgres) · backups + PITR…'],
|
||||
['ok', 'database ready · secrets resolved from Secret Manager'],
|
||||
['step', 'deploy: image → single VM · fail-stop'],
|
||||
['step', 'migrations: applying at startup…'],
|
||||
['ok', '0001_init applied'],
|
||||
['ok', '/healthz 200 — ppe live'],
|
||||
['out', ' rehearsal: real sign-up (real email) → storefront → admin ✓'],
|
||||
['sp'],
|
||||
['cmd', 'flotilla deploy ecomm --env prod'],
|
||||
['ok', 'same gesture — day-one prod is the bootstrap state'],
|
||||
]} />
|
||||
</BBay>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFBootPrinciple, HFBootRunbook, HFBootDev, HFBootChannel, HFBootDeploy });
|
||||
@@ -0,0 +1,87 @@
|
||||
// hf-intro.jsx — Hi-fi cover: the design language Direction A locks in.
|
||||
const { HFScreen: IScreen, HFWordmark: IWordmark, HF_H: I, HF_DSC: IDS } = window;
|
||||
const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
|
||||
|
||||
function HFCover() {
|
||||
const langs = [
|
||||
['Ground', 'Midnight sky with a barely-there starfield; a gold horizon glow warms the bottom of entry screens.'],
|
||||
['Depth', 'Carried by layered surfaces and hairlines — not drop shadows. Only the auth card earns real elevation.'],
|
||||
['Type', 'Space Grotesk for structure, Inter for body — calm and precise, never hypey. Sentence case throughout; uppercase only for small eyebrows.'],
|
||||
['Action', 'One gold CTA per screen. Lilac for links and nodes. Hover lifts 1px; focus is a gold ring.'],
|
||||
['Attention', 'Gold, always — the palette carries no red. Honest failure, never a faked success.'],
|
||||
['Emptiness', 'A finished state with its own composition — no zeroed tiles, no locked-feature teasers.'],
|
||||
];
|
||||
return (
|
||||
<IScreen horizon style={{ padding: '44px 46px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={28} sub />
|
||||
<ITag variant="live">SD-0001 · Hi-fi · Direction A</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 30 }}>
|
||||
<IEyebrow style={{ margin: '0 0 14px' }}>Quiet centered — the full design</IEyebrow>
|
||||
<h1 style={{ fontFamily: I.disp, fontWeight: 700, letterSpacing: '-0.02em', fontSize: 40, lineHeight: 1.03, color: I.star, margin: '0 0 12px' }}>
|
||||
The language, before<br />the hundred screens
|
||||
</h1>
|
||||
<p style={{ fontFamily: I.body, fontSize: 15.5, lineHeight: 1.6, color: I.soft, maxWidth: 620, margin: 0, textWrap: 'pretty' }}>
|
||||
Every surface below is the same six decisions, applied. Get these right and the next ninety-six
|
||||
screens inherit a calm, honest house style for free.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 30 }}>
|
||||
{langs.map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', gap: 13, padding: '15px 17px', borderRadius: 12, background: I.raised, border: `1px solid ${I.hairCard}` }}>
|
||||
<span style={{ flex: '0 0 auto', width: 7, height: 7, borderRadius: 4, background: I.gold, marginTop: 6 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: I.disp, fontWeight: 500, fontSize: 14.5, color: I.star, marginBottom: 3 }}>{k}</div>
|
||||
<div style={{ fontFamily: I.body, fontSize: 13, lineHeight: 1.5, color: I.mute }}>{v}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// Entry routing — the shared spine, hi-fi
|
||||
function HFRouting() {
|
||||
const lanes = [
|
||||
{ sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
|
||||
{ sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
|
||||
{ sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
|
||||
];
|
||||
return (
|
||||
<IScreen style={{ padding: '44px 40px' }}>
|
||||
<IEyebrow style={{ margin: '0 0 14px' }}>The shared spine</IEyebrow>
|
||||
<h1 style={{ fontFamily: I.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 28, lineHeight: 1.08, color: I.star, margin: '0 0 10px' }}>Entry routing</h1>
|
||||
<p style={{ fontFamily: I.body, fontSize: 14, lineHeight: 1.55, color: I.soft, margin: '0 0 8px' }}>
|
||||
One server answer — <code style={{ fontFamily: 'ui-monospace,monospace', fontSize: 12.5, color: I.lilac, background: 'var(--wv-lilac-08)', padding: '1px 6px', borderRadius: 5 }}>GET /api/auth/me</code> — decides where every arrival lands.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, marginTop: 26 }}>
|
||||
{lanes.map((l, i) => {
|
||||
const c = l.tone === 'gold' ? I.gold : l.tone === 'lilac' ? I.lilac : I.mute;
|
||||
return (
|
||||
<div key={i} className="row-lift" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '17px 19px', borderRadius: 12, background: I.raised, border: `1px solid ${I.hairCard}` }}>
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto' }}>
|
||||
<Cond>{l.sess}</Cond><Cond>{l.store}</Cond>
|
||||
</div>
|
||||
<span aria-hidden="true" style={{ color: c, fontSize: 18, flex: '0 0 auto' }}>→</span>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontFamily: I.disp, fontWeight: 700, fontSize: 17, color: c, letterSpacing: '-0.01em' }}>{l.arrow}</span>
|
||||
<span style={{ fontFamily: I.body, fontSize: 11.5, color: I.mute }}>{l.puc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontFamily: I.body, fontSize: 13, lineHeight: 1.55, color: I.mute, margin: '24px 0 0', display: 'flex', gap: 9 }}>
|
||||
<span aria-hidden="true" style={{ color: I.lilac }}>◇</span>
|
||||
Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
|
||||
</p>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
function Cond({ children }) {
|
||||
return <span style={{ fontFamily: I.body, fontSize: 13, color: I.soft, padding: '6px 12px', borderRadius: 999, background: 'rgba(237,234,255,.05)', border: `1px solid ${I.hair}`, whiteSpace: 'nowrap' }}>{children}</span>;
|
||||
}
|
||||
|
||||
Object.assign(window, { HFCover, HFRouting });
|
||||
@@ -0,0 +1,267 @@
|
||||
// hf-kit.jsx — Wiggleverse ecomm HI-FI primitives (Direction A · Quiet centered).
|
||||
// Depth = layered surfaces + hairlines (the brand avoids shadows). Hover = 1–3px lift.
|
||||
// Merchant persona: Ben's Bets · ben@bensbets.com
|
||||
|
||||
const HFDS = window.WiggleverseDesignSystem_94cd80;
|
||||
const { Button: HFButton, Soul: HFSoul, Eyebrow: HFEyebrow, Tag: HFTag } = HFDS;
|
||||
|
||||
// Resolve a brand asset: blob URL when bundled offline (window.__resources), else relative path.
|
||||
const HF_ASSET = (id, path) => (window.__resources && window.__resources[id]) || path;
|
||||
window.HF_ASSET = HF_ASSET;
|
||||
|
||||
// ---- token shorthands ----------------------------------------------------
|
||||
const H = {
|
||||
sky: 'var(--wv-midnight)', night: 'var(--wv-night)',
|
||||
raised: 'var(--wv-indigo)', raisedHi: 'var(--wv-indigo-2)',
|
||||
star: 'var(--wv-starlight)', soft: 'var(--wv-starlight-78)',
|
||||
mute: 'var(--wv-starlight-60)', faint: 'var(--wv-starlight-55)',
|
||||
lilac: 'var(--wv-lilac)', violet: 'var(--wv-violet)',
|
||||
gold: 'var(--wv-gold)', goldHi: 'var(--wv-gold-hi)', goldInk: 'var(--wv-gold-ink)',
|
||||
hair: 'var(--border-soft)', hairCard: 'var(--border-card)', hairStrong: 'var(--border-strong)',
|
||||
disp: 'var(--wv-font-display)', body: 'var(--wv-font-body)', soul: 'var(--wv-font-human)',
|
||||
shadow: 'var(--shadow-soft)',
|
||||
};
|
||||
const MENU_EMAIL = 'ben@bensbets.com';
|
||||
const STORE = "Ben's Bets";
|
||||
|
||||
// barely-there starfield (Direction A: restrained) — a few asymmetric nodes
|
||||
const HF_STARS =
|
||||
'radial-gradient(1.3px 1.3px at 16% 22%, rgba(237,234,255,.34), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 78% 14%, rgba(237,234,255,.22), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 88% 46%, rgba(155,140,255,.30), transparent),' +
|
||||
'radial-gradient(1.3px 1.3px at 30% 72%, rgba(237,234,255,.24), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 62% 86%, rgba(237,234,255,.18), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 7% 56%, rgba(155,140,255,.26), transparent)';
|
||||
// gold horizon — the brand's warmth, a low glow at the bottom of the sky
|
||||
const HF_HORIZON = 'radial-gradient(120% 70% at 50% 142%, rgba(244,199,107,.16) 0%, rgba(244,199,107,.05) 38%, transparent 64%)';
|
||||
|
||||
// One-time injected stylesheet: hover + focus affordances (subtle motion only).
|
||||
function HFStyles() {
|
||||
return (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.hf { --r: var(--radius-card); }
|
||||
.hf a.lk { color: var(--wv-lilac); text-decoration: none; transition: color .15s ease; }
|
||||
.hf a.lk:hover { color: var(--wv-gold); }
|
||||
.hf .row-lift { transition: transform .15s ease, border-color .15s ease, background .15s ease; }
|
||||
.hf .row-lift:hover { transform: translateY(-1px); border-color: var(--border-strong); background: var(--wv-indigo-2); }
|
||||
.hf .field { transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; }
|
||||
.hf .field:hover { border-color: var(--border-strong); }
|
||||
.hf .field.is-focus { border-color: var(--wv-gold); box-shadow: 0 0 0 3px var(--wv-gold-28); background: rgba(237,234,255,.06); }
|
||||
.hf .field.is-attn { border-color: var(--wv-gold); }
|
||||
.hf .caret { display:inline-block; width:1.5px; height:1.1em; background: var(--wv-gold); margin-left:1px; vertical-align:-2px; animation: hfblink 1.1s steps(1) infinite; }
|
||||
@keyframes hfblink { 50% { opacity: 0; } }
|
||||
.hf .signout { font-family: var(--wv-font-display); font-weight:500; font-size:14px; color: var(--wv-starlight); background:transparent; border:none; cursor:pointer; padding:0; transition: color .15s ease; }
|
||||
.hf .signout:hover { color: var(--wv-gold); }
|
||||
.hf .navitem { transition: background .15s ease, color .15s ease; }
|
||||
` }} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ground ──────────────────────────────────────────────────────────
|
||||
function HFScreen({ children, horizon = false, stars = true, bg = H.sky, style = {} }) {
|
||||
const layers = [];
|
||||
if (stars) layers.push(HF_STARS);
|
||||
if (horizon) layers.unshift(HF_HORIZON);
|
||||
const background = layers.length ? `${layers.join(',')}, ${bg}` : bg;
|
||||
return (
|
||||
<div className="hf" style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', background,
|
||||
color: H.star, fontFamily: H.body, display: 'flex', flexDirection: 'column',
|
||||
position: 'relative', overflow: 'hidden', ...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wordmark — mark tile + "ecomm" in Space Grotesk ───────────────────────
|
||||
function HFWordmark({ size = 26, sub = false }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size * 0.42 }}>
|
||||
<img src={HF_ASSET('markTile', 'assets/mark-tile.svg')} width={size} height={size}
|
||||
style={{ borderRadius: size * 0.22, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: size * 0.3 }}>
|
||||
<span style={{ fontFamily: H.disp, fontWeight: 700, fontSize: size * 0.86, letterSpacing: '-0.015em', color: H.star }}>ecomm</span>
|
||||
{sub && <span style={{ fontFamily: H.body, fontSize: size * 0.42, color: H.mute, letterSpacing: '0.02em' }}>by Wiggleverse</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TopBar — glass chrome ──────────────────────────────────────────────────
|
||||
function HFTopBar({ left, right, pad = '0 36px', height = 68 }) {
|
||||
return (
|
||||
<header style={{
|
||||
flex: '0 0 auto', height, padding: pad, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', borderBottom: `1px solid ${H.hair}`,
|
||||
background: 'var(--glass-sky)', backdropFilter: 'blur(var(--glass-blur))',
|
||||
position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>{left}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HFAccountChip({ email = MENU_EMAIL, menu = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, position: 'relative' }}>
|
||||
<span style={{ width: 30, height: 30, borderRadius: 15, background: 'var(--wv-lilac-16)', border: `1px solid ${H.hairCard}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: H.disp, fontWeight: 700, fontSize: 13, color: H.star }}>B</span>
|
||||
<span style={{ fontFamily: H.body, fontSize: 14, color: H.soft }}>{email}</span>
|
||||
<span style={{ width: 1, height: 18, background: H.hair }} />
|
||||
<button className="signout">Sign out</button>
|
||||
{menu && (
|
||||
<div style={{ position: 'absolute', top: 40, right: 0, minWidth: 216, zIndex: 9, background: H.raised, border: `1px solid ${H.hairCard}`, borderRadius: 12, padding: 7, boxShadow: H.shadow }}>
|
||||
<div style={{ padding: '9px 12px' }}>
|
||||
<div style={{ fontFamily: H.disp, fontWeight: 500, fontSize: 13.5, color: H.star }}>Ben</div>
|
||||
<div style={{ fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{email}</div>
|
||||
</div>
|
||||
<div style={{ borderTop: `1px solid ${H.hair}`, margin: '6px 4px' }} />
|
||||
<div className="navitem" style={{ padding: '9px 12px', fontFamily: H.body, fontSize: 13.5, color: H.soft, borderRadius: 8, cursor: 'pointer' }}>Account settings</div>
|
||||
<div className="navitem" style={{ padding: '9px 12px', fontFamily: H.body, fontSize: 13.5, color: H.gold, fontWeight: 600, borderRadius: 8, cursor: 'pointer' }}>Sign out</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field ──────────────────────────────────────────────────────────────────
|
||||
function HFField({ label, placeholder, value, helper, error, focus = false, optional = false, hint }) {
|
||||
const cls = `field${focus ? ' is-focus' : ''}${error ? ' is-attn' : ''}`;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ fontFamily: H.body, fontWeight: 500, fontSize: 13.5, color: H.soft, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ fontSize: 12.5, color: H.mute, fontWeight: 400 }}>optional</span>}
|
||||
</label>
|
||||
<div className={cls} style={{
|
||||
height: 52, borderRadius: 10, padding: '0 16px', display: 'flex', alignItems: 'center',
|
||||
background: 'rgba(237,234,255,.035)', border: `1.5px solid ${H.hair}`,
|
||||
fontFamily: H.body, fontSize: 16, color: value ? H.star : H.mute,
|
||||
}}>
|
||||
{value || placeholder}
|
||||
{focus && !value && <span className="caret" />}
|
||||
{hint && <span style={{ marginLeft: 'auto', fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{hint}</span>}
|
||||
</div>
|
||||
{error && <HFNote tone="attn">{error}</HFNote>}
|
||||
{helper && !error && <HFNote>{helper}</HFNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HFNote({ children, tone }) {
|
||||
return <span style={{ fontFamily: H.body, fontSize: 13, lineHeight: 1.45, color: tone === 'attn' ? H.gold : H.mute, display: 'flex', gap: 7, alignItems: 'baseline' }}>
|
||||
{tone === 'attn' && <span aria-hidden="true" style={{ color: H.gold, fontSize: 11 }}>◆</span>}
|
||||
<span>{children}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
// ── CodeField — 6 cells ─────────────────────────────────────────────────────
|
||||
function HFCodeField({ filled = 0, error = false, vals = ['4', '1', '9', '2', '0', '4'] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 11 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => {
|
||||
const active = i === filled && !error;
|
||||
const has = i < filled;
|
||||
return (
|
||||
<div key={i} className={`field${active ? ' is-focus' : ''}${error ? ' is-attn' : ''}`} style={{
|
||||
width: 52, height: 64, borderRadius: 11, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(237,234,255,.035)', border: `1.5px solid ${H.hair}`,
|
||||
fontFamily: H.disp, fontWeight: 500, fontSize: 26, color: H.star,
|
||||
}}>
|
||||
{has ? vals[i] : (active ? <span className="caret" style={{ height: 28 }} /> : '')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner — gold attention / lilac info (no red in palette) ───────────────
|
||||
function HFBanner({ children, tone = 'attn', title }) {
|
||||
const attn = tone === 'attn';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, padding: '14px 16px', borderRadius: 12,
|
||||
background: attn ? 'rgba(244,199,107,.10)' : 'var(--wv-lilac-08)',
|
||||
border: `1px solid ${attn ? 'var(--wv-gold-40)' : 'var(--wv-lilac-18)'}`,
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ color: attn ? H.gold : H.lilac, fontSize: 13, lineHeight: '22px' }}>{attn ? '◆' : '◇'}</span>
|
||||
<div style={{ fontFamily: H.body, fontSize: 14, lineHeight: 1.5, color: H.soft }}>
|
||||
{title && <div style={{ color: H.star, fontWeight: 600, marginBottom: 2 }}>{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AuthCard — the one surface that earns a real shadow ────────────────────
|
||||
function HFAuthCard({ children, width = 440 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, maxWidth: '100%', boxSizing: 'border-box', background: H.raised,
|
||||
border: `1px solid ${H.hairCard}`, borderRadius: 'var(--radius-card)',
|
||||
padding: '40px 40px 34px', display: 'flex', flexDirection: 'column', gap: 22,
|
||||
boxShadow: H.shadow, position: 'relative', zIndex: 2,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Primary action wrapper (DS Button, full-width option) ──────────────────
|
||||
function HFPrimary({ children, full = true, disabled = false }) {
|
||||
return (
|
||||
<HFButton variant="primary" href={disabled ? undefined : '#'} disabled={disabled}
|
||||
style={{ justifyContent: 'center', width: full ? '100%' : 'auto', padding: '.85rem 1.4rem', fontSize: 15.5, boxSizing: 'border-box' }}>
|
||||
{children}
|
||||
</HFButton>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────────────
|
||||
function HFFooter({ pad = '20px 36px' }) {
|
||||
return (
|
||||
<footer style={{ flex: '0 0 auto', padding: pad, borderTop: `1px solid ${H.hair}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, fontFamily: H.body, fontSize: 12.5, color: H.mute, background: 'rgba(9,12,34,.4)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9 }}>
|
||||
<img src={HF_ASSET('markGold', 'assets/mark-mono-gold.svg')} width={16} height={16} alt="" style={{ opacity: 0.85 }} />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', gap: 18 }}>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Privacy</a>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Open Core</a>
|
||||
<a className="lk" href="#" style={{ color: H.mute }}>Terms</a>
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heading helper ───────────────────────────────────────────────────────────
|
||||
function HFHeading({ children, sub, size = 27 }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: H.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: size, color: H.star, margin: '0 0 9px', lineHeight: 1.08 }}>{children}</h1>
|
||||
{sub && <p style={{ fontFamily: H.body, fontSize: 14.5, lineHeight: 1.55, color: H.soft, margin: 0 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hfHonest = { fontFamily: H.body, fontSize: 13, color: H.mute, lineHeight: 1.5, margin: 0 };
|
||||
|
||||
// ── StateCard — frames a single state in the states stacks ─────────────────
|
||||
function HFStateCard({ label, note, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
<HFTag variant="soon">{label}</HFTag>
|
||||
{note && <span style={{ fontFamily: H.body, fontSize: 12.5, color: H.mute }}>{note}</span>}
|
||||
</div>
|
||||
<div style={{ background: H.raised, border: `1px solid ${H.hairCard}`, borderRadius: 12, padding: 22 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function HFStates({ children }) {
|
||||
return <div className="hf" style={{ height: '100%', background: `${HF_STARS}, ${H.sky}`, padding: 26, boxSizing: 'border-box', overflow: 'hidden', fontFamily: H.body, display: 'flex', flexDirection: 'column', gap: 20 }}>{children}</div>;
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
HF_H: H, HF_STARS, HF_HORIZON, HF_STORE: STORE, HF_EMAIL: MENU_EMAIL, HF_DSC: HFDS,
|
||||
HFStyles, HFScreen, HFWordmark, HFTopBar, HFAccountChip, HFField, HFNote, HFCodeField,
|
||||
HFBanner, HFAuthCard, HFPrimary, HFFooter, HFHeading, HFStateCard, HFStates,
|
||||
hfHonest,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// hf-landing.jsx — Landing (PUC-1), Direction A hi-fi. Desktop + mobile.
|
||||
const { HFScreen: LScreen, HFWordmark: LWordmark, HFTopBar: LTopBar, HFFooter: LFooter,
|
||||
HFPrimary: LPrimary, HF_H: L, HF_DSC: LDS } = window;
|
||||
const { Soul: LSoul, Eyebrow: LEyebrow } = LDS;
|
||||
|
||||
function NavLinks({ mobile = false }) {
|
||||
if (mobile) return <a className="lk" href="#" style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14, color: L.star }}>Log in</a>;
|
||||
return (
|
||||
<nav style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14, color: L.soft }}>How it works</a>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14, color: L.soft }}>Open Core</a>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14, color: L.star }}>Log in</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// trust row — three honest promises, hairline-separated (no icons-as-slop)
|
||||
function Promises({ device }) {
|
||||
const items = [
|
||||
['No trial clock', 'Your storefront never expires or locks.'],
|
||||
['No plan wall', 'We take only what it takes to run.'],
|
||||
['Your data, yours', "Nothing collected you didn't agree to give."],
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: device === 'desktop' ? 'repeat(3, 1fr)' : '1fr',
|
||||
gap: device === 'desktop' ? 0 : 14, marginTop: device === 'desktop' ? 44 : 28,
|
||||
borderTop: `1px solid ${L.hair}`, paddingTop: device === 'desktop' ? 24 : 20,
|
||||
}}>
|
||||
{items.map(([t, d], i) => (
|
||||
<div key={t} style={{
|
||||
padding: device === 'desktop' ? '0 22px' : 0,
|
||||
borderLeft: device === 'desktop' && i > 0 ? `1px solid ${L.hair}` : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 5 }}>
|
||||
<span aria-hidden="true" style={{ color: L.gold, fontSize: 10 }}>◆</span>
|
||||
<span style={{ fontFamily: L.disp, fontWeight: 500, fontSize: 14.5, color: L.star }}>{t}</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: L.body, fontSize: 13, lineHeight: 1.5, color: L.mute, margin: 0 }}>{d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HFLanding({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<LScreen horizon>
|
||||
<LTopBar pad={desktop ? '0 44px' : '0 22px'}
|
||||
left={<LWordmark size={desktop ? 26 : 22} />} right={<NavLinks mobile={!desktop} />} />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
|
||||
padding: desktop ? '0 44px' : '0 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: desktop ? 680 : '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<LEyebrow style={{ margin: '0 0 20px' }}>One storefront, fully yours</LEyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: L.disp, fontWeight: 700, letterSpacing: '-0.02em', lineHeight: 1.02,
|
||||
fontSize: desktop ? 62 : 36, color: L.star, margin: '0 0 22px',
|
||||
}}>
|
||||
Sell online,<br /><span style={{ color: L.gold }}>honestly.</span>
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: L.body, fontSize: desktop ? 18.5 : 15.5, lineHeight: 1.6, color: L.soft,
|
||||
maxWidth: 520, margin: '0 0 32px', textWrap: 'pretty',
|
||||
}}>
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: desktop ? 'row' : 'column', gap: 14, alignItems: 'center', width: desktop ? 'auto' : '100%' }}>
|
||||
<div style={{ width: desktop ? 'auto' : '100%' }}><LPrimary full={!desktop}>Create your storefront →</LPrimary></div>
|
||||
<a className="lk" href="#" style={{ fontFamily: L.body, fontSize: 14.5, color: L.mute }}>
|
||||
Already selling with us? <span style={{ color: L.lilac }}>Log in</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ width: '100%', marginTop: desktop ? 8 : 4 }}><Promises device={device} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<LFooter pad={desktop ? '20px 44px' : '16px 22px'} />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFLanding });
|
||||
@@ -0,0 +1,116 @@
|
||||
// hf-signin.jsx — Sign in (PUC-2 / PUC-3) hi-fi. Email → one-time code + states.
|
||||
const { HFScreen: SScreen, HFWordmark: SWordmark, HFTopBar: STopBar, HFFooter: SFooter,
|
||||
HFAuthCard: SAuthCard, HFField: SField, HFCodeField: SCodeField, HFBanner: SBanner,
|
||||
HFPrimary: SPrimary, HFHeading: SHeading, HFStateCard: SStateCard, HFStates: SStates,
|
||||
HF_H: S, hfHonest: sHonest } = window;
|
||||
|
||||
function SBack({ label = 'Back' }) {
|
||||
return <a className="lk" href="#" style={{ fontFamily: S.body, fontSize: 14, color: S.mute }}>← {label}</a>;
|
||||
}
|
||||
|
||||
// ── Step 1 · email ──────────────────────────────────────────────────────────
|
||||
function EmailStep({ framing = 'signup', s = {} }) {
|
||||
const login = framing === 'login';
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 2 }}><SWordmark size={24} /></div>
|
||||
<SHeading sub={login ? 'Enter your email and we\u2019ll send a one-time code to sign in.' : 'Enter your email to begin. There\u2019s no password to create.'}>
|
||||
{login ? 'Log in' : 'Create your storefront'}
|
||||
</SHeading>
|
||||
{s.deliveryFail && <SBanner title="We couldn't send the code">The email didn't go out. Try again in a moment — nothing was lost.</SBanner>}
|
||||
<SField label="Email" placeholder="you@example.com" value={s.email}
|
||||
focus={!s.emailError && !s.email} error={s.emailError} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SPrimary disabled={!!s.cooldown}>{s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}</SPrimary>
|
||||
<p style={sHonest}>We'll email you a one-time code. That's all we need — no password, ever.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2 · code ────────────────────────────────────────────────────────────
|
||||
function CodeStep({ s = {} }) {
|
||||
const email = s.email || 'ben@bensbets.com';
|
||||
let err = null;
|
||||
if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
|
||||
if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 2 }}><SWordmark size={24} /></div>
|
||||
<SHeading sub={<>We sent a 6-digit code to <span style={{ color: S.star }}>{email}</span>. <a className="lk" href="#">Wrong address?</a></>}>
|
||||
Check your email
|
||||
</SHeading>
|
||||
{s.welcome && <SBanner tone="info" title={s.welcome === 'new' ? 'Welcome to ecomm' : 'Welcome back'}>
|
||||
{s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
|
||||
</SBanner>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
|
||||
<SCodeField filled={s.filled ?? 3} error={!!(s.codeWrong || s.codeExpired)} />
|
||||
{err && <span style={{ fontFamily: S.body, fontSize: 13, color: S.gold, display: 'flex', gap: 6, alignSelf: 'flex-start' }}><span aria-hidden="true" style={{ fontSize: 11 }}>◆</span>{err}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SPrimary>Continue</SPrimary>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p style={sHonest}>Good for 10 minutes.</p>
|
||||
<a className="lk" href="#" style={{ fontFamily: S.disp, fontWeight: 500, fontSize: 13.5, color: s.cooldown ? S.mute : S.lilac }}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Resend code'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HFSignIn({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<SScreen horizon>
|
||||
<STopBar pad={desktop ? '0 36px' : '0 22px'} left={<SWordmark size={desktop ? 22 : 20} />} right={<SBack />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<SAuthCard width={desktop ? 432 : '100%'}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</SAuthCard>
|
||||
</div>
|
||||
{desktop && <SFooter />}
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── States stack ─────────────────────────────────────────────────────────────
|
||||
function codeNote(text) {
|
||||
return <span style={{ fontFamily: S.body, fontSize: 13, color: S.gold, display: 'flex', gap: 6, marginTop: 10 }}><span aria-hidden="true" style={{ fontSize: 11 }}>◆</span>{text}</span>;
|
||||
}
|
||||
function HFSignInStates() {
|
||||
return (
|
||||
<SStates>
|
||||
<SStateCard label="Invalid email" note="inline field error">
|
||||
<SField label="Email" value="ben@bensbets" error="That doesn't look like an email address." />
|
||||
</SStateCard>
|
||||
<SStateCard label="Resend cooldown" note="PUC-2c · 60s per email">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
||||
<SPrimary disabled>Resend in 47s</SPrimary>
|
||||
<span style={sHonest}>One code per minute keeps inboxes — and abuse — in check.</span>
|
||||
</div>
|
||||
</SStateCard>
|
||||
<SStateCard label="Delivery failed" note="502 · honest failure (INV-9)">
|
||||
<SBanner title="We couldn't send the code">The email didn't go out — try again. We'd never fake a success.</SBanner>
|
||||
</SStateCard>
|
||||
<SStateCard label="Wrong code" note="PUC-2a">
|
||||
<SCodeField filled={6} error />{codeNote("That code didn't match — 2 attempts left.")}
|
||||
</SStateCard>
|
||||
<SStateCard label="Expired code" note="PUC-2b · 10-min window">
|
||||
<SCodeField filled={6} error />{codeNote('That code expired. Send a fresh one below.')}
|
||||
</SStateCard>
|
||||
<SStateCard label="Attempts exhausted" note="5 tries · code invalidated (INV-3)">
|
||||
<SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>
|
||||
</SStateCard>
|
||||
<SStateCard label="Honest welcome" note="PUC-3 · same flow, true label">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SBanner tone="info" title="Welcome to ecomm">A new account was created for this email.</SBanner>
|
||||
<SBanner tone="info" title="Welcome back">Signed in to your existing account.</SBanner>
|
||||
</div>
|
||||
</SStateCard>
|
||||
</SStates>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFSignIn, HFSignInStates });
|
||||
@@ -0,0 +1,79 @@
|
||||
// hf-storefront.jsx — Create storefront (PUC-4/5/7) hi-fi + states.
|
||||
const { HFScreen: FScreen, HFWordmark: FWordmark, HFTopBar: FTopBar, HFAccountChip: FChip,
|
||||
HFFooter: FFooter, HFAuthCard: FAuthCard, HFField: FField, HFBanner: FBanner,
|
||||
HFPrimary: FPrimary, HFStateCard: FStateCard, HFStates: FStates, HF_H: F, HF_DSC: FDS } = window;
|
||||
const { Eyebrow: FEyebrow } = FDS;
|
||||
|
||||
function CreateForm({ s = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<FEyebrow style={{ margin: '0 0 12px' }}>One storefront, fully yours</FEyebrow>
|
||||
<h1 style={{ fontFamily: F.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, color: F.star, margin: '0 0 9px', lineHeight: 1.08 }}>
|
||||
Create your storefront
|
||||
</h1>
|
||||
<p style={{ fontFamily: F.body, fontSize: 14.5, lineHeight: 1.55, color: F.soft, margin: 0 }}>
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{s.alreadyOwns && <FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a className="lk" href="#">Go to your admin →</a>
|
||||
</FBanner>}
|
||||
<FField label="Storefront name" optional placeholder="e.g. Ben's Bets" value={s.name}
|
||||
focus={!s.name && !s.blankPreview} error={s.nameError}
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time." />
|
||||
{s.blankPreview && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginTop: -8, fontFamily: F.body, fontSize: 13, color: F.mute }}>
|
||||
<span aria-hidden="true" style={{ color: F.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: F.soft, fontStyle: 'italic' }}>“ben's storefront”</span> — change it later.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FPrimary disabled={s.creating}>{s.creating ? 'Creating…' : 'Create storefront'}</FPrimary>
|
||||
<p style={{ fontFamily: F.body, fontSize: 13, color: F.mute, lineHeight: 1.5, margin: 0 }}>
|
||||
You can rename, configure, or close your storefront whenever you like.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HFCreate({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<FScreen horizon>
|
||||
<FTopBar pad={desktop ? '0 36px' : '0 20px'} left={<FWordmark size={desktop ? 22 : 20} />}
|
||||
right={desktop ? <FChip /> : <button className="signout">Sign out</button>} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<FAuthCard width={desktop ? 448 : '100%'}><CreateForm s={s} /></FAuthCard>
|
||||
</div>
|
||||
{desktop && <FFooter />}
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function HFCreateStates() {
|
||||
return (
|
||||
<FStates>
|
||||
<FStateCard label="Blank name allowed" note="PUC-4 · default generated">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FField label="Storefront name" optional placeholder="e.g. Ben's Bets" helper="Leave blank and we'll name it for you." />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontFamily: F.body, fontSize: 13, color: F.mute }}>
|
||||
<span aria-hidden="true" style={{ color: F.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: F.soft, fontStyle: 'italic' }}>“ben's storefront”</span>
|
||||
</div>
|
||||
</div>
|
||||
</FStateCard>
|
||||
<FStateCard label="Creating" note="button busy, single submit">
|
||||
<FPrimary disabled>Creating…</FPrimary>
|
||||
</FStateCard>
|
||||
<FStateCard label="Already owns one" note="409 · defense in depth (PUC-7)">
|
||||
<FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a className="lk" href="#">Go to your admin →</a>
|
||||
</FBanner>
|
||||
</FStateCard>
|
||||
</FStates>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { HFCreate, HFCreateStates });
|
||||
@@ -0,0 +1,128 @@
|
||||
// wf-admin.jsx — Screen 4: Admin shell (PUC-8, PUC-9). A/B + states.
|
||||
const { Screen: AScreen, Wordmark: AWordmark, TopBar: ATopBar, AccountChip: AAccountChip,
|
||||
Banner: ABanner, EmptyAdmin: AEmptyAdmin, StateBlock: AStateBlock, WF_T: AT, WF_DS: ADS } = window;
|
||||
const { Eyebrow: AEyebrow } = ADS;
|
||||
|
||||
const STORE = 'Studio Fern';
|
||||
|
||||
// storefront identity lockup — the admin's anchor (left of top bar in A)
|
||||
function StoreId({ size = 17 }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 11 }}>
|
||||
<img src="assets/mark-tile.svg" width={size + 6} height={size + 6} style={{ borderRadius: 6, display: 'block' }} alt="" />
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', lineHeight: 1.1 }}>
|
||||
<span style={{ fontFamily: AT.disp, fontWeight: 700, fontSize: size, color: AT.star, letterSpacing: '-0.01em' }}>{STORE}</span>
|
||||
<span style={{ fontFamily: AT.body, fontSize: 11, color: AT.mute }}>ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — top bar + centered honest-empty ──────────────────────────
|
||||
function AdminA({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<AScreen>
|
||||
<ATopBar pad={desktop ? '0 32px' : '0 18px'} left={<StoreId size={desktop ? 17 : 15} />}
|
||||
right={desktop ? <AAccountChip /> : <button style={signOutBtn}>Sign out</button>} />
|
||||
<AEmptyAdmin storefront={STORE} compact={!desktop} />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — durable app shell (sidebar + content) ────────────────────
|
||||
function AdminB({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<AScreen>
|
||||
<ATopBar pad="0 18px" left={<StoreId size={15} />} right={<button style={signOutBtn}>Sign out</button>} />
|
||||
<div style={{ flex: '0 0 auto', padding: '12px 18px', borderBottom: `1px solid ${AT.hair}`, display: 'flex', gap: 8 }}>
|
||||
<NavItem active>Home</NavItem>
|
||||
</div>
|
||||
<AEmptyAdmin storefront={STORE} compact />
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AScreen style={{ flexDirection: 'row' }}>
|
||||
<Sidebar />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<header style={{ flex: '0 0 auto', height: 60, padding: '0 36px', display: 'flex', alignItems: 'center', borderBottom: `1px solid ${AT.hair}` }}>
|
||||
<span style={{ fontFamily: AT.disp, fontWeight: 500, fontSize: 15, color: AT.soft }}>Home</span>
|
||||
</header>
|
||||
<div style={{ flex: 1, display: 'flex' }}>
|
||||
<AEmptyAdmin storefront={STORE} />
|
||||
</div>
|
||||
</div>
|
||||
</AScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside style={{ flex: '0 0 258px', background: 'var(--wv-night)', borderRight: `1px solid ${AT.hair}`, display: 'flex', flexDirection: 'column', padding: '22px 16px' }}>
|
||||
<div style={{ padding: '0 8px 18px' }}><StoreId /></div>
|
||||
<div style={{ borderTop: `1px solid ${AT.hair}`, margin: '0 8px 16px' }} />
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<NavItem active full>Home</NavItem>
|
||||
</nav>
|
||||
<div style={{ flex: 1 }} />
|
||||
<p style={{ fontFamily: AT.body, fontSize: 11.5, lineHeight: 1.5, color: 'var(--wv-starlight-55)', padding: '0 10px', margin: '0 0 16px' }}>
|
||||
Catalog, orders & settings join this rail as ecomm grows. Nothing is shown here before it exists.
|
||||
</p>
|
||||
<div style={{ borderTop: `1px solid ${AT.hair}`, padding: '14px 10px 2px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<span style={{ fontFamily: AT.body, fontSize: 13, color: AT.soft }}>mara@studiofern.com</span>
|
||||
<button style={{ ...signOutBtn, fontSize: 13, textAlign: 'left' }}>Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ children, active, full }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 9, padding: full ? '9px 12px' : '7px 13px',
|
||||
borderRadius: 9, fontFamily: AT.disp, fontWeight: 500, fontSize: 14,
|
||||
color: active ? AT.star : AT.mute,
|
||||
background: active ? 'var(--wv-lilac-12)' : 'transparent',
|
||||
border: full ? `1px solid ${active ? 'var(--wv-lilac-18)' : 'transparent'}` : 'none',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ width: 6, height: 6, borderRadius: 3, background: active ? AT.lilac : 'transparent' }} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const signOutBtn = { fontFamily: AT.disp, fontWeight: 500, fontSize: 14, color: AT.star, background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 };
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
function AdminStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<AStateBlock label="Loading" note="minimal skeleton while /me resolves">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Skel w={90} h={12} />
|
||||
<Skel w={200} h={28} />
|
||||
<Skel w={280} h={12} />
|
||||
<Skel w={240} h={12} />
|
||||
</div>
|
||||
</AStateBlock>
|
||||
<AStateBlock label="Session expired" note="redirect to sign-in">
|
||||
<ABanner tone="info" title="Your session ended">
|
||||
For your security you've been signed out. <a href="#" style={{ color: AT.lilac, textDecoration: 'none' }}>Sign in again →</a>
|
||||
</ABanner>
|
||||
</AStateBlock>
|
||||
<AStateBlock label="Sign out" note="account menu open (PUC-9)">
|
||||
<div style={{ position: 'relative', height: 96 }}>
|
||||
<AAccountChip menu />
|
||||
</div>
|
||||
</AStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Skel({ w, h }) {
|
||||
return <div style={{ width: w, height: h, borderRadius: 6, background: 'rgba(237,234,255,.08)' }} />;
|
||||
}
|
||||
|
||||
Object.assign(window, { AdminA, AdminB, AdminStates });
|
||||
@@ -0,0 +1,214 @@
|
||||
// wf-bootstrap.jsx — Section 5: Bootstrap (PUC-10 / PUC-11, INV-1/INV-7).
|
||||
// Operator & developer surfaces — terminals and a runbook, not app screens.
|
||||
const { Screen: BScreen, Wordmark: BWordmark, WF_T: BT, WF_DS: BDS } = window;
|
||||
const { Eyebrow: BEyebrow, Soul: BSoul, Tag: BTag, Callout: BCallout } = BDS;
|
||||
|
||||
const MONO = 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace';
|
||||
|
||||
// ── Terminal window ────────────────────────────────────────────────────────
|
||||
function Terminal({ title, lines, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--wv-night)', border: `1px solid ${BT.hair}`, borderRadius: 12,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column', ...style,
|
||||
}}>
|
||||
<div style={{ flex: '0 0 auto', height: 40, padding: '0 14px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: `1px solid ${BT.hair}`, background: 'rgba(155,140,255,.05)' }}>
|
||||
<span style={{ display: 'flex', gap: 6 }}>
|
||||
{['rgba(155,140,255,.5)', 'rgba(244,199,107,.5)', 'rgba(237,234,255,.3)'].map((c, i) => (
|
||||
<span key={i} style={{ width: 9, height: 9, borderRadius: 5, background: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: BT.mute, marginLeft: 4 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '16px 18px', fontFamily: MONO, fontSize: 12.5, lineHeight: 1.75, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
||||
{lines.map((ln, i) => <TLine key={i} ln={ln} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function TLine({ ln }) {
|
||||
const [k, t] = ln;
|
||||
if (k === 'sp') return <div style={{ height: 9 }} />;
|
||||
if (k === 'cmd') return <div><span style={{ color: BT.lilac }}>$ </span><span style={{ color: BT.star }}>{t}</span></div>;
|
||||
if (k === 'step') return <div><span style={{ color: BT.lilac }}>→ </span><span style={{ color: BT.soft }}>{t}</span></div>;
|
||||
if (k === 'ok') return <div><span style={{ color: BT.gold }}>✓ </span><span style={{ color: BT.star }}>{t}</span></div>;
|
||||
if (k === 'hl') return <div style={{ color: BT.gold, background: 'rgba(244,199,107,.08)', margin: '0 -8px', padding: '1px 8px', borderRadius: 4 }}>{t}</div>;
|
||||
if (k === 'note') return <div style={{ color: 'var(--wv-starlight-55)', fontStyle: 'italic' }}>{t}</div>;
|
||||
return <div style={{ color: BT.mute }}>{t}</div>;
|
||||
}
|
||||
|
||||
// ── Wrap a section artboard on the sky ─────────────────────────────────────
|
||||
function Bay({ children, pad = 30 }) {
|
||||
return <div style={{ height: '100%', background: BT.sky, padding: pad, boxSizing: 'border-box', overflow: 'hidden', fontFamily: BT.body, display: 'flex', flexDirection: 'column' }}>{children}</div>;
|
||||
}
|
||||
function BayHead({ kicker, title, sub }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<BEyebrow style={{ margin: '0 0 8px' }}>{kicker}</BEyebrow>
|
||||
<h2 style={{ fontFamily: BT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 22, color: BT.star, margin: '0 0 6px', lineHeight: 1.1 }}>{title}</h2>
|
||||
{sub && <p style={{ fontFamily: BT.body, fontSize: 13.5, lineHeight: 1.5, color: BT.soft, margin: 0, maxWidth: 560 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1 · The principle — empty is a working state (INV-1) ───────────────────
|
||||
function BootstrapPrinciple() {
|
||||
const steps = [
|
||||
['Operator starts the app', 'scripts/dev.sh locally · flotilla deploy when deployed', 'lilac'],
|
||||
['App migrates itself', 'pending .sql migrations apply in order, fail-stop (INV-7)', 'lilac'],
|
||||
['/healthz goes green', 'process up · DB reachable · migrations current', 'gold'],
|
||||
['First merchant walks the product', 'sign up → create storefront → admin (PUC-2 / 4 / 6)', 'gold'],
|
||||
['First rows exist', 'created through the flows alone — nothing placed by hand', 'lilac'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="INV-1 · the bootstrap property" title="Empty is a working state"
|
||||
sub="No seed step, ever. A fresh, empty database is a first-class state the product brings to life itself — which is what makes launching anywhere an ordinary, rehearsed act." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, position: 'relative' }}>
|
||||
{steps.map(([t, d, c], i) => {
|
||||
const col = c === 'gold' ? BT.gold : BT.lilac;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 16, paddingBottom: i < steps.length - 1 ? 18 : 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '0 0 auto' }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 13, border: `1.5px solid ${col}`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: BT.disp, fontWeight: 700, fontSize: 13 }}>{i + 1}</span>
|
||||
{i < steps.length - 1 && <span style={{ width: 1.5, flex: 1, minHeight: 22, background: BT.hair }} />}
|
||||
</div>
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
<div style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 15, color: BT.star }}>{t}</div>
|
||||
<div style={{ fontFamily: MONO, fontSize: 11.5, color: BT.mute, marginTop: 3 }}>{d}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2 · The runbook — docs/BOOTSTRAP.md (paper) ────────────────────────────
|
||||
function RunbookDoc() {
|
||||
const envs = [
|
||||
['localhost', './scripts/dev.sh', 'Docker is the one prerequisite. Brings up Postgres, migrates, serves on :5173.'],
|
||||
['pre-prod (PPE)', 'flotilla deploy ecomm --env ppe', 'Provisions Cloud SQL, resolves secrets, migrates at startup. Rehearse here first.'],
|
||||
['production', 'flotilla deploy ecomm --env prod', 'The identical gesture. Day-one prod is simply the bootstrap state.'],
|
||||
];
|
||||
return (
|
||||
<div style={{ height: '100%', background: 'var(--wv-paper)', padding: '34px 38px', boxSizing: 'border-box', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: 'var(--wv-violet)', background: 'rgba(124,111,224,.12)', padding: '3px 10px', borderRadius: 999 }}>docs/BOOTSTRAP.md</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 11.5, color: '#8A7FB0' }}>the documented gesture</span>
|
||||
</div>
|
||||
<h2 style={{ fontFamily: BT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: 'var(--wv-ink)', margin: '0 0 8px', lineHeight: 1.08 }}>Bringing ecomm up from empty</h2>
|
||||
<p style={{ fontFamily: BT.body, fontSize: 14, lineHeight: 1.55, color: '#4B4170', margin: '0 0 22px', maxWidth: 560 }}>
|
||||
One gesture, three environments. No hand-seeded data — the first merchant arrives through the product flows alone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{envs.map(([env, cmd, note], i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '128px 1fr', gap: 18, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 14, color: 'var(--wv-ink)' }}>{env}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: MONO, fontSize: 12.5, color: 'var(--wv-ink)', background: '#ECE8F7', border: '1px solid #DCD5F0', borderRadius: 8, padding: '7px 11px', display: 'inline-block' }}>{cmd}</div>
|
||||
<div style={{ fontFamily: BT.body, fontSize: 12.5, lineHeight: 1.5, color: '#6A5F90', marginTop: 6 }}>{note}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<BCallout onLight>Empty is a working state — there is no seed.</BCallout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3 · localhost — scripts/dev.sh ─────────────────────────────────────────
|
||||
function DevTerminal() {
|
||||
const lines = [
|
||||
['cmd', 'git clone …/wiggleverse-ecomm && cd wiggleverse-ecomm'],
|
||||
['cmd', './scripts/dev.sh'],
|
||||
['sp'],
|
||||
['step', 'datastore: starting postgres (docker compose up)…'],
|
||||
['ok', 'postgres healthy · ecomm_dev · :5432'],
|
||||
['step', 'migrations: applying pending…'],
|
||||
['ok', '0001_init applied · schema current'],
|
||||
['step', 'backend: uvicorn on :8000'],
|
||||
['step', 'web: vite on :5173 (proxying /api)'],
|
||||
['ok', '/healthz 200 {status:"ok", migrations:"current"}'],
|
||||
['sp'],
|
||||
['out', ' ecomm is up against an empty database.'],
|
||||
['hl', ' open http://localhost:5173'],
|
||||
['sp'],
|
||||
['note', 'one-time codes print to this log (LogMailer) — no real mail in dev.'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-10 · localhost" title="One command from clean checkout"
|
||||
sub="A developer reaches “first merchant, first storefront” with Docker as the only prerequisite — the app migrates an empty database itself." />
|
||||
<Terminal title="scripts/dev.sh" lines={lines} style={{ flex: 1 }} />
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4 · The code channel — dev log vs real email ───────────────────────────
|
||||
function DevChannel() {
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-10 / PUC-11 · the OTC channel" title="Where the one-time code lands"
|
||||
sub="Same issue/verify path everywhere — only the mailer differs by configuration (INV-8)." />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
<div>
|
||||
<ChanLabel tone="lilac">Dev — the code prints to the backend log</ChanLabel>
|
||||
<Terminal title="backend · LogMailer" lines={[
|
||||
['out', '[mailer] adapter=LogMailer'],
|
||||
['out', ' to: mara@studiofern.com'],
|
||||
['hl', ' subject: Your ecomm code: 419 204'],
|
||||
['out', ' code valid 10m · single-use · 5 tries'],
|
||||
]} />
|
||||
</div>
|
||||
<div>
|
||||
<ChanLabel tone="gold">Deployed — the code arrives as real email</ChanLabel>
|
||||
<div style={{ background: BT.raised, border: `1px solid ${BT.hairCard}`, borderRadius: 12, padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: BT.body, fontSize: 12, color: BT.mute, marginBottom: 8, borderBottom: `1px solid ${BT.hair}`, paddingBottom: 8 }}>
|
||||
<span>ecomm <no-reply@ecomm.wiggleverse.org></span><span>now</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: BT.disp, fontWeight: 500, fontSize: 15, color: BT.star, marginBottom: 6 }}>Your ecomm code: 419 204</div>
|
||||
<div style={{ fontFamily: BT.body, fontSize: 12.5, lineHeight: 1.5, color: BT.soft }}>
|
||||
Enter this code to sign in. It's good for 10 minutes. If you didn't request it, ignore this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
function ChanLabel({ children, tone }) {
|
||||
const c = tone === 'gold' ? BT.gold : BT.lilac;
|
||||
return <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 9, fontFamily: BT.body, fontSize: 12.5, color: BT.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c }}>{tone === 'gold' ? '◆' : '◇'}</span>{children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// ── 5 · deployed — flotilla (PPE then Prod) ────────────────────────────────
|
||||
function DeployTerminal() {
|
||||
const lines = [
|
||||
['cmd', 'flotilla deploy ecomm --env ppe'],
|
||||
['sp'],
|
||||
['step', 'provision: Cloud SQL (postgres) · backups + PITR…'],
|
||||
['ok', 'database ready · secrets resolved from Secret Manager'],
|
||||
['step', 'deploy: image → single VM · fail-stop'],
|
||||
['step', 'migrations: applying at startup…'],
|
||||
['ok', '0001_init applied'],
|
||||
['ok', '/healthz 200 — ppe live'],
|
||||
['out', ' rehearsal: real sign-up (real email) → storefront → admin ✓'],
|
||||
['sp'],
|
||||
['cmd', 'flotilla deploy ecomm --env prod'],
|
||||
['ok', 'same gesture — day-one prod is the bootstrap state'],
|
||||
];
|
||||
return (
|
||||
<Bay>
|
||||
<BayHead kicker="PUC-11 · pre-prod, then prod" title="The same gesture, deployed"
|
||||
sub="The operator runs flotilla; PPE rehearses Prod with real email (BUC-5a). No environment is “later.”" />
|
||||
<Terminal title="flotilla — operator front door" lines={lines} style={{ flex: 1 }} />
|
||||
</Bay>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { BootstrapPrinciple, RunbookDoc, DevTerminal, DevChannel, DeployTerminal });
|
||||
@@ -0,0 +1,175 @@
|
||||
// wf-intro.jsx — System framing artboards: the two directions + entry-routing spine.
|
||||
const { Screen: IScreen, Wordmark: IWordmark, WF_T: IT, WF_DS: IDS } = window;
|
||||
const { Eyebrow: IEyebrow, Soul: ISoul, Tag: ITag } = IDS;
|
||||
|
||||
// ── The two directions, side by side ───────────────────────────────────────
|
||||
function TwoDirections() {
|
||||
return (
|
||||
<IScreen starfield style={{ padding: '40px 44px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={26} />
|
||||
<ITag variant="live">SD-0001 · MVP wireframes</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 26 }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>One A/B, run across all four screens</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 34, lineHeight: 1.05, color: IT.star, margin: '0 0 10px' }}>
|
||||
Two bets on the lasting frame
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 15, lineHeight: 1.6, color: IT.soft, maxWidth: 620, margin: 0 }}>
|
||||
These are the first screens of a product headed for hundreds. The variation isn't cosmetic — each
|
||||
direction is a different answer to <em>how much frame to commit to on day one</em>. Same content, same tokens,
|
||||
same honest copy; different skeleton.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 22, marginTop: 30 }}>
|
||||
<DirCard letter="A" name="Quiet centered" tone="lilac"
|
||||
bet="Keep chrome minimal until features earn it."
|
||||
lines={['Auth & onboarding are one centered column on the sky.',
|
||||
'Admin is a slim top bar over a centered empty state.',
|
||||
'Fewest moving parts — the calm voice carries it.']}
|
||||
best="Reads calmest now; less to maintain." cost="A bigger reframe when the nav finally arrives." />
|
||||
<DirCard letter="B" name="Durable frame" tone="gold"
|
||||
bet="Set the skeleton now; let screens slot in."
|
||||
lines={['Entry uses a split: a persistent brand/identity rail + a work column.',
|
||||
'Admin ships the real shell — sidebar + content region.',
|
||||
'Catalog, orders, settings drop into an existing frame later.']}
|
||||
best="No redesign when features land." cost="More frame than four screens strictly need." />
|
||||
</div>
|
||||
<div style={{ marginTop: 26, padding: '16px 20px', borderRadius: 12, background: 'var(--wv-lilac-08)', border: `1px solid var(--wv-lilac-18)`, display: 'flex', gap: 13, alignItems: 'flex-start' }}>
|
||||
<span aria-hidden="true" style={{ color: IT.lilac }}>◇</span>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.55, color: IT.soft, margin: 0 }}>
|
||||
<strong style={{ color: IT.star }}>Both honor the OHM bar.</strong> No trial clock, no plan wall, no fabricated metrics or
|
||||
locked-feature teasers. Empty is an honest, finished state — not a sales surface. Attention states use <span style={{ color: IT.gold }}>gold</span>,
|
||||
since the palette carries no red.
|
||||
</p>
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function DirCard({ letter, name, bet, lines, best, cost, tone }) {
|
||||
const c = tone === 'gold' ? IT.gold : IT.lilac;
|
||||
return (
|
||||
<div style={{ background: IT.raised, border: `1px solid ${IT.hairCard}`, borderRadius: 14, padding: '22px 22px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ width: 34, height: 34, borderRadius: 9, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: IT.disp, fontWeight: 700, fontSize: 17, color: tone === 'gold' ? IT.goldInk : '#0E1230', background: c }}>{letter}</span>
|
||||
<div>
|
||||
<div style={{ fontFamily: IT.disp, fontWeight: 700, fontSize: 18, color: IT.star, letterSpacing: '-0.01em' }}>{name}</div>
|
||||
<div style={{ fontFamily: IT.body, fontStyle: 'italic', fontSize: 13, color: c }}>{bet}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{lines.map((l, i) => (
|
||||
<li key={i} style={{ display: 'flex', gap: 9, fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.45, color: IT.soft }}>
|
||||
<span aria-hidden="true" style={{ color: c, flex: '0 0 auto' }}>·</span>{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ borderTop: `1px solid ${IT.hair}`, paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<TradeRow k="Wins" v={best} c={IT.lilac} />
|
||||
<TradeRow k="Costs" v={cost} c={IT.gold} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function TradeRow({ k, v, c }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 10, fontFamily: IT.body, fontSize: 12.5, lineHeight: 1.4 }}>
|
||||
<span style={{ flex: '0 0 42px', color: c, fontWeight: 600 }}>{k}</span>
|
||||
<span style={{ color: IT.mute }}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Entry routing — the exhaustive arrival rule (§6.5) ─────────────────────
|
||||
function EntryRouting() {
|
||||
const lanes = [
|
||||
{ sess: 'No session', store: '—', arrow: 'Landing', puc: 'PUC-1', tone: 'mute' },
|
||||
{ sess: 'Valid session', store: 'No storefront', arrow: 'Create storefront', puc: 'PUC-5', tone: 'gold' },
|
||||
{ sess: 'Valid session', store: 'Has storefront', arrow: 'Admin shell', puc: 'PUC-6', tone: 'lilac' },
|
||||
];
|
||||
return (
|
||||
<IScreen style={{ padding: '40px 38px' }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>The shared spine</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 27, lineHeight: 1.08, color: IT.star, margin: '0 0 10px' }}>
|
||||
Entry routing
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 14, lineHeight: 1.55, color: IT.soft, margin: '0 0 8px' }}>
|
||||
One server answer — <code style={{ fontFamily: 'ui-monospace,monospace', fontSize: 12.5, color: IT.lilac, background: 'var(--wv-lilac-08)', padding: '1px 6px', borderRadius: 5 }}>GET /api/auth/me</code> —
|
||||
decides where every arrival lands. Every screen here obeys it.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 24 }}>
|
||||
{lanes.map((l, i) => {
|
||||
const c = l.tone === 'gold' ? IT.gold : l.tone === 'lilac' ? IT.lilac : IT.mute;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', borderRadius: 12, background: IT.raised, border: `1px solid ${IT.hairCard}` }}>
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto' }}>
|
||||
<Cond>{l.sess}</Cond>
|
||||
<Cond>{l.store}</Cond>
|
||||
</div>
|
||||
<span aria-hidden="true" style={{ color: c, fontSize: 18, flex: '0 0 auto' }}>→</span>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontFamily: IT.disp, fontWeight: 700, fontSize: 17, color: c, letterSpacing: '-0.01em' }}>{l.arrow}</span>
|
||||
<span style={{ fontFamily: IT.body, fontSize: 11.5, color: IT.mute }}>{l.puc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13, lineHeight: 1.55, color: IT.mute, margin: '22px 0 0', display: 'flex', gap: 9 }}>
|
||||
<span aria-hidden="true" style={{ color: IT.lilac }}>◇</span>
|
||||
Exhaustive by design — no state lands nowhere. A merchant without a storefront is guided to create one, never stranded.
|
||||
</p>
|
||||
<div style={{ flex: 1 }} />
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
function Cond({ children }) {
|
||||
return <span style={{ fontFamily: IT.body, fontSize: 13, color: IT.soft, padding: '5px 11px', borderRadius: 999, background: 'rgba(237,234,255,.05)', border: `1px solid ${IT.hair}`, whiteSpace: 'nowrap' }}>{children}</span>;
|
||||
}
|
||||
|
||||
// ── Direction A, chosen — the system this locks in ─────────────────────────
|
||||
function SystemA() {
|
||||
const patterns = [
|
||||
['Entry surfaces', 'One centered card on the midnight sky — landing, sign-in, create. Content-led, no side chrome.'],
|
||||
['Admin', 'A slim top bar over a centered state. The storefront name is the anchor; no sidebar yet.'],
|
||||
['Actions', 'One primary gold CTA per screen. Lilac for secondary links. Ghost buttons stay rare.'],
|
||||
['Attention', 'Errors and waits read in gold — the palette carries no red. Honest failure, never a fake success.'],
|
||||
['Emptiness', 'Honestly empty everywhere: no zeroed tiles, no locked-feature teasers, no manufactured aspiration.'],
|
||||
];
|
||||
return (
|
||||
<IScreen starfield style={{ padding: '40px 44px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IWordmark size={26} />
|
||||
<ITag variant="live">SD-0001 · MVP wireframes</ITag>
|
||||
</div>
|
||||
<div style={{ marginTop: 26 }}>
|
||||
<IEyebrow style={{ margin: '0 0 12px' }}>Chosen direction</IEyebrow>
|
||||
<h1 style={{ fontFamily: IT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 34, lineHeight: 1.05, color: IT.star, margin: '0 0 10px' }}>
|
||||
Quiet centered
|
||||
</h1>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 15, lineHeight: 1.6, color: IT.soft, maxWidth: 600, margin: 0 }}>
|
||||
A single calm column on the sky, with the least chrome the job allows. The honest voice carries the rest —
|
||||
and every screen here uses the same tokens, the same copy register, the same primary action.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 26, display: 'flex', flexDirection: 'column', gap: 0, border: `1px solid ${IT.hairCard}`, borderRadius: 14, overflow: 'hidden', background: IT.raised }}>
|
||||
{patterns.map(([k, v], i) => (
|
||||
<div key={k} style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 18, padding: '15px 20px', borderTop: i ? `1px solid ${IT.hair}` : 'none' }}>
|
||||
<span style={{ fontFamily: IT.disp, fontWeight: 500, fontSize: 14, color: IT.lilac }}>{k}</span>
|
||||
<span style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.5, color: IT.soft }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 22, padding: '15px 20px', borderRadius: 12, background: 'rgba(244,199,107,.08)', border: `1px solid var(--wv-gold-40)`, display: 'flex', gap: 13, alignItems: 'flex-start' }}>
|
||||
<span aria-hidden="true" style={{ color: IT.gold }}>◆</span>
|
||||
<p style={{ fontFamily: IT.body, fontSize: 13.5, lineHeight: 1.55, color: IT.soft, margin: 0 }}>
|
||||
<strong style={{ color: IT.star }}>Deferred, not forgotten.</strong> When the product grows past forms, a persistent
|
||||
nav frame gets introduced as its own deliberate step — designed then, against real sections, not guessed at now.
|
||||
</p>
|
||||
</div>
|
||||
</IScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SystemA, TwoDirections, EntryRouting });
|
||||
@@ -0,0 +1,303 @@
|
||||
// wf-kit.jsx — Wiggleverse ecomm wireframe primitives.
|
||||
// Mid-fi: real Wiggleverse tokens + composed DS components (Button, Soul, Eyebrow, Tag, Notice).
|
||||
// Everything exported to window at the bottom so sibling babel scripts can use it.
|
||||
|
||||
const DS = window.WiggleverseDesignSystem_94cd80;
|
||||
const { Button, Soul, Eyebrow, Tag, Notice } = DS;
|
||||
|
||||
// ---- token shorthands (resolved from the linked token stylesheets) -------
|
||||
const T = {
|
||||
sky: 'var(--wv-midnight)',
|
||||
raised: 'var(--wv-indigo)',
|
||||
raisedHi: 'var(--wv-indigo-2)',
|
||||
night: 'var(--wv-night)',
|
||||
star: 'var(--wv-starlight)',
|
||||
soft: 'var(--wv-starlight-78)',
|
||||
mute: 'var(--wv-starlight-60)',
|
||||
lilac: 'var(--wv-lilac)',
|
||||
gold: 'var(--wv-gold)',
|
||||
goldInk: 'var(--wv-gold-ink)',
|
||||
hair: 'var(--border-soft)',
|
||||
hairCard: 'var(--border-card)',
|
||||
disp: 'var(--wv-font-display)',
|
||||
body: 'var(--wv-font-body)',
|
||||
};
|
||||
|
||||
// faint scattered starfield — the brand's one decorative texture (off-center, low alpha)
|
||||
const STARFIELD =
|
||||
'radial-gradient(1.4px 1.4px at 18% 24%, rgba(237,234,255,.5), transparent),' +
|
||||
'radial-gradient(1.4px 1.4px at 73% 16%, rgba(237,234,255,.35), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 88% 52%, rgba(155,140,255,.45), transparent),' +
|
||||
'radial-gradient(1.6px 1.6px at 32% 70%, rgba(237,234,255,.4), transparent),' +
|
||||
'radial-gradient(1.2px 1.2px at 58% 84%, rgba(237,234,255,.3), transparent),' +
|
||||
'radial-gradient(1.3px 1.3px at 8% 58%, rgba(155,140,255,.4), transparent),' +
|
||||
'radial-gradient(1.1px 1.1px at 46% 40%, rgba(237,234,255,.28), transparent)';
|
||||
|
||||
// ── Screen — fills the artboard, the midnight ground, column flow ──────────
|
||||
function Screen({ children, starfield = false, bg = T.sky, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box',
|
||||
background: starfield ? `${STARFIELD}, ${bg}` : bg,
|
||||
color: T.star, fontFamily: T.body,
|
||||
display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden',
|
||||
...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wordmark — the mark tile + "ecomm" (Space Grotesk) ─────────────────────
|
||||
function Wordmark({ size = 26, muted = false }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size * 0.42 }}>
|
||||
<img src="assets/mark-tile.svg" width={size} height={size}
|
||||
style={{ borderRadius: size * 0.22, display: 'block', opacity: muted ? 0.85 : 1 }} alt="" />
|
||||
<span style={{
|
||||
fontFamily: T.disp, fontWeight: 700, fontSize: size * 0.86,
|
||||
letterSpacing: '-0.015em', color: T.star,
|
||||
}}>ecomm</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TopBar — the chrome shared by every authenticated surface ──────────────
|
||||
function TopBar({ left, right, pad = '0 32px', height = 64 }) {
|
||||
return (
|
||||
<header style={{
|
||||
flex: '0 0 auto', height, padding: pad,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${T.hair}`,
|
||||
background: 'rgba(14,18,48,.6)', backdropFilter: 'blur(10px)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>{left}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// account chip used at top-right of authenticated surfaces
|
||||
function AccountChip({ email = 'mara@studiofern.com', menu = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, position: 'relative' }}>
|
||||
<span style={{ fontFamily: T.body, fontSize: 14, color: T.soft }}>{email}</span>
|
||||
<span style={{ width: 1, height: 18, background: T.hair }} />
|
||||
<button style={{
|
||||
fontFamily: T.disp, fontWeight: 500, fontSize: 14, color: T.star,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
|
||||
}}>Sign out</button>
|
||||
{menu && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 30, right: 0, minWidth: 200, zIndex: 9,
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 12,
|
||||
padding: 6, boxShadow: '0 8px 24px rgba(9,12,34,.4)',
|
||||
}}>
|
||||
<MenuRow>{email}</MenuRow>
|
||||
<div style={{ borderTop: `1px solid ${T.hair}`, margin: '6px 4px' }} />
|
||||
<MenuRow strong>Sign out</MenuRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MenuRow({ children, strong }) {
|
||||
return <div style={{
|
||||
padding: '8px 12px', fontFamily: T.body, fontSize: 13.5,
|
||||
color: strong ? T.star : T.mute, fontWeight: strong ? 600 : 400, borderRadius: 7,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
// ── Field — label + input + helper/error. Real token styling. ──────────────
|
||||
function Field({ label, placeholder, value, helper, error, focus = false, optional = false }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<label style={{
|
||||
fontFamily: T.body, fontWeight: 500, fontSize: 13.5, color: T.soft,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ fontSize: 12.5, color: T.mute, fontWeight: 400 }}>optional</span>}
|
||||
</label>
|
||||
<div style={{
|
||||
height: 50, borderRadius: 10, padding: '0 15px', display: 'flex', alignItems: 'center',
|
||||
background: 'rgba(237,234,255,.04)',
|
||||
border: `1.5px solid ${error ? T.gold : focus ? T.gold : T.hair}`,
|
||||
boxShadow: focus ? '0 0 0 3px var(--wv-gold-28)' : 'none',
|
||||
fontFamily: T.body, fontSize: 15.5,
|
||||
color: value ? T.star : T.mute,
|
||||
}}>
|
||||
{value || placeholder}
|
||||
{focus && !value && <span style={{
|
||||
width: 1.5, height: 20, marginLeft: 1, background: T.gold,
|
||||
}} />}
|
||||
</div>
|
||||
{error && <FieldNote tone="attn">{error}</FieldNote>}
|
||||
{helper && !error && <FieldNote>{helper}</FieldNote>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function FieldNote({ children, tone }) {
|
||||
return <span style={{
|
||||
fontFamily: T.body, fontSize: 13, lineHeight: 1.4,
|
||||
color: tone === 'attn' ? T.gold : T.mute,
|
||||
display: 'flex', gap: 6, alignItems: 'baseline',
|
||||
}}>
|
||||
{tone === 'attn' && <span aria-hidden="true" style={{ color: T.gold }}>◆</span>}
|
||||
<span>{children}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
// ── CodeField — one-time-code entry, 6 cells ───────────────────────────────
|
||||
function CodeField({ filled = 0, error = false }) {
|
||||
const cells = [0, 1, 2, 3, 4, 5];
|
||||
const vals = ['4', '1', '9', '', '', ''];
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{cells.map((i) => {
|
||||
const active = i === filled;
|
||||
const has = i < filled;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
width: 50, height: 60, borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(237,234,255,.04)',
|
||||
border: `1.5px solid ${error ? T.gold : active ? T.gold : T.hair}`,
|
||||
boxShadow: active ? '0 0 0 3px var(--wv-gold-28)' : 'none',
|
||||
fontFamily: T.disp, fontWeight: 500, fontSize: 24, color: T.star,
|
||||
}}>
|
||||
{has ? vals[i] : (active ? <span style={{ width: 1.5, height: 26, background: T.gold }} /> : '')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner — inline status. attn = gold (no red in palette); info = lilac ──
|
||||
function Banner({ children, tone = 'attn', title }) {
|
||||
const attn = tone === 'attn';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, padding: '13px 16px', borderRadius: 12,
|
||||
background: attn ? 'rgba(244,199,107,.10)' : 'var(--wv-lilac-08)',
|
||||
border: `1px solid ${attn ? 'var(--wv-gold-40)' : 'var(--wv-lilac-18)'}`,
|
||||
fontFamily: T.body, alignItems: 'flex-start',
|
||||
}}>
|
||||
<span aria-hidden="true" style={{ color: attn ? T.gold : T.lilac, fontSize: 14, lineHeight: '21px' }}>
|
||||
{attn ? '◆' : '◇'}
|
||||
</span>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, color: T.soft }}>
|
||||
{title && <div style={{ color: T.star, fontWeight: 600, marginBottom: 2 }}>{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AuthCard — the centered raised surface for Direction A ─────────────────
|
||||
function AuthCard({ children, width = 420 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, maxWidth: '100%', boxSizing: 'border-box',
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 14,
|
||||
padding: '38px 38px 34px',
|
||||
display: 'flex', flexDirection: 'column', gap: 22,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BrandRail — the persistent identity panel for Direction B ──────────────
|
||||
function BrandRail({ compact = false, soul = 'Your storefront is yours.' }) {
|
||||
return (
|
||||
<aside style={{
|
||||
flex: compact ? '0 0 auto' : '0 0 42%',
|
||||
background: `${STARFIELD}, ${T.night}`,
|
||||
borderRight: compact ? 'none' : `1px solid ${T.hair}`,
|
||||
borderBottom: compact ? `1px solid ${T.hair}` : 'none',
|
||||
padding: compact ? '26px 28px' : '44px 46px',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: compact ? 'flex-start' : 'space-between',
|
||||
position: 'relative', overflow: 'hidden', gap: compact ? 18 : 0,
|
||||
}}>
|
||||
<Wordmark size={compact ? 24 : 28} />
|
||||
{!compact && (
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Soul size="lg" tone="gold" as="p">Treat humans as humans —<br />everything else follows.</Soul>
|
||||
<p style={{ fontFamily: T.body, fontSize: 14.5, lineHeight: 1.6, color: T.mute, marginTop: 20 }}>
|
||||
Honest commerce, built on one shared set of ethics. No trial clock, no plan wall — just your storefront.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{compact && <Soul size="sm" tone="gold" as="p">{soul}</Soul>}
|
||||
{!compact && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: T.body, fontSize: 12, color: T.mute }}>
|
||||
<span style={{ width: 22, height: 1, background: T.hair }} />
|
||||
<span>A Wiggleverse line · Open Core</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer — minimal project identity + the standing dedication ────────────
|
||||
function Footer({ onDark = true }) {
|
||||
return (
|
||||
<footer style={{
|
||||
flex: '0 0 auto', padding: '20px 32px', borderTop: `1px solid ${T.hair}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16,
|
||||
fontFamily: T.body, fontSize: 12.5, color: T.mute,
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 9 }}>
|
||||
<img src="assets/mark-mono-gold.svg" width={16} height={16} alt="" style={{ opacity: 0.8 }} />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
<span>Privacy · Open Core</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section wrapper used in the canvas to title state stacks ───────────────
|
||||
function StateBlock({ label, note, children, h }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<Tag variant="soon">{label}</Tag>
|
||||
{note && <span style={{ fontFamily: T.body, fontSize: 12.5, color: T.mute }}>{note}</span>}
|
||||
</div>
|
||||
<div style={{
|
||||
background: T.raised, border: `1px solid ${T.hairCard}`, borderRadius: 12,
|
||||
padding: 22, height: h, boxSizing: 'border-box',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Honest-empty admin body — the heart of PUC-8 ───────────────────────────
|
||||
function EmptyAdmin({ storefront = 'Studio Fern', compact = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start', justifyContent: 'center',
|
||||
padding: compact ? '32px 26px' : '0 64px', maxWidth: 720, margin: compact ? 0 : '0 auto',
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
}}>
|
||||
<Eyebrow style={{ margin: '0 0 14px' }}>Your storefront</Eyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: T.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.05,
|
||||
fontSize: compact ? 30 : 'var(--text-h1)', color: T.star, margin: '0 0 16px',
|
||||
}}>{storefront}</h1>
|
||||
<p style={{
|
||||
fontFamily: T.body, fontSize: compact ? 15 : 17, lineHeight: 1.6, color: T.soft,
|
||||
maxWidth: 480, margin: 0,
|
||||
}}>
|
||||
There's nothing to manage yet. Catalog, orders, and settings will appear
|
||||
here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
WF_T: T, WF_STARFIELD: STARFIELD,
|
||||
Screen, Wordmark, TopBar, AccountChip, MenuRow, Field, FieldNote, CodeField,
|
||||
Banner, AuthCard, BrandRail, Footer, StateBlock, EmptyAdmin,
|
||||
WF_DS: DS,
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// wf-landing.jsx — Screen 1: Landing (PUC-1). Two directions.
|
||||
const { Screen: LScreen, Wordmark: LWordmark, TopBar: LTopBar, Footer: LFooter, WF_T: LT, WF_DS: LDS } = window;
|
||||
const { Button: LButton, Soul: LSoul, Eyebrow: LEyebrow } = LDS;
|
||||
|
||||
function LogInLink({ size = 14 }) {
|
||||
return <a href="#" style={{
|
||||
fontFamily: LT.disp, fontWeight: 500, fontSize: size, color: LT.star,
|
||||
textDecoration: 'none', textUnderlineOffset: 3,
|
||||
}}>Log in</a>;
|
||||
}
|
||||
|
||||
function LandingValue({ device }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<>
|
||||
<LEyebrow style={{ margin: '0 0 18px' }}>One storefront, fully yours</LEyebrow>
|
||||
<h1 style={{
|
||||
fontFamily: LT.disp, fontWeight: 700, letterSpacing: '-0.015em', lineHeight: 1.04,
|
||||
fontSize: desktop ? 'var(--text-h1)' : 30, color: LT.star, margin: '0 0 20px',
|
||||
}}>Sell online,<br />honestly.</h1>
|
||||
<p style={{
|
||||
fontFamily: LT.body, fontSize: desktop ? 18 : 15.5, lineHeight: 1.6, color: LT.soft,
|
||||
maxWidth: 460, margin: '0 0 30px',
|
||||
}}>
|
||||
Claim your storefront on a platform that takes only what it takes to run —
|
||||
no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — Quiet centered ───────────────────────────────────────────
|
||||
function LandingA({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<LScreen starfield>
|
||||
<LTopBar pad={desktop ? '0 40px' : '0 22px'}
|
||||
left={<LWordmark size={desktop ? 26 : 22} />} right={<LogInLink />} />
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: desktop ? 'center' : 'flex-start', justifyContent: 'center',
|
||||
textAlign: desktop ? 'center' : 'left',
|
||||
padding: desktop ? '0 40px' : '0 24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', alignItems: desktop ? 'center' : 'flex-start' }}>
|
||||
<div style={{ display: 'contents' }}><LandingValue device={device} /></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: desktop ? 'center' : 'stretch', gap: 16, width: desktop ? 'auto' : '100%' }}>
|
||||
<LButton variant="primary" href="#">Create your storefront →</LButton>
|
||||
<a href="#" style={{ fontFamily: LT.body, fontSize: 14, color: LT.mute, textDecoration: 'none', textAlign: desktop ? 'center' : 'left' }}>
|
||||
Already selling with us? <span style={{ color: LT.lilac }}>Log in</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LFooter />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — Durable frame (split) ────────────────────────────────────
|
||||
function LandingB({ device = 'desktop' }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<LScreen>
|
||||
<BrandRailLite />
|
||||
<div style={{ flex: 1, padding: '28px 24px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<LandingValue device={device} />
|
||||
<Doors />
|
||||
</div>
|
||||
<LFooter />
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{
|
||||
flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`,
|
||||
borderRight: `1px solid ${LT.hair}`, padding: '44px 46px',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden',
|
||||
}}>
|
||||
<LWordmark size={28} />
|
||||
<div style={{ maxWidth: 380 }}>
|
||||
<LSoul size="lg" tone="gold" as="p">Treat humans as humans —<br />everything else follows.</LSoul>
|
||||
<p style={{ fontFamily: LT.body, fontSize: 15, lineHeight: 1.6, color: LT.mute, marginTop: 22 }}>
|
||||
Honest commerce on one shared set of ethics. Your storefront is yours — to keep, to leave, to own.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: LT.body, fontSize: 12, color: LT.mute }}>
|
||||
<span style={{ width: 22, height: 1, background: LT.hair }} />
|
||||
<span>A Wiggleverse line · Open Core</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 70px' }}>
|
||||
<LandingValue device={device} />
|
||||
<Doors />
|
||||
</div>
|
||||
</LScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandRailLite() {
|
||||
return (
|
||||
<div style={{
|
||||
background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${LT.hair}`,
|
||||
padding: '24px 24px 26px', display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<LWordmark size={22} />
|
||||
<LSoul size="sm" tone="gold">Your storefront is yours.</LSoul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Doors() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 440 }}>
|
||||
<DoorRow primary title="Create your storefront"
|
||||
desc="New here? Set up the one storefront that's yours." cta="Get started →" />
|
||||
<DoorRow title="Log in"
|
||||
desc="Already selling with us? Pick up where you left off." cta="Log in →" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function DoorRow({ primary, title, desc, cta }) {
|
||||
return (
|
||||
<a href="#" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16,
|
||||
padding: '18px 20px', borderRadius: 14, textDecoration: 'none',
|
||||
background: primary ? 'rgba(244,199,107,.08)' : 'var(--wv-indigo)',
|
||||
border: `1px solid ${primary ? 'var(--wv-gold-40)' : LT.hairCard}`,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: LT.disp, fontWeight: 500, fontSize: 17, color: LT.star, marginBottom: 3 }}>{title}</div>
|
||||
<div style={{ fontFamily: LT.body, fontSize: 13.5, color: LT.mute, lineHeight: 1.45 }}>{desc}</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: LT.disp, fontWeight: 500, fontSize: 14, color: primary ? LT.gold : LT.lilac, whiteSpace: 'nowrap' }}>{cta}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { LandingA, LandingB });
|
||||
@@ -0,0 +1,161 @@
|
||||
// wf-signin.jsx — Screen 2: Sign in (PUC-2, PUC-3). Email → one-time code. A/B + states.
|
||||
const { Screen: SScreen, Wordmark: SWordmark, TopBar: STopBar, Field: SField, CodeField: SCodeField,
|
||||
Banner: SBanner, AuthCard: SAuthCard, BrandRail: SBrandRail, WF_T: ST, WF_DS: SDS } = window;
|
||||
const { Button: SButton, Soul: SSoul } = SDS;
|
||||
|
||||
function BackLink({ label = 'Back' }) {
|
||||
return <a href="#" style={{ fontFamily: ST.body, fontSize: 14, color: ST.mute, textDecoration: 'none' }}>← {label}</a>;
|
||||
}
|
||||
function Heading({ children, sub }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: ST.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: ST.star, margin: '0 0 8px', lineHeight: 1.1 }}>{children}</h1>
|
||||
{sub && <p style={{ fontFamily: ST.body, fontSize: 14.5, lineHeight: 1.5, color: ST.soft, margin: 0 }}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const honest = { fontFamily: ST.body, fontSize: 13, color: ST.mute, lineHeight: 1.5, margin: 0 };
|
||||
|
||||
// ── Step renderers (shared by A card and B column) ─────────────────────────
|
||||
function EmailStep({ framing = 'signup', s = {} }) {
|
||||
const title = framing === 'login' ? 'Log in' : 'Create your storefront';
|
||||
const sub = framing === 'login' ? 'Enter your email and we\u2019ll send you a code to sign in.' : 'Enter your email to begin. No password to create.';
|
||||
return (
|
||||
<>
|
||||
<Heading sub={sub}>{title}</Heading>
|
||||
{s.deliveryFail && <SBanner title="We couldn't send the code">The email didn't go out. Try again in a moment — nothing was lost.</SBanner>}
|
||||
<SField label="Email" placeholder="you@example.com"
|
||||
value={s.email} focus={!s.emailError && !s.value} error={s.emailError} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SButton variant="primary" href="#" disabled={!!s.cooldown}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Send code'}
|
||||
</SButton>
|
||||
<p style={honest}>We'll email you a one-time code. That's all we need.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeStep({ s = {} }) {
|
||||
const email = s.email || 'mara@studiofern.com';
|
||||
let err = null;
|
||||
if (s.codeWrong) err = `That code didn't match — ${s.attempts ?? 2} attempts left.`;
|
||||
if (s.codeExpired) err = 'That code expired. Send a fresh one below.';
|
||||
return (
|
||||
<>
|
||||
<Heading sub={<>We sent a code to <span style={{ color: ST.star }}>{email}</span>. <a href="#" style={{ color: ST.lilac, textDecoration: 'none' }}>Wrong address?</a></>}>
|
||||
Check your email
|
||||
</Heading>
|
||||
{s.welcome && <SBanner tone="info" title={s.welcome === 'new' ? 'Welcome to ecomm' : 'Welcome back'}>
|
||||
{s.welcome === 'new' ? 'A new account was created for this email.' : 'Signed in to your existing account.'}
|
||||
</SBanner>}
|
||||
{s.codeExhausted && <SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
<SCodeField filled={s.filled ?? 3} error={!!(s.codeWrong || s.codeExpired || s.codeExhausted)} />
|
||||
{err && <span style={{ fontFamily: ST.body, fontSize: 13, color: ST.gold, display: 'flex', gap: 6 }}><span aria-hidden="true">◆</span>{err}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<SButton variant="primary" href="#">Continue</SButton>
|
||||
<a href="#" style={{ fontFamily: ST.disp, fontWeight: 500, fontSize: 14, color: s.cooldown ? ST.mute : ST.lilac, textDecoration: 'none' }}>
|
||||
{s.cooldown ? `Resend in ${s.cooldown}s` : 'Resend code'}
|
||||
</a>
|
||||
</div>
|
||||
<p style={honest}>The code is good for 10 minutes. If you didn't request it, ignore this.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — centered card ────────────────────────────────────────────
|
||||
function SignInA({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<SScreen starfield>
|
||||
<STopBar pad={desktop ? '0 40px' : '0 22px'} left={<SWordmark size={desktop ? 24 : 22} />} right={<BackLink />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<SAuthCard width={desktop ? 430 : '100%'}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</SAuthCard>
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — split (brand rail + form column) ─────────────────────────
|
||||
function SignInB({ step = 'email', framing = 'signup', device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<SScreen>
|
||||
<div style={{ background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${ST.hair}`, padding: '22px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SWordmark size={22} />
|
||||
<SSoul size="sm" tone="gold">Your storefront is yours.</SSoul>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '26px 24px', display: 'flex', flexDirection: 'column', gap: 22, justifyContent: 'center' }}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{ flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`, borderRight: `1px solid ${ST.hair}`, padding: '40px 46px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
||||
<SWordmark size={26} />
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<SSoul size="lg" tone="gold" as="p">We are verbs,<br />not nouns.</SSoul>
|
||||
<p style={{ fontFamily: ST.body, fontSize: 14.5, lineHeight: 1.6, color: ST.mute, marginTop: 20 }}>
|
||||
One door, two framings. Whether you're signing up or coming back, it's the same honest step — your email, one code.
|
||||
</p>
|
||||
</div>
|
||||
<BackLink label="Back to start" />
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 64px' }}>
|
||||
<div style={{ width: '100%', maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{step === 'email' ? <EmailStep framing={framing} s={s} /> : <CodeStep s={s} />}
|
||||
</div>
|
||||
</div>
|
||||
</SScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
const { StateBlock: SStateBlock } = window;
|
||||
function codeNote(text) {
|
||||
return <span style={{ fontFamily: ST.body, fontSize: 13, color: ST.gold, display: 'flex', gap: 6, marginTop: 9 }}><span aria-hidden="true">◆</span>{text}</span>;
|
||||
}
|
||||
function SignInStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<SStateBlock label="Invalid email" note="inline field error">
|
||||
<SField label="Email" value="mara@studiofern" error="That doesn't look like an email address." />
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Resend cooldown" note="PUC-2c · 60s per email">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<SButton variant="primary" href="#" disabled>Resend in 47s</SButton>
|
||||
<span style={honest}>One code per minute keeps inboxes (and abuse) in check.</span>
|
||||
</div>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Delivery failed" note="502 · honest failure (INV-9)">
|
||||
<SBanner title="We couldn't send the code">The email didn't go out — try again. We'd never fake a success.</SBanner>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Wrong code" note="PUC-2a">
|
||||
<SCodeField filled={6} error />
|
||||
{codeNote("That code didn't match — 2 attempts left.")}
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Expired code" note="PUC-2b">
|
||||
<SCodeField filled={6} error />
|
||||
{codeNote('That code expired. Send a fresh one below.')}
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Attempts exhausted" note="5 tries · code invalidated (INV-3)">
|
||||
<SBanner title="Too many attempts">That code is no longer valid. Request a fresh one to keep going.</SBanner>
|
||||
</SStateBlock>
|
||||
<SStateBlock label="Honest welcome" note="PUC-3 · same flow, true label">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SBanner tone="info" title="Welcome to ecomm">A new account was created for this email.</SBanner>
|
||||
<SBanner tone="info" title="Welcome back">Signed in to your existing account.</SBanner>
|
||||
</div>
|
||||
</SStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SignInA, SignInB, EmailStep, CodeStep, SignInStates });
|
||||
@@ -0,0 +1,117 @@
|
||||
// wf-storefront.jsx — Screen 3: Create storefront (PUC-4/5/7). A/B + states.
|
||||
const { Screen: FScreen, Wordmark: FWordmark, TopBar: FTopBar, AccountChip: FAccountChip,
|
||||
Field: FField, Banner: FBanner, AuthCard: FAuthCard, StateBlock: FStateBlock,
|
||||
WF_T: FT, WF_DS: FDS } = window;
|
||||
const { Button: FButton, Soul: FSoul, Eyebrow: FEyebrow } = FDS;
|
||||
|
||||
function CreateForm({ s = {} }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<FEyebrow style={{ margin: '0 0 12px' }}>One storefront, fully yours</FEyebrow>
|
||||
<h1 style={{ fontFamily: FT.disp, fontWeight: 700, letterSpacing: '-0.015em', fontSize: 26, color: FT.star, margin: '0 0 8px', lineHeight: 1.1 }}>Create your storefront</h1>
|
||||
<p style={{ fontFamily: FT.body, fontSize: 14.5, lineHeight: 1.5, color: FT.soft, margin: 0 }}>
|
||||
This is the one thing to do right now. Nothing here costs anything or commits you to anything.
|
||||
</p>
|
||||
</div>
|
||||
{s.alreadyOwns && <FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a href="#" style={{ color: FT.lilac, textDecoration: 'none' }}>Go to your admin →</a>
|
||||
</FBanner>}
|
||||
<FField label="Storefront name" optional
|
||||
placeholder="e.g. Studio Fern" value={s.name}
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change later." />
|
||||
{s.blankPreview && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginTop: -6, fontFamily: FT.body, fontSize: 13, color: FT.mute }}>
|
||||
<span aria-hidden="true" style={{ color: FT.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: FT.soft, fontStyle: 'italic', fontFamily: FT.body }}>“mara's storefront”</span> — change it any time.
|
||||
</div>
|
||||
)}
|
||||
<FButton variant="primary" href="#" disabled={s.creating}>
|
||||
{s.creating ? 'Creating…' : 'Create storefront'}
|
||||
</FButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction A — centered card ────────────────────────────────────────────
|
||||
function CreateA({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
return (
|
||||
<FScreen starfield>
|
||||
<FTopBar pad={desktop ? '0 32px' : '0 18px'} left={<FWordmark size={desktop ? 24 : 21} />}
|
||||
right={desktop ? <FAccountChip /> : <SignOutMini />} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: desktop ? 32 : 18 }}>
|
||||
<FAuthCard width={desktop ? 440 : '100%'}><CreateForm s={s} /></FAuthCard>
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direction B — split (the frame begins to form) ─────────────────────────
|
||||
function CreateB({ device = 'desktop', s = {} }) {
|
||||
const desktop = device === 'desktop';
|
||||
if (!desktop) {
|
||||
return (
|
||||
<FScreen>
|
||||
<div style={{ background: `${window.WF_STARFIELD}, var(--wv-night)`, borderBottom: `1px solid ${FT.hair}`, padding: '20px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<FWordmark size={21} />
|
||||
<SignOutMini />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '24px 22px', display: 'flex', flexDirection: 'column', gap: 20, justifyContent: 'center' }}>
|
||||
<CreateForm s={s} />
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FScreen style={{ flexDirection: 'row' }}>
|
||||
<aside style={{ flex: '0 0 44%', background: `${window.WF_STARFIELD}, var(--wv-night)`, borderRight: `1px solid ${FT.hair}`, padding: '40px 46px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
||||
<FWordmark size={26} />
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<FSoul size="lg" tone="gold" as="p">Build the<br />dictionary first.</FSoul>
|
||||
<p style={{ fontFamily: FT.body, fontSize: 14.5, lineHeight: 1.6, color: FT.mute, marginTop: 20 }}>
|
||||
One storefront, claimed by you. The frame you see now is the one your catalog, orders and settings will live in.
|
||||
</p>
|
||||
</div>
|
||||
<FAccountChip />
|
||||
</aside>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 64px' }}>
|
||||
<div style={{ width: '100%', maxWidth: 420, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<CreateForm s={s} />
|
||||
</div>
|
||||
</div>
|
||||
</FScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutMini() {
|
||||
return <button style={{ fontFamily: FT.disp, fontWeight: 500, fontSize: 13, color: FT.star, background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}>Sign out</button>;
|
||||
}
|
||||
|
||||
// ── States stack ───────────────────────────────────────────────────────────
|
||||
function CreateStates() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<FStateBlock label="Blank name" note="allowed → default generated">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<FField label="Storefront name" optional placeholder="e.g. Studio Fern"
|
||||
helper="Leave blank and we'll name it for you." />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontFamily: FT.body, fontSize: 13, color: FT.mute }}>
|
||||
<span aria-hidden="true" style={{ color: FT.lilac }}>◇</span>
|
||||
Saves as <span style={{ color: FT.soft, fontStyle: 'italic' }}>“mara's storefront”</span>
|
||||
</div>
|
||||
</div>
|
||||
</FStateBlock>
|
||||
<FStateBlock label="Creating" note="button busy">
|
||||
<FButton variant="primary" href="#" disabled>Creating…</FButton>
|
||||
</FStateBlock>
|
||||
<FStateBlock label="Already owns one" note="409 · defense in depth (PUC-7)">
|
||||
<FBanner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today. <a href="#" style={{ color: FT.lilac, textDecoration: 'none' }}>Go to your admin →</a>
|
||||
</FBanner>
|
||||
</FStateBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { CreateA, CreateB, CreateStates });
|
||||
Reference in New Issue
Block a user