Copy the founder's design handoff bundle (.proto/design/) into docs/product/reference/ byte-for-byte so dispatch agents running in git worktrees can read the HTML prototypes, veect-codebase/ prototype, upload PNGs, and remaining bundle files (ADR-029: reference only, never vendored into packages/). Ignore-list entries so whole-codebase auditors and formatters skip reference material: .prettierignore (byte preservation through lint-staged), .fallowrc.json ignorePatterns, root ESLint ignores, and the coverage:diff allowlist in scripts/coverage/diff.mjs (+ unit test). .DS_Store files skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
3731 lines
282 KiB
HTML
3731 lines
282 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<script src="./support.js"></script>
|
||
</head>
|
||
<body>
|
||
<x-dc>
|
||
<helmet>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
|
||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Fragment+Mono&family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Space+Grotesk:wght@400;500;700&family=Lora:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
html,body{margin:0;padding:0;height:100%;}
|
||
body{background:#121110;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||
--bg:#121110;--panel:#191715;--raised:#221F1C;--line:#2B2724;--line2:#38332E;
|
||
--t1:#F1EEEA;--t2:#B8B0A6;--t3:#8C847A;
|
||
--iris:#3D7BFA;--iris-t:#8FB3FF;--iris-dim:rgba(61,123,250,.15);
|
||
--live:#3FB984;--warn:#E0A03A;--err:#E5484D;
|
||
--chip:#1E1B18;--hoverbg:rgba(241,238,234,.06);
|
||
}
|
||
body[data-th="light"]{background:#FAF9F7;
|
||
--bg:#FAF9F7;--panel:#FFFFFF;--raised:#FFFFFF;--line:#ECE8E2;--line2:#DDD7CE;
|
||
--t1:#1A1714;--t2:#55504A;--t3:#8A8279;
|
||
--iris:#2557D6;--iris-t:#2557D6;--iris-dim:rgba(37,87,214,.10);
|
||
--live:#1E9E6A;--warn:#B97A17;--err:#D13438;
|
||
--chip:#F3F1ED;--hoverbg:rgba(26,23,20,.05);
|
||
}
|
||
a{color:var(--iris-t);text-decoration:none} a:hover{color:var(--t1)}
|
||
*{box-sizing:border-box}
|
||
button{font:inherit;color:inherit;background:none;border:none;padding:0;cursor:pointer;text-align:left}
|
||
input,textarea,select{font:inherit;color:inherit}
|
||
input::placeholder,textarea::placeholder{color:inherit;opacity:.42}
|
||
::selection{background:rgba(61,123,250,.35)}
|
||
*:focus-visible{outline:2px solid var(--iris-t);outline-offset:1px;border-radius:4px}
|
||
::-webkit-scrollbar{width:10px;height:10px}
|
||
::-webkit-scrollbar-thumb{background:var(--line2);border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
@keyframes riseIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||
@keyframes popIn{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:none}}
|
||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||
@keyframes lineFlash{0%{background:rgba(61,123,250,.30)}100%{background:transparent}}
|
||
@keyframes nodePulse{0%{box-shadow:0 0 0 0 rgba(61,123,250,.55)}100%{box-shadow:0 0 0 12px rgba(61,123,250,0)}}
|
||
@keyframes shimmer{0%{background-position:-220px 0}100%{background-position:220px 0}}
|
||
@keyframes blinkq{0%,100%{opacity:1}50%{opacity:.35}}
|
||
@keyframes spinq{to{transform:rotate(360deg)}}
|
||
@media (prefers-reduced-motion: reduce){*{animation-duration:.01ms !important;transition-duration:.01ms !important}}
|
||
</style>
|
||
</helmet>
|
||
<div style="position:fixed;inset:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);color:var(--t1);font-family:'Instrument Sans',sans-serif;font-size:13px;line-height:1.4;">
|
||
|
||
<sc-if value="{{ isOnboarding }}" hint-placeholder-val="{{ true }}">
|
||
<div data-screen-label="Onboarding" style="flex:1;overflow:auto;display:flex;align-items:center;justify-content:center;padding:48px 32px;">
|
||
<div style="width:100%;max-width:920px;display:flex;flex-direction:column;">
|
||
|
||
<sc-if value="{{ obWelcome }}" hint-placeholder-val="{{ true }}">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="4" width="8" height="8" rx="1.5" transform="rotate(45 8 8)" fill="#3D7BFA"></rect></svg>
|
||
<span style="font-size:14px;font-weight:600;letter-spacing:.01em;">Weave</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);margin-left:4px;">design-system-native canvas</span>
|
||
</div>
|
||
<h1 style="font-size:clamp(30px,6vw,42px);font-weight:600;letter-spacing:-0.8px;line-height:1.08;margin:30px 0 0;">Bring your design system to life.</h1>
|
||
<p style="font-size:16px;color:var(--t2);line-height:1.55;max-width:600px;margin:14px 0 0;">Weave turns your own tokens and components into real, production-ready React — on-brand, high-craft, nothing lost in translation.</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:14px;margin-top:36px;">
|
||
<button onClick="{{ goPaste }}" style="flex:1 1 250px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;display:flex;flex-direction:column;align-items:flex-start;gap:8px;transition:border-color .15s,transform .15s;" style-hover="border-color:var(--iris);transform:translateY(-1px)">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--iris-t);">{ }</span>
|
||
<span style="font-size:15px;font-weight:600;">Paste your tokens</span>
|
||
<span style="font-size:13px;color:var(--t2);line-height:1.5;">A Tailwind config or CSS variables — Weave parses, contrast-checks, and re-themes the base kit to your brand.</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);margin-top:6px;">~30 seconds</span>
|
||
</button>
|
||
<button onClick="{{ goQuick }}" style="flex:1 1 250px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;display:flex;flex-direction:column;align-items:flex-start;gap:8px;transition:border-color .15s,transform .15s;" style-hover="border-color:var(--iris);transform:translateY(-1px)">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--iris-t);">◇</span>
|
||
<span style="font-size:15px;font-weight:600;">No token file? Start with 5</span>
|
||
<span style="font-size:13px;color:var(--t2);line-height:1.5;">Brand, neutrals, radius, type, spacing — set the essentials over the base kit. Import the real thing later.</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);margin-top:6px;">the no-file path</span>
|
||
</button>
|
||
</div>
|
||
<div style="margin-top:26px;">
|
||
<button onClick="{{ skipToEditor }}" style="font-size:13px;color:var(--iris-t);font-weight:500;" style-hover="color:var(--t1)">Skip — explore with the demo system →</button>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ obPaste }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ goWelcome }}" style="font-size:12.5px;color:var(--t3);align-self:flex-start;" style-hover="color:var(--t1)">← Back</button>
|
||
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Paste your tokens</h2>
|
||
<p style="font-size:13.5px;color:var(--t2);margin:0;">Tailwind config or CSS variables. Weave parses, contrast-checks, and re-themes the base kit — no hard stops.</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-top:20px;align-items:stretch;">
|
||
<textarea value="{{ pasteText }}" onChange="{{ onPasteText }}" spellCheck="{{ false }}" style="flex:1.15 1 300px;min-height:330px;background:var(--chip);border:1px solid var(--line);border-radius:12px;padding:14px 16px;font-family:'Fragment Mono',monospace;font-size:12px;line-height:1.75;color:var(--t1);resize:none;outline:none;"></textarea>
|
||
<div style="flex:1 1 280px;display:flex;flex-direction:column;gap:10px;">
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:9px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">PARSE</span>
|
||
<sc-for list="{{ parseSteps }}" as="item" hint-placeholder-count="5">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<span style="{{ item.glyphStyle }}">{{ item.glyph }}</span>
|
||
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);">{{ item.note }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<sc-if value="{{ contrastReady }}" hint-placeholder-val="{{ false }}">
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:9px;animation:riseIn .3s both;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">CONTRAST — TOKEN PAIRS</span>
|
||
<sc-for list="{{ contrastRows }}" as="item" hint-placeholder-count="3">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11.5px;color:var(--t2);">{{ item.pair }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11.5px;color:var(--t1);">{{ item.ratio }}</span>
|
||
<span style="{{ item.badgeStyle }}">{{ item.badge }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<button onClick="{{ parseOrReveal }}" style="{{ parseBtnStyle }}">{{ parseBtnLabel }}</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ obQuick }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ goWelcome }}" style="font-size:12.5px;color:var(--t3);align-self:flex-start;" style-hover="color:var(--t1)">← Back</button>
|
||
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Start with five tokens</h2>
|
||
<p style="font-size:13.5px;color:var(--t2);margin:0;">The essentials, set over the base kit. Everything stays a token — swap in the real file any time.</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:24px;margin-top:22px;">
|
||
<div style="flex:1 1 280px;display:flex;flex-direction:column;gap:18px;">
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">BRAND COLOR</span>
|
||
<div style="display:flex;gap:12px;align-items:center;">
|
||
<sc-for list="{{ brandSwatches }}" as="item" hint-placeholder-count="4">
|
||
<button onClick="{{ item.onPick }}" title="{{ item.hex }}" style="{{ item.style }}"></button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">NEUTRALS</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:2px;">
|
||
<sc-for list="{{ neutralSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">CORNER RADIUS</span>
|
||
<div style="display:flex;gap:12px;align-items:center;">
|
||
<input type="range" min="0" max="20" step="2" value="{{ radiusVal }}" onChange="{{ onRadius }}" style="flex:1;accent-color:var(--iris);">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11.5px;color:var(--t1);background:var(--chip);border:1px solid var(--line);border-radius:6px;padding:4px 8px;min-width:44px;text-align:center;">{{ radiusLabel }}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">TYPE</span>
|
||
<div style="display:flex;gap:8px;">
|
||
<sc-for list="{{ fontCards }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onPick }}" style="{{ item.cardStyle }}">
|
||
<span style="{{ item.prevStyle }}">Ag</span>
|
||
<span style="font-size:11.5px;color:var(--t2);">{{ item.name }}</span>
|
||
</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);letter-spacing:.08em;">SPACING SCALE</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:2px;max-width:220px;">
|
||
<sc-for list="{{ spaceSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ bringToLife }}" style="{{ lifeBtnStyle }}">Bring it to life →</button>
|
||
</div>
|
||
<div style="{{ kitVarsMini }}">
|
||
<div style="align-self:flex-start;background:var(--kd);color:var(--kas);border-radius:999px;padding:3px 10px;font-family:var(--kf);font-weight:600;font-size:11px;letter-spacing:.02em;transition:all .25s;">NEW</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||
<span style="font-size:12px;font-weight:600;color:var(--ki);font-family:var(--kf);transition:all .25s;">Email</span>
|
||
<div style="border:1px solid var(--kl);border-radius:var(--krb);padding:9px 12px;color:var(--km);font-size:13px;font-family:var(--kf);transition:all .25s;">you@company.com</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<div style="background:var(--ka);color:var(--kai);border-radius:var(--krb);padding:9px 16px;font-family:var(--kf);font-weight:600;font-size:13.5px;transition:all .25s;">Get started</div>
|
||
<div style="border:1px solid var(--kl);color:var(--ki);border-radius:var(--krb);padding:9px 16px;font-family:var(--kf);font-weight:600;font-size:13.5px;transition:all .25s;">Docs</div>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#8A8279;">live preview — your tokens over the base kit</span>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ obReveal }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;">
|
||
<sc-if value="{{ revealPre }}" hint-placeholder-val="{{ false }}">
|
||
<h2 style="font-size:26px;font-weight:600;letter-spacing:-.4px;margin:0;">Here's the base kit — in Weave gray.</h2>
|
||
</sc-if>
|
||
<sc-if value="{{ revealLive }}" hint-placeholder-val="{{ false }}">
|
||
<h2 style="font-size:26px;font-weight:600;letter-spacing:-.4px;margin:0;animation:popIn .4s both;">Your system is live.</h2>
|
||
</sc-if>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11.5px;color:var(--t3);margin-top:8px;">{{ revealStat }}</span>
|
||
</div>
|
||
<div style="{{ kitVars }}">
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;min-height:112px;">
|
||
<div style="background:var(--ka);color:var(--kai);border-radius:var(--krb);padding:9px 16px;font-family:var(--kf);font-weight:600;font-size:13.5px;transition:all .55s cubic-bezier(.2,.7,.2,1);">Get started</div>
|
||
<div style="border:1px solid var(--kl);color:var(--ki);border-radius:var(--krb);padding:8px 15px;font-family:var(--kf);font-weight:600;font-size:13px;transition:all .55s cubic-bezier(.2,.7,.2,1) .04s;">Secondary</div>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;min-height:112px;">
|
||
<div style="background:var(--kd);color:var(--kas);border-radius:999px;padding:4px 12px;font-family:var(--kf);font-weight:600;font-size:11.5px;letter-spacing:.02em;transition:all .55s cubic-bezier(.2,.7,.2,1) .08s;">NEW</div>
|
||
<div style="border:1px solid var(--kl);color:var(--km);border-radius:999px;padding:4px 12px;font-family:var(--kf);font-weight:500;font-size:11.5px;transition:all .55s cubic-bezier(.2,.7,.2,1) .1s;">Filter · 3</div>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:6px;justify-content:center;min-height:112px;">
|
||
<span style="font-size:12px;font-weight:600;color:var(--ki);font-family:var(--kf);transition:all .55s cubic-bezier(.2,.7,.2,1) .12s;">Email</span>
|
||
<div style="border:1px solid var(--kl);border-radius:var(--krb);padding:9px 12px;color:var(--km);font-size:12.5px;font-family:var(--kf);transition:all .55s cubic-bezier(.2,.7,.2,1) .14s;">you@company.com</div>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:8px;justify-content:center;min-height:112px;">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<div style="width:30px;height:30px;border-radius:999px;background:var(--kd);color:var(--kas);display:flex;align-items:center;justify-content:center;font-family:var(--kf);font-weight:600;font-size:11px;transition:all .55s cubic-bezier(.2,.7,.2,1) .16s;">MK</div>
|
||
<div style="display:flex;flex-direction:column;">
|
||
<span style="font-size:12.5px;font-weight:600;color:var(--ki);font-family:var(--kf);transition:all .55s cubic-bezier(.2,.7,.2,1) .18s;">Maya Kade</span>
|
||
<span style="font-size:11px;color:var(--km);font-family:var(--kf);transition:all .55s cubic-bezier(.2,.7,.2,1) .18s;">Product designer</span>
|
||
</div>
|
||
</div>
|
||
<div style="height:6px;border-radius:99px;background:var(--kd);width:100%;transition:all .55s cubic-bezier(.2,.7,.2,1) .2s;"></div>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:10px;justify-content:center;min-height:112px;">
|
||
<div style="display:flex;gap:14px;">
|
||
<span style="font-size:12.5px;font-weight:600;color:var(--ki);font-family:var(--kf);border-bottom:2px solid var(--ka);padding-bottom:5px;transition:all .55s cubic-bezier(.2,.7,.2,1) .22s;">Overview</span>
|
||
<span style="font-size:12.5px;color:var(--km);font-family:var(--kf);padding-bottom:5px;transition:all .55s cubic-bezier(.2,.7,.2,1) .22s;">Members</span>
|
||
<span style="font-size:12.5px;color:var(--km);font-family:var(--kf);padding-bottom:5px;transition:all .55s cubic-bezier(.2,.7,.2,1) .22s;">Billing</span>
|
||
</div>
|
||
<div style="height:1px;background:var(--kl);width:100%;transition:all .55s cubic-bezier(.2,.7,.2,1) .22s;"></div>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;align-items:center;gap:12px;justify-content:flex-start;min-height:112px;">
|
||
<div style="width:36px;height:21px;border-radius:999px;background:var(--ka);position:relative;transition:all .55s cubic-bezier(.2,.7,.2,1) .26s;">
|
||
<div style="position:absolute;top:2.5px;right:2.5px;width:16px;height:16px;border-radius:99px;background:#fff;"></div>
|
||
</div>
|
||
<span style="font-size:12.5px;color:var(--ki);font-family:var(--kf);font-weight:500;transition:all .55s cubic-bezier(.2,.7,.2,1) .26s;">Notifications</span>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:10px;justify-content:center;min-height:112px;">
|
||
<div style="position:relative;height:5px;border-radius:99px;background:var(--kd);transition:all .55s cubic-bezier(.2,.7,.2,1) .3s;">
|
||
<div style="position:absolute;left:0;top:0;bottom:0;width:62%;border-radius:99px;background:var(--ka);transition:all .55s cubic-bezier(.2,.7,.2,1) .3s;"></div>
|
||
<div style="position:absolute;left:62%;top:50%;transform:translate(-50%,-50%);width:15px;height:15px;border-radius:99px;background:#fff;border:1.5px solid var(--ka);transition:all .55s cubic-bezier(.2,.7,.2,1) .3s;"></div>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--km);transition:all .55s cubic-bezier(.2,.7,.2,1) .3s;">volume — 62</span>
|
||
</div>
|
||
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:4px;justify-content:center;min-height:112px;">
|
||
<span style="font-size:30px;font-weight:600;color:var(--ki);font-family:var(--kf);line-height:1;transition:all .55s cubic-bezier(.2,.7,.2,1) .34s;">Ag</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--km);transition:all .55s cubic-bezier(.2,.7,.2,1) .34s;">{{ revealFontNote }}</span>
|
||
</div>
|
||
</div>
|
||
<sc-if value="{{ revealDone }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:14px;margin-top:28px;animation:riseIn .4s both;">
|
||
<button onClick="{{ openEditor }}" style="{{ lifeBtnStyle }}">Open the editor →</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);">⌘K anywhere · ✦ to compose</span>
|
||
</div>
|
||
</sc-if>
|
||
</sc-if>
|
||
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isEditor }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Editor" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||
|
||
<div style="height:42px;flex:0 0 auto;display:flex;align-items:center;gap:5px;padding:0 10px;border-bottom:1px solid var(--line);position:relative;z-index:30;background:var(--bg);">
|
||
<svg width="16" height="16" viewBox="0 0 16 16" style="flex:0 0 auto;"><rect x="4" y="4" width="8" height="8" rx="1.5" transform="rotate(45 8 8)" fill="#3D7BFA"></rect></svg>
|
||
<div style="position:relative;">
|
||
<button onClick="{{ onProj }}" style="display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:7px;font-size:13px;font-weight:500;color:var(--t1);" style-hover="background:var(--hoverbg)">{{ projLabel }} <span style="color:var(--t3);font-size:9px;">▾</span></button>
|
||
<sc-if value="{{ projMenuOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div style="position:absolute;top:32px;left:0;z-index:60;width:240px;background:var(--raised);border:1px solid var(--line2);border-radius:11px;box-shadow:0 20px 48px rgba(0,0,0,.45);overflow:hidden;display:flex;flex-direction:column;animation:popIn .13s both;">
|
||
<div style="display:flex;align-items:center;gap:9px;padding:10px 12px;border-bottom:1px solid var(--line);">
|
||
<span style="width:24px;height:24px;border-radius:7px;background:var(--iris-dim);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--iris-t);flex:0 0 auto;">◆</span>
|
||
<div style="display:flex;flex-direction:column;gap:1px;min-width:0;">
|
||
<span style="font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Solstice — Marketing site</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);">solstice system · autosaved</span>
|
||
</div>
|
||
</div>
|
||
<div style="padding:5px;display:flex;flex-direction:column;">
|
||
<sc-for list="{{ projMenuItems }}" as="item" hint-placeholder-count="6">
|
||
<sc-if value="{{ item.isDivider }}" hint-placeholder-val="{{ false }}">
|
||
<div style="height:1px;background:var(--line);margin:5px 6px;"></div>
|
||
</sc-if>
|
||
<sc-if value="{{ item.isItem }}" hint-placeholder-val="{{ true }}">
|
||
<button onClick="{{ item.onRun }}" style="display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:7px;font-size:12.5px;color:var(--t1);width:100%;" style-hover="background:var(--hoverbg)">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);width:16px;flex:0 0 auto;">{{ item.glyph }}</span>
|
||
<span style="flex:1;min-width:0;text-align:left;">{{ item.label }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);flex:0 0 auto;">{{ item.hint }}</span>
|
||
</button>
|
||
</sc-if>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
<span style="width:1px;height:18px;background:var(--line);"></span>
|
||
|
||
<sc-if value="{{ showSave }}" hint-placeholder-val="{{ true }}">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ saveLabel }}</span>
|
||
</sc-if>
|
||
<sc-if value="{{ showBreakpoint }}" hint-placeholder-val="{{ true }}">
|
||
<div style="position:absolute;left:50%;transform:translateX(-50%);display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ bpSegs }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onClick }}" title="{{ item.title }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<span style="flex:1"></span>
|
||
|
||
<button onClick="{{ openPreview }}" style="display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:7px;font-size:12.5px;font-weight:500;color:var(--t2);" style-hover="background:var(--hoverbg);color:var(--t1)"><span style="font-size:11px;">⟳</span>{{ previewLabel }}</button>
|
||
<button onClick="{{ togglePolish }}" style="{{ polishBtnStyle }}"><span style="font-size:12px;">✧</span>{{ polishLabel }}</button>
|
||
<button onClick="{{ toggleCode }}" style="{{ codeBtnStyle }}"><span style="font-family:'Fragment Mono',monospace;font-size:11px;"></></span>{{ codeLabel }}</button>
|
||
<button onClick="{{ openExport }}" style="display:flex;align-items:center;gap:5px;background:var(--iris);color:#fff;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:600;letter-spacing:.01em;" style-hover="filter:brightness(1.12)"><span style="font-size:11px;">⇪</span>{{ exportLabel }}</button>
|
||
|
||
<button onClick="{{ toggleTheme }}" title="Toggle app theme" style="padding:6px 9px;border-radius:7px;color:var(--t2);font-size:13px;" style-hover="background:var(--hoverbg);color:var(--t1)">◐</button>
|
||
<sc-if value="{{ showAvatar }}" hint-placeholder-val="{{ true }}">
|
||
<button onClick="{{ goProfile }}" title="Profile" style="width:26px;height:26px;border-radius:99px;background:var(--raised);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;color:var(--t2);" style-hover="border-color:var(--t3)">M</button>
|
||
</sc-if>
|
||
</div>
|
||
|
||
<div style="{{ bodyRowStyle }}">
|
||
|
||
<div style="{{ activityBarStyle }}">
|
||
<sc-for list="{{ activityItems }}" as="item" hint-placeholder-count="9">
|
||
<sc-if value="{{ item.isDivider }}" hint-placeholder-val="{{ false }}">
|
||
<div style="height:1px;width:26px;background:var(--line);margin:4px 0;flex:0 0 auto;"></div>
|
||
</sc-if>
|
||
<sc-if value="{{ item.isBtn }}" hint-placeholder-val="{{ true }}">
|
||
<button onClick="{{ item.onClick }}" title="{{ item.title }}" style="{{ item.style }}">
|
||
<span style="display:flex;align-items:center;justify-content:center;height:16px;">{{ item.glyph }}</span>
|
||
<span style="font-size:8px;letter-spacing:.03em;font-weight:500;">{{ item.label }}</span>
|
||
</button>
|
||
</sc-if>
|
||
</sc-for>
|
||
</div>
|
||
|
||
<sc-if value="{{ leftRailVisible }}" hint-placeholder-val="{{ true }}">
|
||
<div style="{{ leftRailStyle }}">
|
||
<sc-if value="{{ showRailHandles }}" hint-placeholder-val="{{ false }}">
|
||
<div onPointerDown="{{ startLeftResize }}" title="Drag to resize" style="position:absolute;top:0;bottom:0;right:-3px;width:7px;cursor:col-resize;z-index:31;" style-hover="background:var(--iris-dim)"></div>
|
||
</sc-if>
|
||
<button onClick="{{ collapseLeft }}" title="Collapse panel" style="position:absolute;top:9px;right:6px;z-index:5;width:24px;height:24px;border-radius:6px;color:var(--t3);display:flex;align-items:center;justify-content:center;font-size:16px;" style-hover="color:var(--t1);background:var(--hoverbg)">«</button>
|
||
<div style="display:flex;align-items:center;gap:7px;margin:14px 32px 8px 12px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;letter-spacing:.08em;color:var(--t3);">{{ leftTabTitle }}</span>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;padding:2px 8px 16px;display:flex;flex-direction:column;">
|
||
|
||
<sc-if value="{{ tabLayers }}" hint-placeholder-val="{{ false }}">
|
||
<sc-for list="{{ layerRows }}" as="item" hint-placeholder-count="8">
|
||
<button onClick="{{ item.onClick }}" onMouseOver="{{ item.onOver }}" style="{{ item.style }}">
|
||
<span onClick="{{ item.onCaret }}" style="{{ item.caretStyle }}">{{ item.caret }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;width:14px;flex:0 0 auto;opacity:.7;">{{ item.glyph }}</span>
|
||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.name }}</span>
|
||
</button>
|
||
</sc-for>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ tabLibrary }}" hint-placeholder-val="{{ false }}">
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:6px 2px 8px;">BASE KIT — MAPPED TO YOUR CODE</div>
|
||
<sc-for list="{{ libItems }}" as="item" hint-placeholder-count="10">
|
||
<div draggable="{{ true }}" onDragStart="{{ item.onDrag }}" style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--raised);border:1px solid var(--line);border-radius:8px;cursor:grab;margin-bottom:6px;" style-hover="border-color:var(--line2)">
|
||
<span style="width:22px;height:22px;border-radius:6px;background:var(--chip);display:flex;align-items:center;justify-content:center;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);flex:0 0 auto;">{{ item.glyph }}</span>
|
||
<span style="font-size:12.5px;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.name }}</span>
|
||
<button onClick="{{ item.onChip }}" title="{{ item.chipTitle }}" style="{{ item.chipStyle }}">{{ item.chip }}</button>
|
||
<button onClick="{{ item.onIso }}" title="View in isolation — variants and states" style="color:var(--t3);font-size:11px;padding:1px 4px;border-radius:5px;flex:0 0 auto;" style-hover="color:var(--t1);background:var(--hoverbg)">◉</button>
|
||
<button onClick="{{ item.onAdd }}" title="Add to canvas" style="color:var(--t3);font-size:13px;padding:1px 4px;border-radius:5px;flex:0 0 auto;" style-hover="color:var(--t1);background:var(--hoverbg)">+</button>
|
||
</div>
|
||
</sc-for>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:12px 2px 8px;">PATTERNS — YOURS</div>
|
||
<sc-if value="{{ hasPatterns }}" hint-placeholder-val="{{ false }}">
|
||
<sc-for list="{{ patternRows }}" as="item" hint-placeholder-count="2">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--raised);border:1px solid var(--line);border-radius:8px;margin-bottom:6px;">
|
||
<span style="width:22px;height:22px;border-radius:6px;background:var(--iris-dim);display:flex;align-items:center;justify-content:center;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--iris-t);flex:0 0 auto;">▦</span>
|
||
<span style="font-size:12.5px;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.name }}</span>
|
||
<button onClick="{{ item.onAdd }}" title="Add to canvas" style="color:var(--t3);font-size:13px;padding:1px 4px;border-radius:5px;flex:0 0 auto;" style-hover="color:var(--t1);background:var(--hoverbg)">+</button>
|
||
</div>
|
||
</sc-for>
|
||
</sc-if>
|
||
<sc-if value="{{ noPatterns }}" hint-placeholder-val="{{ true }}">
|
||
<div style="font-size:11.5px;color:var(--t3);line-height:1.55;padding:0 2px;">Save a selection as a pattern — it lives here, in your system.</div>
|
||
</sc-if>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:12px 2px 8px;">CUSTOM — YOUR CODE</div>
|
||
<sc-for list="{{ customRows }}" as="item" hint-placeholder-count="1">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--raised);border:1px solid var(--line);border-radius:8px;margin-bottom:6px;">
|
||
<span style="width:22px;height:22px;border-radius:6px;background:var(--iris-dim);display:flex;align-items:center;justify-content:center;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--iris-t);flex:0 0 auto;">◈</span>
|
||
<div style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0;">
|
||
<span style="font-size:12.5px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.name }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.file }}</span>
|
||
</div>
|
||
<button onClick="{{ item.onIso }}" title="View in isolation" style="color:var(--t3);font-size:11px;padding:1px 4px;border-radius:5px;flex:0 0 auto;" style-hover="color:var(--t1);background:var(--hoverbg)">◉</button>
|
||
<button onClick="{{ item.onAdd }}" title="Add to canvas" style="color:var(--t3);font-size:13px;padding:1px 4px;border-radius:5px;flex:0 0 auto;" style-hover="color:var(--t1);background:var(--hoverbg)">+</button>
|
||
</div>
|
||
</sc-for>
|
||
<button onClick="{{ pickCompFile }}" style="display:flex;align-items:center;gap:7px;border:1px dashed var(--line2);border-radius:8px;padding:8px 10px;font-size:11.5px;font-weight:500;color:var(--t3);width:100%;" style-hover="border-color:var(--iris);color:var(--t1)">+ Upload component <span style="font-family:'Fragment Mono',monospace;font-size:9.5px;">.tsx / .jsx</span></button>
|
||
<input type="file" accept=".jsx,.tsx,.js" ref="{{ compFileRef }}" onChange="{{ onCompFile }}" style="display:none;">
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ tabTokens }}" hint-placeholder-val="{{ false }}">
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:6px 2px 8px;">COLOR — BRAND</div>
|
||
<button onClick="{{ openBrandPop }}" style="display:flex;align-items:center;gap:8px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:7px 9px;box-shadow:inset 0 1px 0 rgba(255,255,255,.03);" style-hover="border-color:var(--line2)">
|
||
<span style="{{ brandSwatchStyle }}"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;">brand-500</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ brandHex }}</span>
|
||
<span style="color:var(--t3);font-size:9px;">▾</span>
|
||
</button>
|
||
<sc-if value="{{ brandPopOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div style="position:relative;">
|
||
<div style="position:absolute;top:6px;left:0;right:0;z-index:40;background:var(--raised);border:1px solid var(--line2);border-radius:10px;padding:11px;box-shadow:0 16px 40px rgba(0,0,0,.4);display:flex;flex-direction:column;gap:10px;animation:popIn .15s both;">
|
||
<div style="display:flex;gap:9px;">
|
||
<sc-for list="{{ brandOptions }}" as="item" hint-placeholder-count="4">
|
||
<button onClick="{{ item.onPick }}" title="{{ item.hex }}" style="{{ item.style }}"></button>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;gap:6px;">
|
||
<input value="{{ hexDraft }}" onChange="{{ onHexDraft }}" style="flex:1;min-width:0;background:var(--chip);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-family:'Fragment Mono',monospace;font-size:11.5px;color:var(--t1);outline:none;">
|
||
<button onClick="{{ applyHex }}" style="background:var(--iris);color:#fff;border-radius:6px;padding:5px 10px;font-size:11.5px;font-weight:600;">Apply</button>
|
||
</div>
|
||
<div style="display:flex;align-items:center;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);line-height:1.5;flex:1;">edits ripple across canvas + code</span>
|
||
<button onClick="{{ closeBrandPop }}" style="font-size:10.5px;color:var(--t3);" style-hover="color:var(--t1)">close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;border-radius:7px;overflow:hidden;border:1px solid var(--line);margin-top:8px;">
|
||
<sc-for list="{{ brandRamp }}" as="item" hint-placeholder-count="10">
|
||
<span title="{{ item.t }}" style="{{ item.style }}"></span>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);margin-top:6px;">{{ whereUsed }}</span>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:14px 2px 8px;">COLOR — NEUTRAL</div>
|
||
<div style="display:flex;border-radius:7px;overflow:hidden;border:1px solid var(--line);">
|
||
<sc-for list="{{ neutralRamp }}" as="item" hint-placeholder-count="10">
|
||
<span title="{{ item.t }}" style="{{ item.style }}"></span>
|
||
</sc-for>
|
||
</div>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:14px 2px 8px;">TYPE</div>
|
||
<div style="display:flex;align-items:center;gap:10px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:8px 10px;box-shadow:inset 0 1px 0 rgba(255,255,255,.03);">
|
||
<span style="{{ fontPrevStyle }}">Ag</span>
|
||
<select value="{{ fontName }}" onChange="{{ onFontPick }}" style="flex:1;min-width:0;background:transparent;border:none;color:var(--t1);font-size:12px;outline:none;cursor:pointer;">
|
||
<option value="Bricolage Grotesque">Bricolage Grotesque</option>
|
||
<option value="Space Grotesk">Space Grotesk</option>
|
||
<option value="Lora">Lora</option>
|
||
</select>
|
||
</div>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:14px 2px 8px;">NEUTRALS</div>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:2px;">
|
||
<sc-for list="{{ neutralTokenSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:14px 2px 8px;">RADIUS</div>
|
||
<div style="display:flex;align-items:center;gap:8px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:7px 9px;box-shadow:inset 0 1px 0 rgba(255,255,255,.03);">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;">radius-md</span>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ radMinus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">−</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;min-width:34px;text-align:center;">{{ radiusChip }}</span>
|
||
<button onClick="{{ radPlus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">+</button>
|
||
</div>
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:14px 2px 8px;">SPACING</div>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:2px;">
|
||
<sc-for list="{{ spaceTokenSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);margin-top:6px;">4 · 8 · 12 · 16 · 24 · 32 · 48</span>
|
||
</sc-if>
|
||
<sc-if value="{{ tabHistory }}" hint-placeholder-val="{{ false }}">
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;padding:6px 2px 8px;">HISTORY — EVERY CHANGE, LABELLED</div>
|
||
<sc-if value="{{ historyEmpty }}" hint-placeholder-val="{{ true }}">
|
||
<div style="font-size:11.5px;color:var(--t3);line-height:1.55;padding:0 2px;">No steps yet — every edit lands here with a label. Click one to travel back.</div>
|
||
</sc-if>
|
||
<sc-for list="{{ historyRows }}" as="item" hint-placeholder-count="4">
|
||
<button onClick="{{ item.onJump }}" style="{{ item.rowStyle }}" style-hover="background:var(--hoverbg)">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);width:26px;flex:0 0 auto;">{{ item.step }}</span>
|
||
<span style="font-size:12px;color:var(--t1);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;">{{ item.label }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);flex:0 0 auto;">{{ item.time }}</span>
|
||
</button>
|
||
</sc-for>
|
||
</sc-if>
|
||
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<div style="flex:1;min-width:320px;position:relative;display:flex;flex-direction:column;background:var(--bg);">
|
||
<div ref="{{ deskRef }}" onMouseOver="{{ onDeskOver }}" onClick="{{ onDeskClick }}" onPointerDown="{{ onDeskPan }}" style="{{ deskStyle }}">
|
||
{{ canvasEl }}
|
||
</div>
|
||
<sc-if value="{{ drawMode }}" hint-placeholder-val="{{ false }}">
|
||
<div onPointerDown="{{ strokeStart }}" style="position:absolute;inset:0;z-index:24;cursor:crosshair;"></div>
|
||
</sc-if>
|
||
<sc-if value="{{ hasStrokes }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ clearStrokes }}" style="position:absolute;top:12px;right:12px;z-index:22;font-family:'Fragment Mono',monospace;font-size:10px;letter-spacing:.06em;color:var(--warn);background:var(--raised);border:1px solid rgba(224,160,58,.45);border-radius:8px;padding:6px 10px;box-shadow:0 8px 24px rgba(0,0,0,.35);" style-hover="color:var(--t1)">CLEAR MARKS ✕</button>
|
||
</sc-if>
|
||
<sc-if value="{{ isoOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div style="position:absolute;inset:0;z-index:23;background:var(--bg);display:flex;flex-direction:column;">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;letter-spacing:.08em;color:var(--t3);">ISOLATION</span>
|
||
<span style="font-size:13px;font-weight:600;">{{ isoName }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);background:var(--chip);border:1px solid var(--line);border-radius:99px;padding:2px 8px;">{{ isoMap }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">real runtime · variants × states</span>
|
||
<button onClick="{{ exitIso }}" style="border:1px solid var(--line2);color:var(--t1);padding:5px 11px;border-radius:7px;font-size:11.5px;font-weight:500;" style-hover="border-color:var(--t3)">Esc ✕</button>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:36px 24px;">{{ isoEl }}</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ bubbleOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div style="{{ bubbleStyle }}">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--warn);border:1px solid rgba(224,160,58,.5);border-radius:5px;padding:2px 6px;flex:0 0 auto;">{{ bubbleTag }}</span>
|
||
<input autoFocus="{{ true }}" value="{{ bubbleText }}" onChange="{{ onBubbleText }}" onKeyDown="{{ onBubbleKey }}" placeholder="Tell Weave what to change…" style="flex:1;background:none;border:none;outline:none;font-size:12.5px;color:var(--t1);min-width:0;">
|
||
<button onClick="{{ submitBubble }}" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);border:1px solid var(--line);border-radius:5px;padding:2px 7px;flex:0 0 auto;" style-hover="color:var(--t1)">⏎</button>
|
||
<button onClick="{{ closeBubble }}" style="color:var(--t3);font-size:10px;padding:2px;flex:0 0 auto;" style-hover="color:var(--t1)">✕</button>
|
||
</div>
|
||
</sc-if>
|
||
<div style="{{ zoomBarStyle }}">
|
||
<div style="display:flex;align-items:center;background:var(--raised);border:1px solid var(--line2);border-radius:7px;overflow:hidden;">
|
||
<button onClick="{{ zoomOut }}" title="Zoom out (⌘−)" style="padding:5px 9px;color:var(--t2);font-size:12px;" style-hover="color:var(--t1);background:var(--hoverbg)">−</button>
|
||
<button onClick="{{ zoomReset }}" title="Zoom to 100% (⌘1)" style="padding:5px 7px;color:var(--t2);font-family:'Fragment Mono',monospace;font-size:10.5px;min-width:44px;text-align:center;" style-hover="color:var(--t1)">{{ zoomPct }}</button>
|
||
<button onClick="{{ zoomIn }}" title="Zoom in (⌘+)" style="padding:5px 9px;color:var(--t2);font-size:12px;" style-hover="color:var(--t1);background:var(--hoverbg)">+</button>
|
||
</div>
|
||
<button onClick="{{ fitView }}" title="Zoom to fit (⌘0)" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t2);background:var(--raised);border:1px solid var(--line2);border-radius:7px;padding:5px 9px;" style-hover="color:var(--t1)">fit</button>
|
||
<button onClick="{{ addFrame }}" title="Add a frame to the board" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t2);background:var(--raised);border:1px solid var(--line2);border-radius:7px;padding:5px 9px;" style-hover="color:var(--t1)">+ frame</button>
|
||
<button onClick="{{ toggleDesk }}" title="Board backdrop — independent of app theme" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t2);background:var(--raised);border:1px solid var(--line2);border-radius:7px;padding:5px 9px;" style-hover="color:var(--t1)">{{ deskLabel }}</button>
|
||
<sc-if value="{{ showPanHint }}" hint-placeholder-val="{{ false }}">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);margin-left:4px;">scroll — pan · ⌘ scroll — zoom · drag board — pan</span>
|
||
</sc-if>
|
||
</div>
|
||
|
||
<div style="{{ pillWrapStyle }}">
|
||
<div style="display:flex;align-items:center;gap:3px;background:var(--raised);border:1px solid var(--line2);border-radius:16px;padding:3px 5px;box-shadow:0 12px 36px rgba(0,0,0,.4);pointer-events:auto;flex-wrap:wrap;justify-content:center;max-width:100%;">
|
||
<span style="width:8px;height:8px;border-radius:2px;background:var(--iris);transform:rotate(45deg);margin:0 5px;flex:0 0 auto;"></span>
|
||
<button onClick="{{ togglePick }}" title="Pick an element, then tell Weave what to change" style="{{ pickBtnStyle }}">{{ pickBtnLbl }}</button>
|
||
<button onClick="{{ toggleDraw }}" title="Scribble an annotation over the board" style="{{ drawBtnStyle }}">{{ drawBtnLbl }}</button>
|
||
<button onClick="{{ toggleOverlays }}" title="Show / hide selection overlays and marks" style="{{ overlaysBtnStyle }}">{{ overlaysLabel }}</button>
|
||
<button onClick="{{ micStub }}" title="Voice input" style="{{ micBtnStyle }}">{{ voiceBtnLbl }}</button>
|
||
</div>
|
||
</div>
|
||
<sc-if value="{{ chatPanelVisible }}" hint-placeholder-val="{{ false }}">
|
||
<div style="{{ chatPanelStyle }}">
|
||
<div onPointerDown="{{ startChatResize }}" title="Drag to resize" style="position:absolute;top:0;bottom:0;right:-3px;width:7px;cursor:col-resize;z-index:31;" style-hover="background:var(--iris-dim)"></div>
|
||
<div style="background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
|
||
<sc-if value="{{ chatHeaderShow }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--line);">
|
||
<span style="color:var(--iris-t);font-size:12px;">✦</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);letter-spacing:.06em;">COMPOSE — CONSTRAINED TO YOUR SYSTEM</span>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ clearChat }}" title="New chat" style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);padding:3px 6px;border-radius:5px;" style-hover="color:var(--t1);background:var(--hoverbg)">clear</button>
|
||
<button onClick="{{ toggleChatPanel }}" title="Collapse chat" style="color:var(--t3);font-size:16px;line-height:1;padding:2px 8px 4px;border-radius:5px;" style-hover="color:var(--t1);background:var(--hoverbg)">«</button>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ chatThreadShow }}" hint-placeholder-val="{{ false }}">
|
||
<div ref="{{ chatRef }}" style="flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:10px;padding:12px;">
|
||
<sc-if value="{{ chatEmpty }}" hint-placeholder-val="{{ true }}">
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding:6px 2px;">
|
||
<span style="color:var(--iris-t);font-size:16px;">✦</span>
|
||
<span style="font-size:12.5px;color:var(--t2);line-height:1.6;">Describe a section — Weave composes it from your mapped components and tokens only.</span>
|
||
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-start;">
|
||
<sc-for list="{{ aiChips }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onPick }}" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t2);background:var(--chip);border:1px solid var(--line);padding:5px 11px;border-radius:99px;" style-hover="border-color:var(--iris);color:var(--t1)">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);line-height:1.6;">◎ pick — scope an edit to one element<br>✎ draw — scribble a note on the board</span>
|
||
</div>
|
||
</sc-if>
|
||
<sc-for list="{{ chatMsgs }}" as="item" hint-placeholder-count="2">
|
||
<div style="{{ item.rowStyle }}">
|
||
<div style="{{ item.bubbleStyle }}">
|
||
<span style="{{ item.roleStyle }}">{{ item.roleLabel }}</span>
|
||
<sc-if value="{{ item.hasAtts }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
<sc-for list="{{ item.atts }}" as="att" hint-placeholder-count="1">
|
||
<span style="display:flex;align-items:center;gap:5px;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t2);background:var(--chip);border:1px solid var(--line);border-radius:6px;padding:3px 7px;">▧ {{ att.name }}</span>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<span style="{{ item.textStyle }}">{{ item.text }}</span>
|
||
<sc-if value="{{ item.isProposal }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap;">
|
||
<button onClick="{{ acceptProposal }}" style="background:var(--iris);color:#fff;border-radius:7px;padding:6px 12px;font-size:12px;font-weight:600;">Accept ⏎</button>
|
||
<button onClick="{{ discardProposal }}" style="color:var(--t3);font-size:12px;font-weight:500;padding:6px 8px;" style-hover="color:var(--t1)">Discard</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">staged as a ghost — nothing applied</span>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ item.isRefusal }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap;">
|
||
<button onClick="{{ composePrimitives }}" style="border:1px solid var(--iris);color:var(--iris-t);padding:6px 11px;border-radius:7px;font-size:12px;font-weight:500;" style-hover="background:var(--iris-dim)">Compose from primitives</button>
|
||
<button onClick="{{ dismissRefusal }}" style="color:var(--t3);font-size:12px;padding:6px 4px;" style-hover="color:var(--t1)">Not now</button>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</div>
|
||
</sc-for>
|
||
<sc-if value="{{ aiThinking }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:2px 4px;">
|
||
<span style="width:11px;height:11px;border-radius:99px;border:1.5px solid var(--iris-t);border-top-color:transparent;animation:spinq .7s linear infinite;flex:0 0 auto;"></span>
|
||
<span style="font-size:12px;color:var(--t2);">composing from your system…</span>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
<div style="{{ composerStyle }}">
|
||
<sc-if value="{{ aiIdleQuick }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;gap:5px;flex-wrap:wrap;align-items:center;">
|
||
<sc-for list="{{ quickChips }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onPick }}" style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--warn);background:rgba(224,160,58,.08);border:1px solid rgba(224,160,58,.4);padding:3px 8px;border-radius:99px;" style-hover="color:var(--t1)">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ aiIdleNoTarget }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;gap:5px;flex-wrap:wrap;align-items:center;">
|
||
<sc-for list="{{ aiChips }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onPick }}" style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);background:var(--chip);border:1px solid var(--line);padding:3px 8px;border-radius:99px;" style-hover="border-color:var(--iris);color:var(--t1)">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;align-items:flex-start;gap:8px;background:var(--chip);border:1px solid var(--line);border-radius:11px;padding:11px 10px;min-height:64px;">
|
||
<span style="color:var(--iris-t);font-size:14px;flex:0 0 auto;">✦</span>
|
||
<sc-if value="{{ hasAiTarget }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ clearAiTarget }}" title="Scoped to this element — click to clear" style="{{ aiTargetChipStyle }}">{{ aiTargetLabel }} ✕</button>
|
||
</sc-if>
|
||
<input ref="{{ aiRef }}" value="{{ aiText }}" onChange="{{ onAiText }}" onKeyDown="{{ onAiKey }}" placeholder="{{ aiPlaceholder }}" style="flex:1;background:none;border:none;outline:none;font-size:13.5px;color:var(--t1);min-width:0;">
|
||
<button onClick="{{ submitAi }}" title="Send" style="{{ sendBtnStyle }}">{{ sendGlyph }}</button>
|
||
</div>
|
||
|
||
|
||
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;">
|
||
<button onClick="{{ pickAttach }}" title="Attach an image — visual reference for compose" style="width:24px;height:24px;border-radius:99px;border:1px solid var(--line);color:var(--t2);display:flex;align-items:center;justify-content:center;font-size:13px;flex:0 0 auto;" style-hover="color:var(--t1);border-color:var(--line2)">+</button>
|
||
<select value="{{ aiModel }}" onChange="{{ onModel }}" style="background:var(--chip);border:1px solid var(--line);border-radius:7px;color:var(--t2);font-family:'Fragment Mono',monospace;font-size:10.5px;padding:4px 6px;outline:none;cursor:pointer;">
|
||
<option value="claude-haiku-4-5">haiku 4.5 — fast</option>
|
||
<option value="claude-sonnet-4-5">sonnet 4.5 — sharper</option>
|
||
</select>
|
||
<sc-for list="{{ pendingAttChips }}" as="att" hint-placeholder-count="1">
|
||
<span style="display:flex;align-items:center;gap:5px;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t2);background:var(--chip);border:1px solid var(--line);border-radius:6px;padding:3px 7px;">▧ {{ att.name }}<button onClick="{{ att.onRemove }}" style="color:var(--t3);font-size:9px;" style-hover="color:var(--t1)">✕</button></span>
|
||
</sc-for>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">⏎ send · esc dismiss</span>
|
||
<input type="file" accept="image/*" multiple ref="{{ fileRef }}" onChange="{{ onFiles }}" style="display:none;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
|
||
<sc-if value="{{ rightRailVisible }}" hint-placeholder-val="{{ true }}">
|
||
<div style="{{ rightRailStyle }}">
|
||
<sc-if value="{{ showRailHandles }}" hint-placeholder-val="{{ false }}">
|
||
<div onPointerDown="{{ startRightResize }}" title="Drag to resize" style="position:absolute;top:0;bottom:0;left:-3px;width:7px;cursor:col-resize;z-index:31;" style-hover="background:var(--iris-dim)"></div>
|
||
</sc-if>
|
||
<button onClick="{{ collapseRight }}" title="Collapse panel" style="position:absolute;top:9px;left:6px;z-index:5;width:24px;height:24px;border-radius:6px;color:var(--t3);display:flex;align-items:center;justify-content:center;font-size:16px;" style-hover="color:var(--t1);background:var(--hoverbg)">»</button>
|
||
<div style="display:flex;align-items:center;gap:7px;margin:14px 10px 8px 32px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;letter-spacing:.08em;color:var(--t3);">{{ rightTabTitle }}</span>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;display:flex;flex-direction:column;">
|
||
|
||
<sc-if value="{{ rightInspector }}" hint-placeholder-val="{{ true }}">
|
||
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ true }}">
|
||
<div style="padding:26px 16px;color:var(--t3);font-size:12.5px;line-height:1.7;">
|
||
Select a component on the canvas.
|
||
<div style="font-family:'Fragment Mono',monospace;font-size:10.5px;margin-top:10px;line-height:1.8;">click — select · drag — move<br>⌘K — commands · ✦ — compose<br>⌘Z — undo · ⌫ — delete</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ false }}">
|
||
<div style="padding:4px 12px 16px;display:flex;flex-direction:column;gap:14px;">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<span style="width:26px;height:26px;border-radius:7px;background:var(--chip);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ selGlyph }}</span>
|
||
<div style="display:flex;flex-direction:column;min-width:0;">
|
||
<span style="font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ selName }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ selType }}</span>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ onSelMap }}" title="{{ selMapTitle }}" style="{{ selMapStyle }}">{{ selMapText }}</button>
|
||
<sc-if value="{{ selIsFrame }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">FRAME</span>
|
||
<input value="{{ frameNameVal }}" onChange="{{ onFrameName }}" style="background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12.5px;color:var(--t1);outline:none;width:100%;">
|
||
<div style="display:flex;flex-direction:column;gap:5px;margin-top:2px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">width</span>
|
||
<div style="display:flex;align-items:center;gap:5px;background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:4px;">
|
||
<button onClick="{{ frameWMinus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">−</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;flex:1;text-align:center;">{{ frameWVal }}</span>
|
||
<button onClick="{{ frameWPlus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ selIsTexty }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">CONTENT</span>
|
||
<input value="{{ selTextVal }}" onChange="{{ onSelText }}" style="background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12.5px;color:var(--t1);outline:none;width:100%;">
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ selIsButton }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">VARIANT</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ variantSegs }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ selIsHeading }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">SIZE</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ sizeSegs }}" as="item" hint-placeholder-count="4">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;margin-top:5px;">WEIGHT</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ weightSegs }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ selIsText }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">TONE</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ toneSegs }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ toneNote }}</span>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ selIsStacky }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">LAYOUT</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:2px;">
|
||
<sc-for list="{{ dirSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:5px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">gap</span>
|
||
<div style="display:flex;align-items:center;gap:5px;background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:4px;">
|
||
<button onClick="{{ gapMinus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">−</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;flex:1;text-align:center;">{{ selGap }}</span>
|
||
<button onClick="{{ gapPlus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">+</button>
|
||
</div>
|
||
</div>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:5px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">padding</span>
|
||
<div style="display:flex;align-items:center;gap:5px;background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:4px;">
|
||
<button onClick="{{ padMinus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">−</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;flex:1;text-align:center;">{{ selPad }}</span>
|
||
<button onClick="{{ padPlus }}" style="width:20px;height:20px;border-radius:5px;background:var(--raised);border:1px solid var(--line2);color:var(--t2);font-size:12px;display:flex;align-items:center;justify-content:center;" style-hover="color:var(--t1)">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<button onClick="{{ toggleTwOpen }}" style="display:flex;align-items:center;gap:6px;width:100%;" style-hover="background:var(--hoverbg)">
|
||
<span style="font-size:8px;color:var(--t3);width:10px;flex:0 0 auto;">{{ twCaret }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">TAILWIND — UTILITIES</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--iris-t);">{{ twSummary }}</span>
|
||
</button>
|
||
<sc-if value="{{ twOpen }}" hint-placeholder-val="{{ false }}">
|
||
<sc-for list="{{ twRows }}" as="item" hint-placeholder-count="5">
|
||
<div style="display:flex;align-items:center;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);width:50px;flex:0 0 auto;">{{ item.cat }}</span>
|
||
<select value="{{ item.val }}" onChange="{{ item.onPick }}" style="flex:1;min-width:0;background:var(--chip);border:1px solid var(--line);border-radius:6px;color:var(--t1);font-family:'Fragment Mono',monospace;font-size:10.5px;padding:4px 6px;outline:none;cursor:pointer;">
|
||
<sc-for list="{{ item.opts }}" as="opt" hint-placeholder-count="4">
|
||
<option value="{{ opt.v }}">{{ opt.l }}</option>
|
||
</sc-for>
|
||
</select>
|
||
</div>
|
||
</sc-for>
|
||
<input value="{{ twCustom }}" onChange="{{ onTwCustom }}" placeholder="custom — e.g. ring-1 ring-stone-200" style="background:var(--chip);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t1);outline:none;width:100%;">
|
||
</sc-if>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">TOKENS BOUND</span>
|
||
<sc-for list="{{ tokenChips }}" as="item" hint-placeholder-count="3">
|
||
<div style="display:flex;align-items:center;gap:6px;background:var(--chip);border:1px solid var(--line);border-radius:7px;padding:5px 8px;box-shadow:inset 0 1px 0 rgba(255,255,255,.03);">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.prop }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t1);">{{ item.tok }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap;">
|
||
<button onClick="{{ duplicateSel }}" style="font-size:11.5px;color:var(--t2);padding:5px 8px;border-radius:6px;border:1px solid var(--line);" style-hover="color:var(--t1);border-color:var(--line2)">⧉ Duplicate</button>
|
||
<sc-if value="{{ canSavePattern }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ savePattern }}" style="font-size:11.5px;color:var(--t2);padding:5px 8px;border-radius:6px;border:1px solid var(--line);" style-hover="color:var(--t1);border-color:var(--line2)">▦ Save pattern</button>
|
||
</sc-if>
|
||
<button onClick="{{ deleteSel }}" style="font-size:11.5px;color:var(--err);padding:5px 8px;border-radius:6px;border:1px solid var(--line);" style-hover="border-color:var(--err)">⌫ Delete</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ rightPolish }}" hint-placeholder-val="{{ false }}">
|
||
<div style="padding:4px 12px 16px;display:flex;flex-direction:column;gap:10px;flex:1;">
|
||
<sc-if value="{{ polishIdle }}" hint-placeholder-val="{{ true }}">
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding-top:14px;">
|
||
<span style="font-size:20px;color:var(--iris-t);">✧</span>
|
||
<span style="font-size:13.5px;font-weight:600;line-height:1.4;">Critique this screen against your craft standard.</span>
|
||
<span style="font-size:12.5px;color:var(--t2);line-height:1.6;">Spacing, hierarchy, contrast, alignment, states — every fix lands in your tokens and components, with the why.</span>
|
||
<button onClick="{{ runPolish }}" style="{{ runPolishStyle }}">Run Polish</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">standard: Impeccable</span>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ polishScanning }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding-top:16px;">
|
||
<div style="height:6px;border-radius:99px;background:linear-gradient(90deg,var(--chip) 0%,var(--line2) 50%,var(--chip) 100%);background-size:220px 100%;animation:shimmer 1.1s infinite linear;"></div>
|
||
<span style="font-size:12.5px;color:var(--t2);">Scanning spacing, hierarchy, contrast…</span>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ polishHasIssues }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ polishCount }}</span>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ fixAll }}" style="border:1px solid var(--iris);color:var(--iris-t);padding:4px 10px;border-radius:7px;font-size:11.5px;font-weight:500;" style-hover="background:var(--iris-dim)">Fix all</button>
|
||
<button onClick="{{ runPolish }}" title="Run again" style="color:var(--t3);font-size:12px;padding:4px;" style-hover="color:var(--t1)">⟳</button>
|
||
</div>
|
||
<sc-for list="{{ polishIssues }}" as="item" hint-placeholder-count="5">
|
||
<div style="background:var(--raised);border:1px solid var(--line);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:7px;">
|
||
<div style="display:flex;align-items:center;gap:7px;">
|
||
<span style="{{ item.sevStyle }}">{{ item.sev }}</span>
|
||
<span style="{{ item.titleStyle }}">{{ item.title }}</span>
|
||
</div>
|
||
<span style="font-size:11.5px;color:var(--t2);line-height:1.55;">{{ item.why }}</span>
|
||
<button onClick="{{ item.onFix }}" style="{{ item.fixStyle }}">{{ item.fixLabel }}</button>
|
||
</div>
|
||
</sc-for>
|
||
</sc-if>
|
||
<sc-if value="{{ polishClean }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px;padding-top:38px;animation:popIn .3s both;">
|
||
<span style="font-size:22px;color:var(--iris-t);">✦</span>
|
||
<span style="font-size:14px;font-weight:600;">Impeccable. Nothing to fix.</span>
|
||
<span style="font-size:12px;color:var(--t3);line-height:1.5;">Every check passed against your standard.</span>
|
||
<button onClick="{{ runPolish }}" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);margin-top:8px;" style-hover="color:var(--t1)">run again</button>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ codeOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div style="{{ codePanelStyle }}">
|
||
<div onPointerDown="{{ startCodeResize }}" title="Drag to resize" style="position:absolute;top:0;bottom:0;left:-3px;width:7px;cursor:col-resize;z-index:31;" style-hover="background:var(--iris-dim)"></div>
|
||
<div style="height:42px;flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:0 10px;border-bottom:1px solid #262019;">
|
||
<button onClick="{{ collapseCode }}" title="Collapse panel" style="width:24px;height:24px;border-radius:6px;color:#7A7268;display:flex;align-items:center;justify-content:center;font-size:16px;flex:0 0 auto;" style-hover="color:#F1EEEA;background:rgba(241,238,234,.06)">»</button>
|
||
<sc-for list="{{ codeFileSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ copyCode }}" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#8C847A;border:1px solid #2B2724;border-radius:6px;padding:4px 9px;margin-left:4px;" style-hover="color:#F1EEEA;border-color:#38332E">copy</button>
|
||
<button onClick="{{ toggleCodeEdit }}" style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#8C847A;border:1px solid #2B2724;border-radius:6px;padding:4px 9px;" style-hover="color:#F1EEEA;border-color:#38332E">{{ codeEditLabel }}</button>
|
||
<sc-if value="{{ codeEditing }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ applyCodeEdit }}" style="background:var(--iris);color:#fff;border-radius:6px;padding:4px 10px;font-size:10.5px;font-family:'Fragment Mono',monospace;font-weight:600;">apply</button>
|
||
</sc-if>
|
||
</div>
|
||
<sc-if value="{{ codeEditing }}" hint-placeholder-val="{{ false }}">
|
||
<div style="flex:1;min-height:0;position:relative;overflow:hidden;background:#161311;">
|
||
<div ref="{{ codeHlRef }}" style="position:absolute;inset:0;overflow:hidden;padding:12px 14px;font-family:'Fragment Mono',monospace;font-size:11px;line-height:1.8;pointer-events:none;">{{ codeDraftEl }}</div>
|
||
<textarea value="{{ codeDraft }}" onChange="{{ onCodeDraft }}" onScroll="{{ onCodeScroll }}" wrap="off" spellCheck="{{ false }}" style="position:absolute;inset:0;width:100%;height:100%;background:transparent;color:transparent;caret-color:#F1EEEA;border:none;outline:none;resize:none;padding:12px 14px;font-family:'Fragment Mono',monospace;font-size:11px;line-height:1.8;white-space:pre;overflow:auto;"></textarea>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ codeReadonly }}" hint-placeholder-val="{{ true }}">
|
||
<div style="flex:1;overflow:auto;padding:10px 0;">{{ codeEl }}</div>
|
||
</sc-if>
|
||
<div style="height:30px;flex:0 0 auto;border-top:1px solid #262019;display:flex;align-items:center;gap:12px;padding:0 12px;font-family:'Fragment Mono',monospace;font-size:9.5px;color:#7A7268;">
|
||
<span>prettier ✓</span><span>tsc --noEmit ✓</span><span style="flex:1"></span><span>imports @solstice/ui</span>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isSignin }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Sign in" style="flex:1;display:flex;align-items:center;justify-content:center;padding:32px;">
|
||
<div style="width:360px;max-width:100%;display:flex;flex-direction:column;gap:13px;">
|
||
<div style="display:flex;align-items:center;gap:9px;">
|
||
<svg width="18" height="18" viewBox="0 0 16 16"><rect x="4" y="4" width="8" height="8" rx="1.5" transform="rotate(45 8 8)" fill="#3D7BFA"></rect></svg>
|
||
<span style="font-size:15px;font-weight:600;">Weave</span>
|
||
</div>
|
||
<h1 style="font-size:26px;font-weight:600;letter-spacing:-.4px;margin:10px 0 0;">Sign in.</h1>
|
||
<p style="font-size:13px;color:var(--t2);line-height:1.55;margin:0 0 6px;">Your system is waiting — everything you compose exports as real React.</p>
|
||
<input value="{{ signinEmail }}" onChange="{{ onSigninEmail }}" onKeyDown="{{ onSigninKey }}" placeholder="you@company.com" style="background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:11px 13px;font-size:13.5px;color:var(--t1);outline:none;">
|
||
<button onClick="{{ signinGo }}" style="{{ signinBtnStyle }}">Continue →</button>
|
||
<div style="display:flex;align-items:center;gap:10px;margin:4px 0;">
|
||
<span style="flex:1;height:1px;background:var(--line);"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">or</span>
|
||
<span style="flex:1;height:1px;background:var(--line);"></span>
|
||
</div>
|
||
<button onClick="{{ ssoStub }}" style="border:1px solid var(--line2);border-radius:9px;padding:10px 13px;font-size:13px;font-weight:500;color:var(--t1);text-align:center;" style-hover="border-color:var(--t3)">Continue with Google</button>
|
||
<button onClick="{{ ssoStub }}" style="border:1px solid var(--line2);border-radius:9px;padding:10px 13px;font-size:13px;font-weight:500;color:var(--t1);text-align:center;" style-hover="border-color:var(--t3)">Continue with GitHub</button>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);line-height:1.6;margin-top:8px;">design-partner MVP — accounts are provisioned manually</span>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isHome }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Home" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||
<div style="height:52px;flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:0 16px;border-bottom:1px solid var(--line);">
|
||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="4" width="8" height="8" rx="1.5" transform="rotate(45 8 8)" fill="#3D7BFA"></rect></svg>
|
||
<span style="font-size:14px;font-weight:600;">Weave</span>
|
||
<span style="flex:1"></span>
|
||
<input value="{{ homeQuery }}" onChange="{{ onHomeQuery }}" placeholder="Search…" style="width:min(200px,24vw);background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font-size:12.5px;color:var(--t1);outline:none;">
|
||
<button onClick="{{ newProject }}" style="display:flex;align-items:center;gap:6px;background:var(--iris);color:#fff;padding:8px 14px;border-radius:8px;font-size:12.5px;font-weight:600;white-space:nowrap;" style-hover="filter:brightness(1.12)">+ New project</button>
|
||
<button onClick="{{ toggleTheme }}" title="Toggle app theme" style="padding:6px 9px;border-radius:7px;color:var(--t2);font-size:13px;" style-hover="background:var(--hoverbg);color:var(--t1)">◐</button>
|
||
<button onClick="{{ goProfile }}" title="Profile" style="width:26px;height:26px;border-radius:99px;background:var(--raised);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;color:var(--t2);flex:0 0 auto;" style-hover="border-color:var(--t3)">M</button>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;">
|
||
<div style="max-width:1060px;margin:0 auto;padding:clamp(18px,4vw,36px) clamp(14px,4vw,32px) 80px;display:flex;flex-direction:column;">
|
||
<div style="display:flex;align-items:baseline;gap:12px;">
|
||
<h1 style="font-size:22px;font-weight:600;letter-spacing:-.3px;margin:0;">Projects</h1>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ homeMeta }}</span>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px;margin-top:18px;">
|
||
<sc-for list="{{ projectCards }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onOpen }}" style="{{ item.cardStyle }}" style-hover="border-color:var(--iris);transform:translateY(-1px)">
|
||
<div style="{{ item.thumbStyle }}">
|
||
<div style="{{ item.thumbA }}"></div>
|
||
<div style="{{ item.thumbB }}"></div>
|
||
<div style="{{ item.thumbC }}"></div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px;padding:12px 14px 13px;width:100%;">
|
||
<span style="font-size:13.5px;font-weight:600;">{{ item.name }}</span>
|
||
<div style="display:flex;align-items:center;gap:8px;width:100%;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.meta }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</sc-for>
|
||
<button onClick="{{ newProject }}" style="min-height:210px;border:1px dashed var(--line2);border-radius:14px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--t3);" style-hover="border-color:var(--iris);color:var(--t1)">
|
||
<span style="font-size:20px;">+</span>
|
||
<span style="font-size:12.5px;font-weight:500;">New project</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;">start from your system</span>
|
||
</button>
|
||
</div>
|
||
<h2 style="font-size:15px;font-weight:600;margin:38px 0 0;">Design systems</h2>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:16px;margin-top:14px;">
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:11px;">
|
||
<div style="display:flex;border-radius:7px;overflow:hidden;border:1px solid var(--line);">
|
||
<sc-for list="{{ homeRamp }}" as="item" hint-placeholder-count="5">
|
||
<span style="{{ item.style }}"></span>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||
<div style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:120px;">
|
||
<span style="font-size:13.5px;font-weight:600;">Solstice</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ homeSysMeta }}</span>
|
||
</div>
|
||
<button onClick="{{ openTokensFromHome }}" style="border:1px solid var(--line2);color:var(--t1);padding:6px 11px;border-radius:7px;font-size:11.5px;font-weight:500;" style-hover="border-color:var(--t3)">Open tokens</button>
|
||
<button onClick="{{ reimportGo }}" style="border:1px solid var(--line2);color:var(--t1);padding:6px 11px;border-radius:7px;font-size:11.5px;font-weight:500;" style-hover="border-color:var(--t3)">Re-import</button>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ goOnboardingHome }}" style="border:1px dashed var(--line2);border-radius:14px;padding:16px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:6px;color:var(--t3);" style-hover="border-color:var(--iris);color:var(--t1)">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--iris-t);">{ }</span>
|
||
<span style="font-size:12.5px;font-weight:600;">Bring a design system</span>
|
||
<span style="font-size:11.5px;line-height:1.5;text-align:left;">Tailwind config, CSS variables — or the 5-token quick path.</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isSettings }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Settings" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||
<div style="height:52px;flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:0 16px;border-bottom:1px solid var(--line);">
|
||
<button onClick="{{ goBack }}" style="font-size:12.5px;color:var(--t3);padding:6px 8px;border-radius:7px;" style-hover="color:var(--t1);background:var(--hoverbg)">← Back</button>
|
||
<span style="font-size:14px;font-weight:600;">Settings</span>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ goProfile }}" title="Profile" style="width:26px;height:26px;border-radius:99px;background:var(--raised);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;color:var(--t2);" style-hover="border-color:var(--t3)">M</button>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;">
|
||
<div style="max-width:760px;margin:0 auto;padding:clamp(18px,4vw,32px) clamp(14px,4vw,32px) 90px;display:flex;flex-direction:column;gap:26px;">
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">DESIGN SYSTEM</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<span style="{{ setSwatchStyle }}"></span>
|
||
<div style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:160px;">
|
||
<span style="font-size:13.5px;font-weight:600;">Solstice</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ setSysMeta }}</span>
|
||
</div>
|
||
<button onClick="{{ reimportGo }}" style="border:1px solid var(--line2);color:var(--t1);padding:7px 12px;border-radius:8px;font-size:12px;font-weight:500;" style-hover="border-color:var(--t3)">Re-import tokens</button>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">contrast — 3 pairs checked · brand body text passes AA at brand-700</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">COMPONENT MAPPINGS — THE MERGE BAR</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;display:flex;flex-direction:column;">
|
||
<sc-for list="{{ mapRows }}" as="item" hint-placeholder-count="8">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;" style-hover="background:var(--hoverbg)">
|
||
<span style="width:22px;height:22px;border-radius:6px;background:var(--chip);display:flex;align-items:center;justify-content:center;font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);flex:0 0 auto;">{{ item.glyph }}</span>
|
||
<span style="font-size:12.5px;font-weight:500;width:88px;flex:0 0 auto;">{{ item.name }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t2);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.path }}</span>
|
||
<button onClick="{{ item.onChip }}" style="{{ item.chipStyle }}">{{ item.chip }}</button>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">unmapped components fall back to the Weave base kit at export — the warning follows them</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">CRAFT STANDARD — POLISH RULESET</span>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<sc-for list="{{ craftCards }}" as="item" hint-placeholder-count="3">
|
||
<button onClick="{{ item.onPick }}" style="{{ item.style }}">
|
||
<span style="font-size:13px;font-weight:600;">{{ item.name }}</span>
|
||
<span style="font-size:11.5px;color:var(--t2);line-height:1.5;text-align:left;">{{ item.desc }}</span>
|
||
<span style="{{ item.tagStyle }}">{{ item.tag }}</span>
|
||
</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">EXPORT DEFAULTS</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;display:flex;flex-direction:column;">
|
||
<sc-for list="{{ exportPrefRows }}" as="item" hint-placeholder-count="4">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:9px 10px;">
|
||
<span style="font-size:12.5px;flex:1;">{{ item.label }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.note }}</span>
|
||
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">PLAN — DESIGN PARTNER</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<div style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:170px;">
|
||
<span style="font-size:13.5px;font-weight:600;">$0 during the MVP</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">billing starts at GA · seats 2 of 5 used · no card on file</span>
|
||
</div>
|
||
<button onClick="{{ manageSeats }}" style="border:1px solid var(--line2);color:var(--t1);padding:7px 12px;border-radius:8px;font-size:12px;font-weight:500;" style-hover="border-color:var(--t3)">Manage seats</button>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ dangerStub }}" style="align-self:flex-start;border:1px solid var(--line);color:var(--err);padding:8px 13px;border-radius:8px;font-size:12px;font-weight:500;" style-hover="border-color:var(--err)">Delete project…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isProfile }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Profile" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||
<div style="height:52px;flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:0 16px;border-bottom:1px solid var(--line);">
|
||
<button onClick="{{ goBack }}" style="font-size:12.5px;color:var(--t3);padding:6px 8px;border-radius:7px;" style-hover="color:var(--t1);background:var(--hoverbg)">← Back</button>
|
||
<span style="font-size:14px;font-weight:600;">Profile</span>
|
||
<span style="flex:1"></span>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;">
|
||
<div style="max-width:640px;margin:0 auto;padding:clamp(18px,4vw,32px) clamp(14px,4vw,32px) 90px;display:flex;flex-direction:column;gap:26px;">
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
||
<span style="width:52px;height:52px;border-radius:99px;background:var(--iris-dim);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:600;color:var(--iris-t);">M</span>
|
||
<div style="display:flex;flex-direction:column;gap:3px;flex:1;min-width:180px;">
|
||
<span style="font-size:16px;font-weight:600;">Maya Kade</span>
|
||
<span style="font-size:12.5px;color:var(--t2);">maya@solstice.team · Product designer</span>
|
||
</div>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--iris-t);background:var(--iris-dim);padding:3px 9px;border-radius:99px;">DESIGN PARTNER</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">PREFERENCES</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;display:flex;align-items:center;gap:12px;">
|
||
<span style="font-size:12.5px;flex:1;">App theme</span>
|
||
<div style="display:flex;gap:2px;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:2px;width:190px;">
|
||
<sc-for list="{{ profThemeSegs }}" as="item" hint-placeholder-count="2">
|
||
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">WORKSPACE — SOLSTICE</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;display:flex;flex-direction:column;">
|
||
<sc-for list="{{ memberRows }}" as="item" hint-placeholder-count="2">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:9px 10px;">
|
||
<span style="{{ item.avStyle }}">{{ item.initials }}</span>
|
||
<div style="display:flex;flex-direction:column;gap:1px;flex:1;">
|
||
<span style="font-size:12.5px;font-weight:500;">{{ item.name }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.role }}</span>
|
||
</div>
|
||
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
|
||
</div>
|
||
</sc-for>
|
||
<button onClick="{{ inviteStub }}" style="margin:4px 6px 6px;align-self:flex-start;border:1px solid var(--line2);color:var(--t1);padding:6px 11px;border-radius:7px;font-size:11.5px;font-weight:500;" style-hover="border-color:var(--t3)">+ Invite</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);letter-spacing:.08em;">KEYBOARD</span>
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t2);line-height:1.7;">
|
||
<span>⌘K — commands · ✦ — compose · ⌘Z — undo</span>
|
||
<span>⌘0 — zoom to fit · ⌘1 — 100% · scroll — pan · ⌘scroll — zoom</span>
|
||
<span>⌫ — delete selection · esc — dismiss anything</span>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ signOut }}" style="align-self:flex-start;border:1px solid var(--line);color:var(--t2);padding:8px 13px;border-radius:8px;font-size:12px;font-weight:500;" style-hover="border-color:var(--t3);color:var(--t1)">Sign out</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isSysupdate }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="System update" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||
<div style="height:52px;flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:0 16px;border-bottom:1px solid var(--line);">
|
||
<button onClick="{{ goBack }}" style="font-size:12.5px;color:var(--t3);padding:6px 8px;border-radius:7px;" style-hover="color:var(--t1);background:var(--hoverbg)">← Back</button>
|
||
<span style="font-size:14px;font-weight:600;">System update</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">re-import tokens · diff · apply with undo</span>
|
||
<span style="flex:1"></span>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;">
|
||
<div style="max-width:820px;margin:0 auto;padding:clamp(18px,4vw,32px) clamp(14px,4vw,32px) 90px;display:flex;flex-direction:column;gap:16px;">
|
||
<sc-if value="{{ sysPaste }}" hint-placeholder-val="{{ true }}">
|
||
<p style="font-size:13px;color:var(--t2);line-height:1.6;margin:0;">Devon updated the tokens. Paste the new file — Weave diffs it against the live system and shows the impact before anything changes.</p>
|
||
<textarea value="{{ sysText }}" onChange="{{ onSysText }}" spellCheck="{{ false }}" style="min-height:220px;background:var(--chip);border:1px solid var(--line);border-radius:12px;padding:14px 16px;font-family:'Fragment Mono',monospace;font-size:12px;line-height:1.75;color:var(--t1);resize:none;outline:none;"></textarea>
|
||
<button onClick="{{ sysCompare }}" style="{{ signinBtnStyle }}">Compare with the live system</button>
|
||
</sc-if>
|
||
<sc-if value="{{ sysDiff }}" hint-placeholder-val="{{ false }}">
|
||
<div style="background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;display:flex;flex-direction:column;">
|
||
<sc-for list="{{ diffRows }}" as="item" hint-placeholder-count="4">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:9px 10px;">
|
||
<span style="{{ item.tagStyle }}">{{ item.tag }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:11.5px;width:110px;flex:0 0 auto;">{{ item.tok }}</span>
|
||
<span style="{{ item.fromSw }}"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);">{{ item.from }}</span>
|
||
<span style="color:var(--t3);font-size:10px;">→</span>
|
||
<span style="{{ item.toSw }}"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t1);">{{ item.to }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:10px;background:var(--iris-dim);border:1px solid var(--line2);border-radius:9px;padding:9px 12px;">
|
||
<span style="color:var(--iris-t);font-size:11px;font-weight:700;">i</span>
|
||
<span style="font-size:12px;color:var(--t1);">{{ sysImpact }}</span>
|
||
</div>
|
||
<div style="display:flex;gap:10px;">
|
||
<button onClick="{{ sysApply }}" style="{{ signinBtnStyle }}">Apply with undo</button>
|
||
<button onClick="{{ sysCancel }}" style="color:var(--t3);font-size:12.5px;padding:9px 6px;" style-hover="color:var(--t1)">Cancel</button>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ sysApplied }}" hint-placeholder-val="{{ false }}">
|
||
<div style="display:flex;flex-direction:column;align-items:center;text-align:center;gap:10px;padding-top:44px;animation:popIn .3s both;">
|
||
<span style="font-size:22px;color:var(--iris-t);">✓</span>
|
||
<span style="font-size:15px;font-weight:600;">System updated.</span>
|
||
<span style="font-size:12.5px;color:var(--t2);line-height:1.6;max-width:420px;">radius-md 12 → 10 rippled across every bound component on the canvas and in the code. Bindings preserved — nothing broke silently.</span>
|
||
<div style="display:flex;gap:12px;align-items:center;margin-top:8px;">
|
||
<button onClick="{{ sysBackEditor }}" style="{{ signinBtnStyle }}">Back to the editor →</button>
|
||
<button onClick="{{ sysUndo }}" style="color:var(--iris-t);font-size:12.5px;font-weight:500;" style-hover="color:var(--t1)">Undo</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ previewOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Preview" style="position:fixed;inset:0;z-index:60;background:#F6F4F1;overflow:auto;animation:fadeIn .2s;">
|
||
<div style="min-height:100%;display:flex;flex-direction:column;align-items:center;">{{ previewEl }}</div>
|
||
<div style="position:fixed;top:14px;right:14px;display:flex;gap:8px;align-items:center;z-index:61;">
|
||
<sc-if value="{{ showPreviewNote }}" hint-placeholder-val="{{ true }}">
|
||
<span style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:8px;padding:7px 11px;font-family:'Fragment Mono',monospace;font-size:10.5px;color:#55504A;box-shadow:0 4px 16px rgba(26,23,20,.08);">preview — real components, real states</span>
|
||
</sc-if>
|
||
<button onClick="{{ closePreview }}" style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:8px;padding:7px 11px;font-size:11.5px;font-weight:500;color:#1A1714;box-shadow:0 4px 16px rgba(26,23,20,.08);" style-hover="border-color:#DDD7CE">Esc ✕</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ exportOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Export" onClick="{{ closeExport }}" style="position:fixed;inset:0;z-index:70;background:rgba(12,11,10,.62);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fadeIn .15s;">
|
||
<div onClick="{{ eatClick }}" style="width:780px;max-width:100%;max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:16px;box-shadow:0 32px 80px rgba(0,0,0,.5);animation:popIn .2s both;display:flex;flex-direction:column;">
|
||
<div style="padding:20px 22px 16px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:7px;">
|
||
<div style="display:flex;align-items:center;gap:10px;">
|
||
<span style="font-size:16px;font-weight:600;letter-spacing:-.2px;">{{ exportTitle }}</span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--iris-t);background:var(--iris-dim);padding:3px 9px;border-radius:99px;letter-spacing:.05em;">PRODUCTION-GRADE</span>
|
||
<span style="flex:1"></span>
|
||
<button onClick="{{ closeExport }}" style="color:var(--t3);font-size:13px;padding:4px 6px;border-radius:6px;" style-hover="color:var(--t1);background:var(--hoverbg)">✕</button>
|
||
</div>
|
||
<span style="font-size:12.5px;color:var(--t2);">Real components, real tokens — nothing inlined, nothing to translate.</span>
|
||
</div>
|
||
<sc-if value="{{ exportHasUnmapped }}" hint-placeholder-val="{{ false }}">
|
||
<div style="margin:14px 22px 0;display:flex;align-items:center;gap:9px;background:rgba(224,160,58,.08);border:1px solid rgba(224,160,58,.35);border-radius:9px;padding:9px 12px;">
|
||
<span style="color:var(--warn);font-size:12px;font-weight:700;">!</span>
|
||
<span style="font-size:12px;color:var(--t2);flex:1;line-height:1.5;">Badge isn't mapped to your library — export falls back to the Weave default for it.</span>
|
||
<button onClick="{{ mapFromExport }}" style="color:var(--iris-t);font-size:12px;font-weight:600;flex:0 0 auto;" style-hover="color:var(--t1)">Map now</button>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;flex-wrap:wrap;gap:18px;padding:16px 22px 22px;">
|
||
<div style="flex:1 1 260px;min-width:240px;background:var(--chip);border:1px solid var(--line);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:3px;">
|
||
<sc-for list="{{ exportTreeRows }}" as="item" hint-placeholder-count="9">
|
||
<div style="{{ item.style }}">
|
||
<span>{{ item.name }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="color:var(--t3);font-size:9.5px;">{{ item.note }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="flex:1 1 280px;display:flex;flex-direction:column;gap:12px;min-width:0;">
|
||
<span style="font-size:13.5px;font-weight:600;">Ready to ship.</span>
|
||
<div style="background:var(--chip);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t1);">npm i && npm run dev</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);line-height:1.6;">
|
||
<span>· {{ exportImportsLine }}</span>
|
||
<span>· tokens as CSS variables — tokens.css</span>
|
||
<span>· deterministic — same tree, same code</span>
|
||
</div>
|
||
<span style="flex:1"></span>
|
||
<div style="display:flex;gap:8px;">
|
||
<button onClick="{{ copyPage }}" style="border:1px solid var(--line2);color:var(--t1);padding:9px 14px;border-radius:8px;font-size:12.5px;font-weight:500;" style-hover="border-color:var(--t3)">Copy {{ exportFileName }}</button>
|
||
<button onClick="{{ downloadZip }}" style="background:var(--iris);color:#fff;padding:9px 14px;border-radius:8px;font-size:12.5px;font-weight:600;" style-hover="filter:brightness(1.12)">Download project .zip</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ cmdkOpen }}" hint-placeholder-val="{{ false }}">
|
||
<div data-screen-label="Command palette" onClick="{{ closeCmdk }}" style="position:fixed;inset:0;z-index:80;background:rgba(12,11,10,.4);display:flex;align-items:flex-start;justify-content:center;padding-top:12vh;animation:fadeIn .12s;">
|
||
<div onClick="{{ eatClick }}" style="width:560px;max-width:92%;background:var(--raised);border:1px solid var(--line2);border-radius:13px;box-shadow:0 32px 80px rgba(0,0,0,.5);overflow:hidden;animation:popIn .15s both;">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:12px 15px;border-bottom:1px solid var(--line);">
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t3);">⌘</span>
|
||
<input autoFocus="{{ true }}" value="{{ cmdQuery }}" onChange="{{ onCmdQuery }}" onKeyDown="{{ onCmdKey }}" placeholder="Search commands — or type ✦ to compose…" style="flex:1;background:none;border:none;outline:none;font-size:14px;color:var(--t1);min-width:0;">
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;padding:6px;max-height:320px;overflow:auto;">
|
||
<sc-for list="{{ cmdRows }}" as="item" hint-placeholder-count="6">
|
||
<button onClick="{{ item.onRun }}" style="{{ item.style }}">
|
||
<span>{{ item.label }}</span>
|
||
<span style="flex:1"></span>
|
||
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.hint }}</span>
|
||
</button>
|
||
</sc-for>
|
||
</div>
|
||
<div style="padding:8px 15px;border-top:1px solid var(--line);font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">⏎ run · ↑↓ navigate · esc close · ✦ routes to compose</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<div style="position:fixed;right:18px;bottom:18px;z-index:90;display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
||
<sc-for list="{{ toastRows }}" as="item" hint-placeholder-count="1">
|
||
<div style="display:flex;align-items:center;gap:9px;background:var(--raised);border:1px solid var(--line2);border-radius:10px;padding:10px 12px;box-shadow:0 16px 40px rgba(0,0,0,.4);animation:popIn .2s both;max-width:380px;">
|
||
<span style="{{ item.glyphStyle }}">{{ item.glyph }}</span>
|
||
<span style="font-size:12.5px;color:var(--t1);line-height:1.45;">{{ item.text }}</span>
|
||
<sc-if value="{{ item.hasAct }}" hint-placeholder-val="{{ false }}">
|
||
<button onClick="{{ item.onAct }}" style="color:var(--iris-t);font-size:12px;font-weight:600;flex:0 0 auto;" style-hover="color:var(--t1)">{{ item.actLabel }}</button>
|
||
</sc-if>
|
||
<button onClick="{{ item.onClose }}" style="color:var(--t3);font-size:10.5px;flex:0 0 auto;padding:2px;" style-hover="color:var(--t1)">✕</button>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
|
||
</div>
|
||
</x-dc>
|
||
<script type="text/x-dc" data-dc-script data-props="{"aiLive":{"editor":"boolean","default":true,"tsType":"boolean","section":"Demo"},"defaultTheme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"\"dark\" | \"light\"","section":"Demo"},"startAt":{"editor":"enum","options":["signin","home","onboarding","editor"],"default":"signin","tsType":"\"signin\" | \"home\" | \"onboarding\" | \"editor\"","section":"Demo"}}">
|
||
class Component extends DCLogic {
|
||
state = {
|
||
screen: (this.props && this.props.startAt) || 'home',
|
||
projMenu: false, homeQuery: '', signinEmail: '',
|
||
craft: 'impeccable', sysStage: 'paste',
|
||
sysText: ':root {\n --brand-500: #E4572E;\n --brand-600: #B23A18; /* was #C7431F */\n --neutral-600: #57534E;\n --radius-md: 10px; /* was 12px */\n --space-7: 28px; /* new */\n --font-display: "Bricolage Grotesque";\n}',
|
||
vw: typeof window !== 'undefined' ? window.innerWidth : 1440,
|
||
theme: (this.props && this.props.defaultTheme) || 'dark',
|
||
ob: 'welcome',
|
||
pasteText: ':root {\n --brand-500: #E4572E;\n --brand-600: #C7431F;\n --neutral-50: #FAFAF9;\n --neutral-600: #57534E;\n --neutral-900: #1C1917;\n --radius-md: 12px;\n --font-display: "Bricolage Grotesque";\n --space-base: 4px;\n}',
|
||
parseIdx: -1, parseRun: false, parseDone: false,
|
||
revealLive: false, revealDone: false,
|
||
quick: { brand: '#E4572E', neutral: 'warm', radius: 12, font: 'Bricolage Grotesque', space: 4 },
|
||
system: null,
|
||
tree: null, selId: null, hovId: null,
|
||
leftTab: 'library', rightTab: 'inspector',
|
||
leftOpen: false, rightOpen: false,
|
||
leftCollapsed: true, rightCollapsed: true, expandedL: {},
|
||
leftW: 232, rightW: 262, codeW: 400,
|
||
collapsed: {},
|
||
cam: { x: 60, y: 48, z: 0.5 }, panning: false,
|
||
activeFrame: 'frame',
|
||
editMode: null, aiTarget: null, bubble: null, bubbleText: '',
|
||
strokes: [], overlaysHidden: false, quickN: 0,
|
||
codeEditing: false, codeDraft: '', isolate: null, customComps: [],
|
||
chat: [], chatCollapsed: false, aiModel: 'claude-haiku-4-5', pendingAtts: [],
|
||
chatOpen: typeof window !== 'undefined' ? window.innerWidth >= 1180 : true,
|
||
chatW: 300,
|
||
codeOpen: false, codeFile: 'home',
|
||
drop: null, bornIds: {}, pulseStamp: 0, flashIds: [], flashStamp: 0,
|
||
saveState: 'saved', syncing: false,
|
||
zoom: 'fit', fit: 0.62, deskLight: false,
|
||
brandPop: false, hexDraft: '#E4572E',
|
||
patterns: [], badgeMapped: false,
|
||
aiText: '', aiStatus: 'idle', proposal: null, refusal: null,
|
||
polishStatus: 'idle', issues: [],
|
||
exportOpen: false, previewOpen: false, cmdk: false, cmdQuery: '', cmdSel: 0,
|
||
toasts: [], history: [], future: [], histCursor: null
|
||
};
|
||
|
||
MONO = "'Fragment Mono',monospace";
|
||
BRANDS = ['#E4572E', '#2A6FDB', '#1F8A5B', '#111827'];
|
||
FONTS = ['Bricolage Grotesque', 'Space Grotesk', 'Lora'];
|
||
WARM = { 50:'#FAFAF9',100:'#F5F5F4',200:'#E7E5E4',300:'#D6D3D1',400:'#A8A29E',500:'#78716C',600:'#57534E',700:'#44403C',800:'#292524',900:'#1C1917' };
|
||
COOL = { 50:'#F8FAFC',100:'#F1F5F9',200:'#E2E8F0',300:'#CBD5E1',400:'#94A3B8',500:'#64748B',600:'#475569',700:'#334155',800:'#1E293B',900:'#0F172A' };
|
||
|
||
T = []; toastN = 0; nodeN = 0; patN = 0;
|
||
|
||
after(ms, fn) { const id = setTimeout(fn, ms); this.T.push(id); return id; }
|
||
|
||
componentDidMount() {
|
||
document.body.dataset.th = this.state.theme;
|
||
this.onKey = (e) => this.handleKey(e);
|
||
window.addEventListener('keydown', this.onKey);
|
||
this.onWinResize = () => this.setState({ vw: window.innerWidth });
|
||
window.addEventListener('resize', this.onWinResize);
|
||
this.deskRef = this.deskRef || React.createRef();
|
||
this.aiRef = this.aiRef || React.createRef();
|
||
this.ro = new ResizeObserver(() => this.recalcFit());
|
||
this.watchDesk();
|
||
if (this.state.screen === 'editor') this.enterEditor(false);
|
||
}
|
||
componentWillUnmount() {
|
||
this.T.forEach(clearTimeout);
|
||
window.removeEventListener('keydown', this.onKey);
|
||
if (this.onWinResize) window.removeEventListener('resize', this.onWinResize);
|
||
if (this.ro) this.ro.disconnect();
|
||
}
|
||
componentDidUpdate() {
|
||
this.watchDesk();
|
||
const t = this.state.tree;
|
||
if (t && t.type !== 'board' && !this.migrating) {
|
||
this.migrating = true;
|
||
const f = this.clone(t); f.props = Object.assign({ x: 0, y: 0, w: 1280 }, f.props || {});
|
||
this.setState({ tree: { id: 'board', type: 'board', name: 'Board', props: {}, children: [f] }, activeFrame: f.id }, () => { this.migrating = false; });
|
||
}
|
||
}
|
||
watchDesk() {
|
||
const el = this.deskRef && this.deskRef.current;
|
||
if (el && el !== this.roEl) {
|
||
if (this.roEl) { this.ro.unobserve(this.roEl); if (this.onWheelBound) this.roEl.removeEventListener('wheel', this.onWheelBound); }
|
||
this.ro.observe(el); this.roEl = el;
|
||
this.onWheelBound = (ev) => this.handleWheel(ev);
|
||
el.addEventListener('wheel', this.onWheelBound, { passive: false });
|
||
this.didFit = false;
|
||
this.after(90, () => { if (!this.didFit) this.fitView(); });
|
||
}
|
||
}
|
||
recalcFit() {}
|
||
clampZ(z) { return Math.max(0.08, Math.min(2.5, z)); }
|
||
setCam(cam) { this.setState({ cam }); }
|
||
setZoom(z, cx, cy) {
|
||
const el = this.roEl; const cam = this.state.cam;
|
||
z = this.clampZ(z);
|
||
let px = cx, py = cy;
|
||
if (px === undefined) { const r = el ? el.getBoundingClientRect() : { width: 900, height: 600 }; px = r.width / 2; py = r.height / 2; }
|
||
const wx = (px - cam.x) / cam.z, wy = (py - cam.y) / cam.z;
|
||
this.setCam({ x: px - wx * z, y: py - wy * z, z });
|
||
}
|
||
zoomStep(f) { this.setZoom(this.state.cam.z * f); }
|
||
handleWheel(e) {
|
||
if (this.state.screen !== 'editor') return;
|
||
e.preventDefault();
|
||
const el = this.roEl; if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const cam = this.state.cam;
|
||
if (this.state.bubble) this.setState({ bubble: null });
|
||
if (e.ctrlKey || e.metaKey) {
|
||
const f = Math.exp(-e.deltaY * 0.0022);
|
||
this.setZoom(cam.z * f, e.clientX - r.left, e.clientY - r.top);
|
||
} else {
|
||
this.setCam({ x: cam.x - e.deltaX, y: cam.y - e.deltaY, z: cam.z });
|
||
}
|
||
}
|
||
onDeskPanDown(e) {
|
||
// two-finger pinch on touch devices → zoom at midpoint
|
||
if (e.pointerType === 'touch') {
|
||
this.pts = this.pts || new Map();
|
||
this.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (this.pts.size === 2) {
|
||
e.preventDefault();
|
||
const rect = this.roEl ? this.roEl.getBoundingClientRect() : { left: 0, top: 0 };
|
||
const two = () => Array.from(this.pts.values());
|
||
let [a, b] = two();
|
||
let d0 = Math.hypot(a.x - b.x, a.y - b.y);
|
||
let z0 = this.state.cam.z;
|
||
const move = (ev) => {
|
||
if (!this.pts.has(ev.pointerId)) return;
|
||
this.pts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||
if (this.pts.size < 2) return;
|
||
[a, b] = two();
|
||
const d1 = Math.hypot(a.x - b.x, a.y - b.y);
|
||
const mid = { x: (a.x + b.x) / 2 - rect.left, y: (a.y + b.y) / 2 - rect.top };
|
||
this.setZoom(z0 * (d1 / Math.max(1, d0)), mid.x, mid.y);
|
||
};
|
||
const up = (ev) => {
|
||
this.pts.delete(ev.pointerId);
|
||
if (this.pts.size < 2) { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up); }
|
||
};
|
||
window.addEventListener('pointermove', move);
|
||
window.addEventListener('pointerup', up);
|
||
window.addEventListener('pointercancel', up);
|
||
return;
|
||
}
|
||
const clear = (ev) => { if (this.pts) this.pts.delete(ev.pointerId); };
|
||
window.addEventListener('pointerup', clear, { once: true });
|
||
window.addEventListener('pointercancel', clear, { once: true });
|
||
}
|
||
const bg = e.target === e.currentTarget || (e.target.dataset && e.target.dataset.world === '1');
|
||
if (!(bg || e.button === 1)) return;
|
||
e.preventDefault();
|
||
if (this.state.bubble) this.setState({ bubble: null });
|
||
const start = { x: e.clientX, y: e.clientY };
|
||
const cam0 = Object.assign({}, this.state.cam);
|
||
let moved = false;
|
||
const move = (ev) => {
|
||
const dx = ev.clientX - start.x, dy = ev.clientY - start.y;
|
||
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
|
||
if (moved) this.setCam({ x: cam0.x + dx, y: cam0.y + dy, z: cam0.z });
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
|
||
this.setState({ panning: false });
|
||
if (moved) { this.suppressDeskClick = true; this.after(120, () => { this.suppressDeskClick = false; }); }
|
||
else if (bg && e.button === 0) this.setState({ selId: null, hovId: null, brandPop: false });
|
||
};
|
||
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
||
this.setState({ panning: true });
|
||
}
|
||
fitView() {
|
||
const el = this.roEl; if (!el || !this.state.tree) return;
|
||
this.didFit = true;
|
||
const frames = this.frames();
|
||
if (!frames.length) return;
|
||
const world = this.worldRef && this.worldRef.current;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
frames.forEach(f => {
|
||
const x = f.props.x || 0, y = f.props.y || 0, w = this.clusterW(f);
|
||
let hgt = 780;
|
||
if (world) { const fe = world.querySelector('[data-fid="' + f.id + '"]'); if (fe && fe.offsetHeight) hgt = fe.offsetHeight; }
|
||
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||
maxX = Math.max(maxX, x + w); maxY = Math.max(maxY, y + hgt);
|
||
});
|
||
const vw2 = this.state.vw || 1440;
|
||
const drawer = vw2 < 1340 && this.state.codeOpen && this.state.screen === 'editor' ? Math.min(this.state.codeW, vw2 * 0.78) : 0;
|
||
const cOpen = this.state.chatOpen === undefined ? vw2 >= 1180 : this.state.chatOpen;
|
||
const chatPad = cOpen && vw2 < 1000 ? Math.min(this.state.chatW || 300, Math.round(vw2 * 0.5)) : 0;
|
||
const W = Math.max(320, el.clientWidth - drawer - chatPad), H = Math.max(240, el.clientHeight);
|
||
const bw = Math.max(200, maxX - minX), bh = Math.max(200, maxY - minY);
|
||
const z = this.clampZ(Math.min((W - 90) / bw, (H - 190) / bh, 1));
|
||
this.setCam({ x: chatPad + (W - bw * z) / 2 - minX * z, y: Math.max(48, (H - 150 - bh * z) / 2 + 34 * z) - minY * z, z });
|
||
}
|
||
setBreakpoint(k) {
|
||
const af = this.activeFrameNode(); if (!af) return;
|
||
if (k === 'all') return this.setFrameViews(af.id, ['desktop', 'tablet', 'mobile'], this.activeView(af));
|
||
this.setFrameViews(af.id, [k], k);
|
||
}
|
||
addFrame() {
|
||
const frames = this.frames();
|
||
let maxX = 0; frames.forEach(f => { maxX = Math.max(maxX, (f.props.x || 0) + this.clusterW(f)); });
|
||
const id = this.nid('frame');
|
||
const node = { id, type: 'frame', name: 'Screen ' + (frames.length + 1), props: { dir: 'col', gap: 0, pad: 0, x: maxX + 140, y: 0, w: 1280 }, children: [] };
|
||
this.mutate(t => { if (t.type === 'board') t.children.push(node); }, { select: id });
|
||
this.setState({ activeFrame: id });
|
||
this.after(90, () => this.fitView());
|
||
}
|
||
startFrameMove(id) {
|
||
return (e) => {
|
||
e.stopPropagation(); e.preventDefault();
|
||
const f0 = this.findNode(this.state.tree, id); if (!f0) return;
|
||
this.pushHistory('Move frame ' + (f0.name || ''));
|
||
const start = { x: e.clientX, y: e.clientY, fx: f0.props.x || 0, fy: f0.props.y || 0 };
|
||
const z = this.state.cam.z;
|
||
const move = (ev) => {
|
||
const t = this.clone(this.state.tree);
|
||
const n = this.findNode(t, id); if (!n) return;
|
||
n.props.x = Math.round(start.fx + (ev.clientX - start.x) / z);
|
||
n.props.y = Math.round(start.fy + (ev.clientY - start.y) / z);
|
||
this.setState({ tree: t });
|
||
};
|
||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
||
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
||
const patch = { selId: id, activeFrame: id, rightTab: 'inspector', rightCollapsed: false };
|
||
if ((this.state.vw || 1440) < 1000) patch.rightOpen = true;
|
||
this.setState(patch);
|
||
};
|
||
}
|
||
strokeStart(e) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (!this.roEl) return;
|
||
const rect = this.roEl.getBoundingClientRect();
|
||
const cam = this.state.cam;
|
||
const pt = (ev) => ({ x: (ev.clientX - rect.left - cam.x) / cam.z, y: (ev.clientY - rect.top - cam.y) / cam.z });
|
||
const stroke = { pts: [pt(e)] };
|
||
this.setState(s => ({ strokes: s.strokes.concat(stroke) }));
|
||
const move = (ev) => {
|
||
const p = pt(ev);
|
||
const last = stroke.pts[stroke.pts.length - 1];
|
||
if (Math.hypot(p.x - last.x, p.y - last.y) < 3 / cam.z) return;
|
||
stroke.pts.push(p);
|
||
this.setState(s => ({ strokes: s.strokes.slice(0, -1).concat({ pts: stroke.pts.slice() }) }));
|
||
};
|
||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
||
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
||
}
|
||
pickTarget(n, e) {
|
||
const rect = this.roEl ? this.roEl.getBoundingClientRect() : { left: 0, top: 0, width: 800 };
|
||
const cOpen = this.state.chatOpen === undefined ? (this.state.vw || 1440) >= 1180 : this.state.chatOpen;
|
||
const xMin = cOpen && (this.state.vw || 1440) < 1000 ? Math.min(this.state.chatW || 300, rect.width * 0.5) + 10 : 10;
|
||
const x = Math.max(xMin, Math.min(e.clientX - rect.left + 10, rect.width - 330));
|
||
const y = Math.max(10, e.clientY - rect.top + 14);
|
||
this.setState({ aiTarget: n.id, editMode: null, bubble: { x, y }, bubbleText: '' });
|
||
this.selectNode(n.id);
|
||
}
|
||
aiTagFor(n) {
|
||
if (!n) return '';
|
||
if (n.type === 'heading') return { xl: 'h1', lg: 'h2', md: 'h3', sm: 'h4' }[n.props.size || 'md'];
|
||
if (n.type === 'text') return 'p';
|
||
if (n.type === 'stack') return 'div';
|
||
if (n.type === 'frame') return 'main';
|
||
return n.type.charAt(0).toUpperCase() + n.type.slice(1);
|
||
}
|
||
pushChat(role, text, extra) {
|
||
extra = extra || {};
|
||
this.chatN = (this.chatN || 0) + 1;
|
||
const id = this.chatN;
|
||
if (extra.kind === 'proposal') this.lastProposalMsgId = id;
|
||
if (extra.kind === 'refusal') this.lastRefusalMsgId = id;
|
||
this.setState(s => ({ chat: s.chat.concat({ id, role, text, kind: extra.kind, atts: extra.atts || [] }).slice(-60), chatCollapsed: false }));
|
||
this.after(70, () => { const el = this.chatRef && this.chatRef.current; if (el) el.scrollTop = el.scrollHeight; });
|
||
}
|
||
countNodes(nodes) { let c = 0; (nodes || []).forEach(n => this.walk(n, () => c++)); return c; }
|
||
onFilesPicked(e) {
|
||
const files = Array.from(e.target.files || []).slice(0, 4);
|
||
const atts = files.map(f => ({ name: f.name.length > 22 ? f.name.slice(0, 19) + '…' : f.name, url: URL.createObjectURL(f) }));
|
||
this.setState(s => ({ pendingAtts: s.pendingAtts.concat(atts).slice(0, 4) }));
|
||
e.target.value = '';
|
||
}
|
||
submitBubble() {
|
||
const txt = this.state.bubbleText.trim(); if (!txt) return;
|
||
const node = this.state.aiTarget ? this.findNode(this.state.tree, this.state.aiTarget) : null;
|
||
if (!node) { this.setState({ bubble: null }); return; }
|
||
this.setState({ bubbleText: '' });
|
||
this.pushChat('user', txt + ' · scoped to ' + this.aiTagFor(node));
|
||
this.submitTargetedAi(txt, node);
|
||
}
|
||
icon(name, size) {
|
||
const h = React.createElement;
|
||
const P = (d, extra) => h('path', Object.assign({ d, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }, extra || {}));
|
||
const paths = {
|
||
chat: [P('M8 1.6l1.5 4.1L13.6 7l-4.1 1.4L8 12.5 6.5 8.4 2.4 7l4.1-1.3L8 1.6z'), P('M13 11.4l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6.6-1.6z', { strokeWidth: 1.2 })],
|
||
layers: [P('M8 2.2L14 5.4 8 8.6 2 5.4 8 2.2z'), P('M2.4 8.4L8 11.4l5.6-3'), P('M2.4 11.2L8 14.2l5.6-3')],
|
||
library: [P('M2.5 2.5h4.4v4.4H2.5z'), P('M9.1 2.5h4.4v4.4H9.1z'), P('M2.5 9.1h4.4v4.4H2.5z'), h('circle', { cx: 11.3, cy: 11.3, r: 2.3, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 })],
|
||
tokens: [h('circle', { cx: 6.2, cy: 6.2, r: 3.7, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }), h('circle', { cx: 10.2, cy: 10.2, r: 3.7, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 })],
|
||
history: [P('M2.6 8a5.4 5.4 0 1 1 1.6 3.8'), P('M2.4 8.6L2.6 11l2.3-.5'), P('M8 5.4V8l2 1.3')],
|
||
inspect: [P('M2.5 4.4h7'), P('M12.3 4.4h1.2'), h('circle', { cx: 10.9, cy: 4.4, r: 1.5, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }), P('M2.5 11.6h1.2'), P('M6.3 11.6h7.2'), h('circle', { cx: 5, cy: 11.6, r: 1.5, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }), P('M2.5 8h11', { opacity: 0 })],
|
||
polish: [P('M8 2.2l1.3 3.4L12.7 7 9.3 8.3 8 11.7 6.7 8.3 3.3 7l3.4-1.4L8 2.2z'), P('M12.6 11l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3-1.3-.5 1.3-.5.5-1.3z', { strokeWidth: 1.1 })],
|
||
code: [P('M6 4.6L2.8 8 6 11.4'), P('M10 4.6L13.2 8 10 11.4')]
|
||
};
|
||
return h('svg', { width: size || 15, height: size || 15, viewBox: '0 0 16 16', style: { display: 'block' } }, paths[name] || []);
|
||
}
|
||
modeBtn(active) {
|
||
return { display: 'flex', alignItems: 'center', gap: '5px', padding: '5px 11px', borderRadius: '999px', fontSize: '11.5px', fontWeight: 500, whiteSpace: 'nowrap', color: active ? '#fff' : 'var(--t2)', background: active ? 'var(--iris)' : 'transparent' };
|
||
}
|
||
|
||
// ---------- color math ----------
|
||
hexRgb(h) { h = h.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; }
|
||
rgbHex(r, g, b) { return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase(); }
|
||
mix(hex, other, t) { const a = this.hexRgb(hex), b = this.hexRgb(other); return this.rgbHex(a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t); }
|
||
ramp(hex) {
|
||
return { 50: this.mix(hex,'#FFFFFF',.94), 100: this.mix(hex,'#FFFFFF',.86), 200: this.mix(hex,'#FFFFFF',.68), 300: this.mix(hex,'#FFFFFF',.45), 400: this.mix(hex,'#FFFFFF',.22), 500: hex, 600: this.mix(hex,'#000000',.16), 700: this.mix(hex,'#000000',.34), 800: this.mix(hex,'#000000',.52), 900: this.mix(hex,'#000000',.68) };
|
||
}
|
||
lum(hex) { const [r,g,b] = this.hexRgb(hex).map(v => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }); return 0.2126*r + 0.7152*g + 0.0722*b; }
|
||
ratio(a, b) { const l1 = this.lum(a), l2 = this.lum(b); const hi = Math.max(l1,l2), lo = Math.min(l1,l2); return (hi + 0.05) / (lo + 0.05); }
|
||
|
||
getSystem() { return this.state.system || this.buildSystem(this.state.quick); }
|
||
buildSystem(q) {
|
||
return { name: 'Solstice', brand: q.brand, ramp: this.ramp(q.brand), n: q.neutral === 'cool' ? this.COOL : this.WARM, neutral: q.neutral, radius: q.radius, font: q.font, space: q.space };
|
||
}
|
||
sysVars(sys) {
|
||
return { '--ka': sys.ramp[500], '--kas': sys.ramp[600], '--kai': '#FFFFFF', '--kd': sys.ramp[100], '--ki': sys.n[900], '--km': sys.n[600], '--kl': sys.n[200], '--kr': sys.radius + 'px', '--krb': Math.max(4, Math.round(sys.radius * 0.66)) + 'px', '--kf': "'" + sys.font + "',sans-serif" };
|
||
}
|
||
grayVars() { return { '--ka': '#A6A099', '--kas': '#7E7870', '--kai': '#FFFFFF', '--kd': '#EFEDEA', '--ki': '#4A453F', '--km': '#8C847A', '--kl': '#E4E0DA', '--kr': '8px', '--krb': '6px', '--kf': "'Instrument Sans',sans-serif" }; }
|
||
|
||
// ---------- shared chrome styles ----------
|
||
primaryBtn(extra) { return Object.assign({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '7px', background: 'var(--iris)', color: '#fff', padding: '10px 18px', borderRadius: '9px', fontSize: '13px', fontWeight: 600, letterSpacing: '.01em', alignSelf: 'flex-start' }, extra || {}); }
|
||
tabBtn(label, glyph, active, onClick, tight) {
|
||
return {
|
||
label, glyph, onClick, title: label,
|
||
style: { flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px', padding: '5px 6px', borderRadius: '7px', background: active ? 'var(--raised)' : 'transparent', color: active ? 'var(--t1)' : 'var(--t3)', border: active ? '1px solid var(--line2)' : '1px solid transparent', boxShadow: active ? '0 1px 2px rgba(0,0,0,.18)' : 'none' },
|
||
glyphStyle: { fontFamily: this.MONO, fontSize: '11px', flex: '0 0 auto' },
|
||
lblStyle: { display: tight ? 'none' : 'inline', fontSize: '11.5px', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
|
||
};
|
||
}
|
||
seg(label, active, onClick, extra) {
|
||
return { label, onClick, style: Object.assign({ flex: 1, textAlign: 'center', padding: '5px 8px', borderRadius: '7px', fontSize: '11.5px', fontWeight: 500, background: active ? 'var(--raised)' : 'transparent', color: active ? 'var(--t1)' : 'var(--t3)', border: active ? '1px solid var(--line2)' : '1px solid transparent', boxShadow: active ? '0 1px 2px rgba(0,0,0,.18)' : 'none' }, extra || {}) };
|
||
}
|
||
|
||
// ---------- toasts ----------
|
||
toast(text, opts) {
|
||
opts = opts || {};
|
||
const id = ++this.toastN;
|
||
const t = { id, text, glyph: opts.glyph || '✓', act: opts.act, onAct: opts.onAct };
|
||
this.setState(s => ({ toasts: s.toasts.concat(t) }));
|
||
this.after(opts.act ? 6500 : 4200, () => this.closeToast(id));
|
||
}
|
||
closeToast(id) { this.setState(s => ({ toasts: s.toasts.filter(t => t.id !== id) })); }
|
||
|
||
// ---------- onboarding ----------
|
||
setOb(ob) { this.setState({ ob }); }
|
||
startParse() {
|
||
if (this.state.parseRun || this.state.parseDone) return;
|
||
this.setState({ parseRun: true, parseIdx: 0 });
|
||
const step = (i) => {
|
||
if (i > 4) { this.setState({ parseRun: false, parseDone: true, parseIdx: 5, system: this.buildSystem(this.state.quick) }); return; }
|
||
this.setState({ parseIdx: i });
|
||
this.after(430, () => step(i + 1));
|
||
};
|
||
step(0);
|
||
}
|
||
goReveal() {
|
||
if (!this.state.system) this.setState({ system: this.buildSystem(this.state.quick) });
|
||
this.setState({ ob: 'reveal', revealLive: false, revealDone: false });
|
||
this.after(750, () => this.setState({ revealLive: true }));
|
||
this.after(2300, () => this.setState({ revealDone: true }));
|
||
}
|
||
go(to) {
|
||
this.navBack = this.state.screen;
|
||
if (to === 'editor') return this.enterEditor(false);
|
||
this.setState({ screen: to, projMenu: false });
|
||
}
|
||
goBack() {
|
||
const b = this.navBack && this.navBack !== this.state.screen ? this.navBack : (this.state.tree ? 'editor' : 'home');
|
||
if (b === 'editor') return this.enterEditor(false);
|
||
this.setState({ screen: b, projMenu: false });
|
||
}
|
||
enterEditor(withToast) {
|
||
const patch = { screen: 'editor', projMenu: false };
|
||
if (!this.state.system) patch.system = this.buildSystem(this.state.quick);
|
||
if (!this.state.tree) patch.tree = this.starterTree();
|
||
this.setState(patch);
|
||
if (withToast) this.after(500, () => this.toast('Your system is live — everything here exports as real React.', { glyph: '✓' }));
|
||
}
|
||
|
||
// ---------- tree ----------
|
||
nid(p) { return (p || 'n') + (++this.nodeN); }
|
||
starterTree() {
|
||
const home = { id: 'frame', type: 'frame', name: 'Home', props: { dir: 'col', gap: 0, pad: 0, x: 0, y: 0, w: 1280 }, children: [
|
||
{ id: 'nav', type: 'stack', name: 'Nav', props: { dir: 'row', gap: 16, padX: 40, padY: 20, align: 'center', justify: 'between' }, children: [
|
||
{ id: 'logo', type: 'heading', name: 'Logo', props: { text: 'Solstice', size: 'sm', weight: 'bold' }, children: [] },
|
||
{ id: 'navLinks', type: 'stack', name: 'Links', props: { dir: 'row', gap: 28, align: 'center' }, children: [
|
||
{ id: 'l1', type: 'text', props: { text: 'Product', size: 'sm', tone: 'muted' }, children: [] },
|
||
{ id: 'l2', type: 'text', props: { text: 'Pricing', size: 'sm', tone: 'muted' }, children: [] },
|
||
{ id: 'l3', type: 'text', props: { text: 'Journal', size: 'sm', tone: 'muted' }, children: [] }
|
||
]},
|
||
{ id: 'navCtas', type: 'stack', name: 'Actions', props: { dir: 'row', gap: 12, align: 'center' }, children: [
|
||
{ id: 'b1', type: 'button', props: { text: 'Sign in', variant: 'ghost', size: 'sm' }, children: [] },
|
||
{ id: 'b2', type: 'button', props: { text: 'Get started', variant: 'primary', size: 'sm' }, children: [] }
|
||
]}
|
||
]},
|
||
{ id: 'hero', type: 'stack', name: 'Hero', props: { dir: 'col', gap: 18, padX: 32, padY: 76, align: 'center' }, children: [
|
||
{ id: 'heroBadge', type: 'badge', props: { text: 'New — shared team calendars' }, children: [] },
|
||
{ id: 'heroHead', type: 'heading', props: { text: 'Scheduling that feels like sunlight', size: 'xl', weight: 'regular' }, children: [] },
|
||
{ id: 'heroSub', type: 'text', props: { text: 'Solstice finds the hour that works in every timezone — automatically, and kindly.', size: 'lg', tone: 'faint' }, children: [] },
|
||
{ id: 'ctaRow', type: 'stack', name: 'CTAs', props: { dir: 'row', gap: 12, align: 'center' }, children: [
|
||
{ id: 'cta1', type: 'button', props: { text: 'Start free', variant: 'primary', size: 'md' }, children: [] },
|
||
{ id: 'cta2', type: 'button', props: { text: 'Book a demo', variant: 'primary', size: 'md' }, children: [] }
|
||
]}
|
||
]},
|
||
{ id: 'features', type: 'stack', name: 'Features', props: { dir: 'row', gap: 24, padX: 40, padY: 12, padBottom: 64 }, children: [
|
||
{ id: 'card1', type: 'card', name: 'Card — Timezones', props: { pad: 14, gap: 12 }, children: [
|
||
{ id: 'c1h', type: 'heading', props: { text: 'One calendar, every timezone', size: 'sm', weight: 'semibold' }, children: [] },
|
||
{ id: 'c1t', type: 'text', props: { text: "See the whole team's day in your own hours — no math.", size: 'sm', tone: 'muted' }, children: [] }
|
||
]},
|
||
{ id: 'card2', type: 'card', name: 'Card — Meetings', props: { pad: 16, gap: 12 }, children: [
|
||
{ id: 'c2h', type: 'heading', props: { text: 'Meetings that fit', size: 'sm', weight: 'semibold' }, children: [] },
|
||
{ id: 'c2t', type: 'text', props: { text: "Solstice proposes times inside everyone's focus hours.", size: 'sm', tone: 'muted' }, children: [] }
|
||
]},
|
||
{ id: 'card3', type: 'card', name: 'Card — Quiet', props: { pad: 16, gap: 12 }, children: [
|
||
{ id: 'c3h', type: 'heading', props: { text: 'Quiet by default', size: 'sm', weight: 'semibold' }, children: [] },
|
||
{ id: 'c3t', type: 'text', props: { text: 'Protected mornings and no-meeting days, enforced gently.', size: 'sm', tone: 'muted' }, children: [] }
|
||
]}
|
||
]}
|
||
]};
|
||
const pricing = { id: 'frame2', type: 'frame', name: 'Pricing', props: { dir: 'col', gap: 0, pad: 0, x: 1420, y: 0, w: 1280 }, children: [] };
|
||
return { id: 'board', type: 'board', name: 'Board', props: {}, children: [home, pricing] };
|
||
}
|
||
clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||
findNode(node, id) {
|
||
if (!node) return null;
|
||
if (node.id === id) return node;
|
||
for (const c of (node.children || [])) { const f = this.findNode(c, id); if (f) return f; }
|
||
return null;
|
||
}
|
||
findParent(node, id) {
|
||
for (const c of (node.children || [])) { if (c.id === id) return node; const f = this.findParent(c, id); if (f) return f; }
|
||
return null;
|
||
}
|
||
isDescendant(node, maybeChildId) { return !!this.findNode(node, maybeChildId); }
|
||
walk(node, fn, depth) { fn(node, depth || 0); (node.children || []).forEach(c => this.walk(c, fn, (depth || 0) + 1)); }
|
||
|
||
frames() {
|
||
const t = this.state.tree;
|
||
if (!t) return [];
|
||
return t.type === 'board' ? (t.children || []) : [t];
|
||
}
|
||
VIEW_ORDER = ['desktop', 'tablet', 'mobile'];
|
||
viewW(f, k) { return k === 'tablet' ? 768 : k === 'mobile' ? 390 : ((f.props && f.props.w) || 1280); }
|
||
viewsOf(f) {
|
||
const v = f.props && f.props.views;
|
||
const arr = Array.isArray(v) && v.length ? v : ['desktop'];
|
||
return this.VIEW_ORDER.filter(k => arr.indexOf(k) >= 0);
|
||
}
|
||
activeView(f) {
|
||
const views = this.viewsOf(f);
|
||
const av = f.props && f.props.view;
|
||
return views.indexOf(av) >= 0 ? av : views[0];
|
||
}
|
||
clusterW(f) {
|
||
const views = this.viewsOf(f);
|
||
return views.reduce((acc, k) => acc + this.viewW(f, k), 0) + 120 * (views.length - 1);
|
||
}
|
||
setFrameViews(frameId, views, view) {
|
||
this.mutate(t => {
|
||
const n = this.findNode(t, frameId); if (!n) return;
|
||
const before = this.clusterW(n);
|
||
n.props.views = views;
|
||
if (view) n.props.view = view;
|
||
const delta = this.clusterW(n) - before;
|
||
if (delta !== 0 && t.type === 'board') {
|
||
const x0 = n.props.x || 0;
|
||
(t.children || []).forEach(f => {
|
||
if (f.id !== frameId && (f.props.x || 0) > x0) f.props.x = (f.props.x || 0) + delta;
|
||
});
|
||
}
|
||
}, { label: views.length > 1 ? 'Variants → ' + views.join(' + ') : 'View → ' + views[0] });
|
||
this.after(90, () => this.fitView());
|
||
}
|
||
frameOf(id) {
|
||
let res = null;
|
||
const dfs = (n, cf) => {
|
||
const cur = n.type === 'frame' ? n : cf;
|
||
if (n.id === id) { res = cur; return true; }
|
||
for (const c of (n.children || [])) if (dfs(c, cur)) return true;
|
||
return false;
|
||
};
|
||
if (this.state.tree) dfs(this.state.tree, null);
|
||
return res;
|
||
}
|
||
activeFrameNode() {
|
||
const fs = this.frames();
|
||
const sel = this.state.selId ? this.frameOf(this.state.selId) : null;
|
||
if (sel) return sel;
|
||
return fs.find(f => f.id === this.state.activeFrame) || fs[0] || null;
|
||
}
|
||
selectNode(id) {
|
||
const f = this.frameOf(id);
|
||
const n = this.findNode(this.state.tree, id);
|
||
const patch = { selId: id, rightTab: 'inspector', brandPop: false, activeFrame: f ? f.id : this.state.activeFrame };
|
||
if (n && n.type === 'frame') {
|
||
patch.rightCollapsed = false;
|
||
if ((this.state.vw || 1440) < 1000) patch.rightOpen = true;
|
||
}
|
||
this.setState(patch);
|
||
}
|
||
pushHistory(label) {
|
||
const snap = { s: JSON.stringify(this.state.tree), label: label || 'Edit', t: Date.now() };
|
||
this.setState(s => {
|
||
const cur = s.histCursor;
|
||
const base = (cur === null || cur === undefined) ? s.history : s.history.slice(0, cur);
|
||
return { history: base.concat(snap).slice(-60), future: [], histCursor: null };
|
||
});
|
||
}
|
||
mutate(fn, opts) {
|
||
opts = opts || {};
|
||
this.pushHistory(opts.label);
|
||
const t = this.clone(this.state.tree);
|
||
fn(t);
|
||
const patch = { tree: t, flashIds: opts.flash || [], flashStamp: Date.now(), syncing: true, saveState: 'saving' };
|
||
if (opts.select !== undefined) patch.selId = opts.select;
|
||
if (opts.pulse) patch.pulseStamp = Date.now();
|
||
this.setState(patch);
|
||
this.after(450, () => this.setState({ syncing: false }));
|
||
this.after(900, () => this.setState({ saveState: 'saved' }));
|
||
}
|
||
undo() {
|
||
const h = this.state.history; if (!h.length) return;
|
||
const cur = { s: JSON.stringify(this.state.tree), label: 'Now', t: Date.now() };
|
||
const last = h[h.length - 1];
|
||
this.setState(s => ({ tree: JSON.parse(last.s), history: h.slice(0, -1), future: s.future.concat(cur), syncing: true, histCursor: null }));
|
||
this.after(400, () => this.setState({ syncing: false }));
|
||
}
|
||
redo() {
|
||
const f = this.state.future; if (!f.length) return;
|
||
const cur = { s: JSON.stringify(this.state.tree), label: 'Now', t: Date.now() };
|
||
const next = f[f.length - 1];
|
||
this.setState(s => ({ tree: JSON.parse(next.s), future: f.slice(0, -1), history: s.history.concat(cur), syncing: true }));
|
||
this.after(400, () => this.setState({ syncing: false }));
|
||
}
|
||
jumpToHistory(idx) {
|
||
const entry = this.state.history[idx]; if (!entry) return;
|
||
this.setState({ tree: JSON.parse(entry.s), syncing: true, selId: null, histCursor: idx });
|
||
this.after(400, () => this.setState({ syncing: false }));
|
||
this.toast('Restored to “' + entry.label + '” — the next edit clears the dimmed trail.');
|
||
}
|
||
|
||
handleKey(e) {
|
||
const tag = (e.target.tagName || '').toUpperCase();
|
||
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable;
|
||
if (e.key === 'Escape') {
|
||
if (this.state.cmdk) return this.setState({ cmdk: false });
|
||
if (this.state.exportOpen) return this.setState({ exportOpen: false });
|
||
if (this.state.previewOpen) return this.setState({ previewOpen: false });
|
||
if (this.state.brandPop) return this.setState({ brandPop: false });
|
||
if ((this.state.vw || 1440) < 1000 && (this.state.leftOpen || this.state.rightOpen)) return this.setState({ leftOpen: false, rightOpen: false });
|
||
if ((this.state.vw || 1440) < 1000 && this.state.chatOpen) return this.setState({ chatOpen: false });
|
||
if (this.state.isolate) return this.setState({ isolate: null });
|
||
if (this.state.codeEditing) return this.setState({ codeEditing: false });
|
||
if (this.state.bubble) return this.setState({ bubble: null });
|
||
if (this.state.editMode) return this.setState({ editMode: null });
|
||
if (this.state.refusal) return this.setState({ refusal: null, aiStatus: 'idle' });
|
||
if (this.state.proposal) return this.discardProposal();
|
||
if (this.state.aiTarget) return this.setState({ aiTarget: null });
|
||
if (this.state.selId) return this.setState({ selId: null });
|
||
return;
|
||
}
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); this.setState(s => ({ cmdk: !s.cmdk, cmdQuery: '', cmdSel: 0 })); return; }
|
||
if (typing) return;
|
||
if (this.state.screen !== 'editor') return;
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? this.redo() : this.undo(); return; }
|
||
if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.zoomStep(1.25); return; }
|
||
if ((e.metaKey || e.ctrlKey) && e.key === '-') { e.preventDefault(); this.zoomStep(0.8); return; }
|
||
if ((e.metaKey || e.ctrlKey) && e.key === '0') { e.preventDefault(); this.fitView(); return; }
|
||
if ((e.metaKey || e.ctrlKey) && e.key === '1') { e.preventDefault(); this.setZoom(1); return; }
|
||
if ((e.key === 'Backspace' || e.key === 'Delete') && this.state.selId && this.state.selId !== 'board') { e.preventDefault(); this.deleteSel(); }
|
||
}
|
||
|
||
// ---------- editor operations ----------
|
||
LIB = [
|
||
{ type: 'stack', name: 'Stack', glyph: '▤', prim: true },
|
||
{ type: 'heading', name: 'Heading', glyph: 'Aa' },
|
||
{ type: 'text', name: 'Text', glyph: '¶' },
|
||
{ type: 'button', name: 'Button', glyph: '▭' },
|
||
{ type: 'badge', name: 'Badge', glyph: '◦', unmappable: true },
|
||
{ type: 'card', name: 'Card', glyph: '▢' },
|
||
{ type: 'input', name: 'Input', glyph: '⌷' },
|
||
{ type: 'avatar', name: 'Avatar', glyph: '◉' },
|
||
{ type: 'image', name: 'Image', glyph: '▧', prim: true },
|
||
{ type: 'divider', name: 'Divider', glyph: '—', prim: true }
|
||
];
|
||
MAPNAME = { heading: 'Text', text: 'Text', button: 'Button', card: 'Card', input: 'TextField', avatar: 'Avatar', badge: 'Badge' };
|
||
|
||
isStacky(n) { return n && (n.type === 'stack' || n.type === 'card' || n.type === 'frame'); }
|
||
defaultNode(type) {
|
||
const id = this.nid();
|
||
const d = {
|
||
stack: { name: 'Stack', props: { dir: 'col', gap: 16, pad: 0 } },
|
||
heading: { props: { text: 'Headline', size: 'md', weight: 'semibold' } },
|
||
text: { props: { text: 'Body copy that explains one idea clearly.', size: 'md', tone: 'muted' } },
|
||
button: { props: { text: 'Button', variant: 'secondary', size: 'md' } },
|
||
badge: { props: { text: 'Badge' } },
|
||
card: { name: 'Card', props: { pad: 16, gap: 12 } },
|
||
input: { props: { label: 'Email', placeholder: 'you@company.com' } },
|
||
avatar: { props: { name: 'Maya Kade' } },
|
||
image: { props: { label: 'image — drop artwork', ratio: '16/9' } },
|
||
divider: { props: {} }
|
||
}[type] || { props: {} };
|
||
const node = { id, type, name: d.name, props: d.props, children: [] };
|
||
if (type === 'card') node.children = [
|
||
{ id: this.nid(), type: 'heading', props: { text: 'Card title', size: 'sm', weight: 'semibold' }, children: [] },
|
||
{ id: this.nid(), type: 'text', props: { text: 'Supporting copy for this card.', size: 'sm', tone: 'muted' }, children: [] }
|
||
];
|
||
return node;
|
||
}
|
||
targetStack() {
|
||
const t = this.state.tree; if (!t) return null;
|
||
const sel = this.findNode(t, this.state.selId);
|
||
return this.isStacky(sel) ? sel : this.activeFrameNode();
|
||
}
|
||
reId(node) { node.id = this.nid(); (node.children || []).forEach(c => this.reId(c)); return node; }
|
||
|
||
insertNode(node, parentId, idx) {
|
||
this.mutate(t => {
|
||
const p = this.findNode(t, parentId); if (!p) return;
|
||
const i = (idx === undefined || idx === null) ? p.children.length : Math.max(0, Math.min(idx, p.children.length));
|
||
p.children.splice(i, 0, node);
|
||
}, { flash: [node.id], select: node.id, pulse: false, label: 'Add ' + (node.name || node.type) });
|
||
}
|
||
addFromLibrary(type) {
|
||
const target = this.targetStack(); if (!target) return;
|
||
this.insertNode(this.defaultNode(type), target.id);
|
||
}
|
||
moveNode(id, pid, idx) {
|
||
if (id === pid) return;
|
||
const moving = this.findNode(this.state.tree, id);
|
||
if (!moving || this.isDescendant(moving, pid)) return;
|
||
this.mutate(t => {
|
||
const oldP = this.findParent(t, id); if (!oldP) return;
|
||
const oldIdx = oldP.children.findIndex(c => c.id === id);
|
||
const node = oldP.children.splice(oldIdx, 1)[0];
|
||
const p = this.findNode(t, pid); if (!p) { oldP.children.splice(oldIdx, 0, node); return; }
|
||
let i = (idx === undefined || idx === null) ? p.children.length : idx;
|
||
if (oldP.id === pid && oldIdx < i) i--;
|
||
i = Math.max(0, Math.min(i, p.children.length));
|
||
p.children.splice(i, 0, node);
|
||
}, { flash: [id], select: id, label: 'Move ' + ((moving.name || moving.type) || 'node') });
|
||
}
|
||
deleteSel() {
|
||
const id = this.state.selId; if (!id || id === 'board') return;
|
||
const node = this.findNode(this.state.tree, id);
|
||
if (node && node.type === 'frame' && this.frames().length <= 1) { this.toast('Keep at least one frame on the board.', { glyph: '!' }); return; }
|
||
this.mutate(t => {
|
||
const p = this.findParent(t, id); if (!p) return;
|
||
p.children = p.children.filter(c => c.id !== id);
|
||
}, { select: null, label: 'Delete ' + ((node && (node.name || node.type)) || 'node') });
|
||
if (node && node.type === 'frame') this.after(30, () => this.setState({ activeFrame: (this.frames()[0] || {}).id }));
|
||
}
|
||
duplicateSel() {
|
||
const id = this.state.selId; if (!id || id === 'board') return;
|
||
const src = this.findNode(this.state.tree, id); if (!src) return;
|
||
const copy = this.reId(this.clone(src));
|
||
if (copy.type === 'frame') { copy.props.x = (copy.props.x || 0) + 90; copy.props.y = (copy.props.y || 0) + 90; copy.name = (src.name || 'Frame') + ' copy'; }
|
||
this.mutate(t => {
|
||
const p = this.findParent(t, id); if (!p) return;
|
||
const i = p.children.findIndex(c => c.id === id);
|
||
p.children.splice(i + 1, 0, copy);
|
||
}, { flash: [copy.id], select: copy.id, label: 'Duplicate ' + (src.name || src.type) });
|
||
}
|
||
setSelProp(key, val, quiet) {
|
||
const id = this.state.selId; if (!id) return;
|
||
if (quiet) {
|
||
const now = Date.now();
|
||
if (!this.lastTextPush || now - this.lastTextPush > 900) { this.pushHistory('Edit text'); this.lastTextPush = now; }
|
||
const t = this.clone(this.state.tree);
|
||
const n = this.findNode(t, id); if (!n) return;
|
||
n.props[key] = val;
|
||
this.setState({ tree: t, flashIds: [id], flashStamp: Date.now(), syncing: true, saveState: 'saving' });
|
||
this.after(450, () => this.setState({ syncing: false }));
|
||
this.after(900, () => this.setState({ saveState: 'saved' }));
|
||
return;
|
||
}
|
||
this.mutate(t => { const n = this.findNode(t, id); if (n) n.props[key] = val; }, { flash: [id], label: 'Set ' + key + ' → ' + val });
|
||
}
|
||
setSelName(val) {
|
||
const id = this.state.selId; if (!id) return;
|
||
const now = Date.now();
|
||
if (!this.lastTextPush || now - this.lastTextPush > 900) { this.pushHistory(); this.lastTextPush = now; }
|
||
const t = this.clone(this.state.tree);
|
||
const n = this.findNode(t, id); if (!n) return;
|
||
n.name = val;
|
||
this.setState({ tree: t, syncing: true, saveState: 'saving' });
|
||
this.after(450, () => this.setState({ syncing: false }));
|
||
this.after(900, () => this.setState({ saveState: 'saved' }));
|
||
}
|
||
inspectNode(id) {
|
||
this.selectNode(id);
|
||
const patch = { rightCollapsed: false, rightTab: 'inspector' };
|
||
if ((this.state.vw || 1440) < 1000) patch.rightOpen = true;
|
||
this.setState(patch);
|
||
}
|
||
stepPad(delta) {
|
||
const n = this.findNode(this.state.tree, this.state.selId); if (!n) return;
|
||
const clamp = (v) => Math.max(0, Math.min(96, v));
|
||
const id = n.id;
|
||
this.mutate(t => {
|
||
const x = this.findNode(t, id); if (!x) return;
|
||
if (x.props.pad !== undefined || (x.props.padX === undefined && x.props.padY === undefined)) x.props.pad = clamp((x.props.pad || 0) + delta);
|
||
else { if (x.props.padX !== undefined) x.props.padX = clamp(x.props.padX + delta); if (x.props.padY !== undefined) x.props.padY = clamp(x.props.padY + delta); }
|
||
}, { flash: [id] });
|
||
}
|
||
stepSel(key, delta, min, max) {
|
||
const n = this.findNode(this.state.tree, this.state.selId); if (!n) return;
|
||
const cur = n.props[key] || 0;
|
||
this.setSelProp(key, Math.max(min, Math.min(max, cur + delta)));
|
||
}
|
||
|
||
// ---------- tailwind utilities ----------
|
||
TW_MAP = {
|
||
'w-full': { width: '100%' }, 'w-1/2': { width: '50%' }, 'w-1/3': { width: '33.333%' }, 'w-2/3': { width: '66.667%' }, 'w-64': { width: '256px' }, 'w-96': { width: '384px' },
|
||
'h-auto': { height: 'auto' }, 'h-24': { height: '96px' }, 'h-48': { height: '192px' }, 'h-64': { height: '256px' }, 'h-96': { height: '384px' },
|
||
'max-w-sm': { maxWidth: '384px' }, 'max-w-md': { maxWidth: '448px' }, 'max-w-lg': { maxWidth: '512px' }, 'max-w-2xl': { maxWidth: '672px' },
|
||
'shadow-sm': { boxShadow: '0 1px 2px 0 rgba(0,0,0,.05)' }, 'shadow-md': { boxShadow: '0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1)' }, 'shadow-lg': { boxShadow: '0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1)' },
|
||
'opacity-75': { opacity: 0.75 }, 'opacity-50': { opacity: 0.5 }
|
||
};
|
||
twStyles(tw) {
|
||
if (!tw) return {};
|
||
const out = {};
|
||
['w', 'h', 'maxw', 'shadow', 'opacity'].forEach(k => { const cls = tw[k]; if (cls && this.TW_MAP[cls]) Object.assign(out, this.TW_MAP[cls]); });
|
||
return out;
|
||
}
|
||
twCls(p) {
|
||
const tw = p && p.tw; if (!tw) return '';
|
||
return ['w', 'h', 'maxw', 'shadow', 'opacity'].map(k => tw[k]).concat([tw.custom]).filter(Boolean).join(' ').trim();
|
||
}
|
||
setTw(cat, val) {
|
||
const id = this.state.selId; if (!id) return;
|
||
this.mutate(t => { const n = this.findNode(t, id); if (!n) return; const tw = Object.assign({}, n.props.tw || {}); if (val) tw[cat] = val; else delete tw[cat]; n.props.tw = tw; }, { flash: [id], label: 'Tailwind · ' + cat });
|
||
}
|
||
|
||
// ---------- editable code (parse back) ----------
|
||
codeText() { return this.buildCode('home').lines.map(l => l.t).join('\n'); }
|
||
toggleCodeEdit() {
|
||
const s = this.state;
|
||
if (s.codeEditing) return this.setState({ codeEditing: false });
|
||
if (s.codeFile !== 'home') { this.toast('tokens.css is generated — edit tokens in the left rail.', { glyph: '!' }); return; }
|
||
this.setState({ codeEditing: true, codeDraft: this.codeText() });
|
||
}
|
||
twNum(v) { const m = v.match(/^\[(\d+)px\]$/); if (m) return parseInt(m[1]); const n = parseFloat(v); return isNaN(n) ? 0 : Math.round(n * 4); }
|
||
clsParse(str) {
|
||
const p = {};
|
||
let sawCol = false;
|
||
(str || '').split(/\s+/).forEach(c => {
|
||
if (c === 'flex-col') { p.dir = 'col'; sawCol = true; }
|
||
else if (c === 'items-center') p.align = 'center';
|
||
else if (c === 'items-start') p.align = 'start';
|
||
else if (c === 'items-end') p.align = 'end';
|
||
else if (c === 'justify-between') p.justify = 'between';
|
||
else if (c === 'justify-center') p.justify = 'center';
|
||
else if (/^gap-/.test(c)) p.gap = this.twNum(c.slice(4));
|
||
else if (/^px-/.test(c)) p.padX = this.twNum(c.slice(3));
|
||
else if (/^py-/.test(c)) p.padY = this.twNum(c.slice(3));
|
||
else if (/^pb-/.test(c)) p.padBottom = this.twNum(c.slice(3));
|
||
else if (/^p-/.test(c)) p.pad = this.twNum(c.slice(2));
|
||
else if (/^max-w-\[(\d+)px\]$/.test(c)) p.maxW = parseInt(c.match(/\d+/)[0]);
|
||
else if (c === 'bg-white') p.bg = 'white';
|
||
else if (c.startsWith('bg-') && /-50$/.test(c)) p.bg = 'soft';
|
||
});
|
||
if (!sawCol && p.dir === undefined) p.dir = 'row';
|
||
return p;
|
||
}
|
||
parseCode(text) {
|
||
const lines = text.split('\n');
|
||
const root = { children: [] };
|
||
const stack = [root];
|
||
let fnName = null;
|
||
const push = (n) => stack[stack.length - 1].children.push(n);
|
||
for (let li = 0; li < lines.length; li++) {
|
||
const t = lines[li].trim();
|
||
if (!t || t.startsWith('import ') || t.startsWith('//') || t === 'return (' || t === ');' || t === '}') continue;
|
||
let m;
|
||
if ((m = t.match(/^export default function (\w+)\s*\(\)\s*\{$/))) { fnName = m[1]; continue; }
|
||
if (/^<main\b/.test(t) || t === '</main>') continue;
|
||
if ((m = t.match(/^<(div|nav)(?: className="([^"]*)")?>$/))) { const n = this.pn('stack', this.clsParse(m[2])); if (m[1] === 'nav') n.name = 'Nav'; push(n); stack.push(n); continue; }
|
||
if ((m = t.match(/^<Card(?: className="([^"]*)")?>$/))) { const c = this.clsParse(m[1]); const n = this.pn('card', { pad: c.pad === undefined ? 16 : c.pad, gap: c.gap }); push(n); stack.push(n); continue; }
|
||
if (t === '</div>' || t === '</nav>' || t === '</Card>') { if (stack.length > 1) stack.pop(); continue; }
|
||
if ((m = t.match(/^<h([1-4])(?: className="([^"]*)")?>(.*)<\/h\1>$/))) {
|
||
const cls = m[2] || '';
|
||
const size = /text-6xl/.test(cls) ? 'xl' : /text-4xl/.test(cls) ? 'lg' : /text-xl(\s|")/.test(cls + '"') ? 'sm' : 'md';
|
||
const weight = /font-bold/.test(cls) ? 'bold' : /font-normal/.test(cls) ? 'regular' : 'semibold';
|
||
push(this.pn('heading', { text: m[3], size, weight })); continue;
|
||
}
|
||
if ((m = t.match(/^<p(?: className="([^"]*)")?>(.*)<\/p>$/))) {
|
||
const cls = m[1] || '';
|
||
const size = /text-sm/.test(cls) ? 'sm' : /text-lg/.test(cls) ? 'lg' : 'md';
|
||
const tone = /-400(\s|")/.test(cls + '"') ? 'faint' : /-600(\s|")/.test(cls + '"') ? 'muted' : 'default';
|
||
push(this.pn('text', { text: m[2], size, tone })); continue;
|
||
}
|
||
if ((m = t.match(/^<Button kind="(\w+)"(?: size="(\w+)")?>(.*)<\/Button>$/))) { push(this.pn('button', { text: m[3], variant: m[1], size: m[2] || 'md' })); continue; }
|
||
if ((m = t.match(/^<Badge>(.*)<\/Badge>$/))) { push(this.pn('badge', { text: m[1] })); continue; }
|
||
if ((m = t.match(/^<TextField label="([^"]*)" placeholder="([^"]*)"\s*\/>$/))) { push(this.pn('input', { label: m[1], placeholder: m[2] })); continue; }
|
||
if ((m = t.match(/^<Avatar name="([^"]*)"\s*\/>$/))) { push(this.pn('avatar', { name: m[1] })); continue; }
|
||
if (/^<hr\b/.test(t)) { push(this.pn('divider', {})); continue; }
|
||
if (/^<div className="aspect-/.test(t)) { push(this.pn('image', { label: 'image — drop artwork', ratio: '16/9' })); continue; }
|
||
if ((m = t.match(/^<([A-Z]\w*)\s*\/>$/)) && (this.state.customComps || []).some(c => c.name === m[1])) {
|
||
const cc = this.state.customComps.find(c => c.name === m[1]);
|
||
push({ id: this.nid('cu'), type: 'custom', name: cc.name, props: { comp: cc.name, file: cc.file, propNames: cc.props }, children: [] }); continue;
|
||
}
|
||
return { error: t.slice(0, 46), line: li + 1 };
|
||
}
|
||
if (stack.length !== 1) return { error: 'an unclosed tag', line: lines.length };
|
||
return { children: root.children, name: fnName };
|
||
}
|
||
applyCodeEdit() {
|
||
const res = this.parseCode(this.state.codeDraft);
|
||
if (res.error) { this.toast('Line ' + res.line + ': couldn\u2019t read \u201c' + res.error + '\u201d — the editor speaks the generated grammar.', { glyph: '!' }); return; }
|
||
const frame = this.activeFrameNode(); if (!frame) return;
|
||
const id = frame.id;
|
||
this.mutate(t => { const f = this.findNode(t, id); if (!f) return; f.children = res.children; if (res.name) f.name = res.name; }, { flash: [id], label: 'Edited code by hand' });
|
||
this.setState({ codeEditing: false });
|
||
this.toast('Parsed back onto the canvas — the grammar runs both ways.');
|
||
}
|
||
|
||
// ---------- isolation ----------
|
||
buildIsoEl() {
|
||
const h = React.createElement;
|
||
const iso = this.state.isolate; if (!iso) return null;
|
||
const sys = this.getSystem();
|
||
const wrap = (label, node) => h('div', { key: label + node.id, style: { display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
|
||
h('span', { style: { fontFamily: this.MONO, fontSize: '10px', color: '#8A8279', letterSpacing: '.06em' } }, label),
|
||
this.renderNode(node, { mode: 'preview', frameW: 1280 }));
|
||
const mk = (type, props) => ({ id: 'iso' + Math.random().toString(36).slice(2, 7), type, props, children: [] });
|
||
let cells = [];
|
||
if (iso === 'button') cells = ['primary', 'secondary', 'ghost'].map(v => wrap(v + ' — hover me', mk('button', { text: 'Get started', variant: v, size: 'md' })));
|
||
else if (iso === 'heading') cells = ['xl', 'lg', 'md', 'sm'].map(sz => wrap('size ' + sz, mk('heading', { text: 'Scheduling, kindly', size: sz, weight: 'semibold' })));
|
||
else if (iso === 'text') cells = ['default', 'muted', 'faint'].map(tn => wrap('tone ' + tn, mk('text', { text: 'Body copy that explains one idea clearly.', size: 'md', tone: tn })));
|
||
else if (iso === 'badge') cells = [wrap('default', mk('badge', { text: 'New — shared calendars' }))];
|
||
else if (iso === 'card') cells = [wrap('default — hover me', { id: 'isoC', type: 'card', props: { pad: 20, gap: 10 }, children: [mk('heading', { text: 'Card title', size: 'sm', weight: 'semibold' }), mk('text', { text: 'The runtime here is real.', size: 'sm', tone: 'muted' })] })];
|
||
else if (iso === 'input') cells = [wrap('default — click in', mk('input', { label: 'Email', placeholder: 'you@company.com' }))];
|
||
else if (iso === 'avatar') cells = [wrap('MK', mk('avatar', { name: 'Maya Kade' })), wrap('DP', mk('avatar', { name: 'Devon Park' }))];
|
||
else if (iso === 'image') cells = [wrap('16 / 9', mk('image', { label: 'image slot', ratio: '16/9' })), wrap('1 / 1', mk('image', { label: 'image slot', ratio: '1/1' }))];
|
||
else if (iso === 'divider') cells = [wrap('hairline', mk('divider', {}))];
|
||
else if (iso === 'stack') cells = [wrap('row · gap 12', { id: 'isoS', type: 'stack', props: { dir: 'row', gap: 12 }, children: [mk('badge', { text: 'One' }), mk('badge', { text: 'Two' }), mk('badge', { text: 'Three' })] })];
|
||
else {
|
||
const cc = (this.state.customComps || []).find(c => c.name === iso);
|
||
if (cc) cells = [wrap(cc.file, { id: 'isoCu', type: 'custom', name: cc.name, props: { comp: cc.name, file: cc.file, propNames: cc.props }, children: [] })];
|
||
}
|
||
return h('div', { style: Object.assign({ background: '#FFFFFF', border: '1px solid #ECE8E2', borderRadius: '18px', padding: '36px', display: 'flex', flexWrap: 'wrap', gap: '40px', alignItems: 'flex-start', maxWidth: '860px', width: '100%', boxShadow: '0 24px 80px rgba(0,0,0,.3)' }, this.canvasVars(sys)) }, cells);
|
||
}
|
||
|
||
// ---------- custom component upload ----------
|
||
onCompFilePicked(e) {
|
||
const f = (e.target.files || [])[0];
|
||
e.target.value = '';
|
||
if (!f) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const src = String(reader.result || '');
|
||
let m = src.match(/export\s+default\s+function\s+([A-Z]\w*)/) || src.match(/function\s+([A-Z]\w*)\s*\(/) || src.match(/const\s+([A-Z]\w*)\s*[:=]/);
|
||
let name = m ? m[1] : f.name.replace(/\.(jsx|tsx|js)$/i, '').replace(/(^|[-_ ])(\w)/g, (a, b, c) => c.toUpperCase());
|
||
if (!/^[A-Z]/.test(name)) name = 'C' + name;
|
||
const pm = src.match(/\(\s*\{\s*([^}]{0,140})\}/);
|
||
const propNames = pm ? pm[1].split(',').map(x => x.trim().split(/[=:]/)[0].trim()).filter(x => /^\w+$/.test(x)).slice(0, 6) : [];
|
||
if ((this.state.customComps || []).some(c => c.name === name)) { this.toast(name + ' is already in your library.', { glyph: '!' }); return; }
|
||
this.setState(s => ({ customComps: s.customComps.concat({ name, file: f.name, props: propNames }) }));
|
||
this.toast(name + ' added to your library — placeholder on canvas, real import in the export.');
|
||
};
|
||
reader.readAsText(f);
|
||
}
|
||
|
||
startResize(which) {
|
||
return (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
const startX = e.clientX;
|
||
const s0 = { left: this.state.leftW, right: this.state.rightW, code: this.state.codeW, chat: this.state.chatW || 300 };
|
||
const move = (ev) => {
|
||
const dx = ev.clientX - startX;
|
||
if (which === 'left') this.setState({ leftW: Math.max(180, Math.min(400, s0.left + dx)) });
|
||
if (which === 'right') this.setState({ rightW: Math.max(210, Math.min(440, s0.right - dx)) });
|
||
if (which === 'code') this.setState({ codeW: Math.max(300, Math.min(700, s0.code - dx)) });
|
||
if (which === 'chat') this.setState({ chatW: Math.max(250, Math.min(480, s0.chat + dx)) });
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
|
||
document.body.style.cursor = ''; document.body.style.userSelect = '';
|
||
};
|
||
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
||
document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none';
|
||
};
|
||
}
|
||
|
||
// ---------- drag & drop ----------
|
||
onLibDrag(type) { return (e) => { e.dataTransfer.setData('text/wtype', type); e.dataTransfer.effectAllowed = 'copy'; }; }
|
||
onPatDrag(pi) { return (e) => { e.dataTransfer.setData('text/wpat', String(pi)); e.dataTransfer.effectAllowed = 'copy'; }; }
|
||
dragOverStack(nodeId, dir) {
|
||
return (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
const els = Array.from(e.currentTarget.children).filter(el => !el.dataset.m);
|
||
let idx = els.length;
|
||
for (let i = 0; i < els.length; i++) {
|
||
const r = els[i].getBoundingClientRect();
|
||
const mid = dir === 'row' ? r.left + r.width / 2 : r.top + r.height / 2;
|
||
const ptr = dir === 'row' ? e.clientX : e.clientY;
|
||
if (ptr < mid) { idx = i; break; }
|
||
}
|
||
const d = this.state.drop;
|
||
if (!d || d.pid !== nodeId || d.idx !== idx) this.setState({ drop: { pid: nodeId, idx } });
|
||
};
|
||
}
|
||
dropOnStack(nodeId) {
|
||
return (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
const drop = this.state.drop;
|
||
const idx = drop && drop.pid === nodeId ? drop.idx : undefined;
|
||
const type = e.dataTransfer.getData('text/wtype');
|
||
const mv = e.dataTransfer.getData('text/wmove');
|
||
const pat = e.dataTransfer.getData('text/wpat');
|
||
this.setState({ drop: null });
|
||
if (type) { const node = this.defaultNode(type); this.mutate(t => { const p = this.findNode(t, nodeId); if (p) p.children.splice(idx === undefined ? p.children.length : idx, 0, node); }, { flash: [node.id], select: node.id }); }
|
||
else if (pat !== '' && pat !== null && pat !== undefined && this.state.patterns[+pat]) { const node = this.reId(this.clone(this.state.patterns[+pat].node)); this.mutate(t => { const p = this.findNode(t, nodeId); if (p) p.children.splice(idx === undefined ? p.children.length : idx, 0, node); }, { flash: [node.id], select: node.id }); }
|
||
else if (mv) this.moveNode(mv, nodeId, idx);
|
||
};
|
||
}
|
||
clearDrop() { if (this.state.drop) this.setState({ drop: null }); }
|
||
|
||
// ---------- tokens ----------
|
||
applyBrand(hex) {
|
||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) { this.toast('That is not a hex color — try #2A6FDB.', { glyph: '!' }); return; }
|
||
const prev = this.getSystem().brand;
|
||
const q = Object.assign({}, this.state.quick, { brand: hex });
|
||
this.setState({ quick: q, system: this.buildSystem(Object.assign({}, this.getSystem(), { brand: hex })), brandPop: false, hexDraft: hex, pulseStamp: Date.now(), flashIds: ['tok-brand'], flashStamp: Date.now(), syncing: true });
|
||
this.after(450, () => this.setState({ syncing: false }));
|
||
const used = this.countBrandUse();
|
||
this.toast('brand-500 → ' + hex.toUpperCase() + ' · ripples across ' + used + ' components + code', { act: 'Undo', onAct: () => this.applyBrand(prev) });
|
||
}
|
||
setRadius(delta) {
|
||
const sys = this.getSystem();
|
||
const r = Math.max(0, Math.min(24, sys.radius + delta));
|
||
this.setState({ system: this.buildSystem(Object.assign({}, sys, { radius: r })), pulseStamp: Date.now(), flashIds: ['tok-radius'], flashStamp: Date.now(), syncing: true });
|
||
this.after(450, () => this.setState({ syncing: false }));
|
||
}
|
||
countBrandUse() {
|
||
let c = 0;
|
||
if (this.state.tree) this.walk(this.state.tree, n => { if (n.type === 'button' || n.type === 'badge' || n.type === 'avatar') c++; });
|
||
return c;
|
||
}
|
||
mapBadge() {
|
||
this.setState({ badgeMapped: true });
|
||
this.toast('Badge → @solstice/ui/Badge · prop variant→tone. Export updated.');
|
||
}
|
||
savePattern() {
|
||
const n = this.findNode(this.state.tree, this.state.selId);
|
||
if (!this.isStacky(n) || n.type === 'frame') return;
|
||
const name = (n.name || 'Pattern') + ' · ' + (++this.patN);
|
||
this.setState(s => ({ patterns: s.patterns.concat({ name, node: this.clone(n) }) }));
|
||
this.toast('"' + name + '" saved to your library.');
|
||
}
|
||
|
||
// ---------- chrome ----------
|
||
toggleTheme() {
|
||
const th = this.state.theme === 'dark' ? 'light' : 'dark';
|
||
this.setState({ theme: th });
|
||
document.body.dataset.th = th;
|
||
}
|
||
chromeBtn(active) {
|
||
return { display: 'flex', alignItems: 'center', gap: '5px', padding: '5px 8px', borderRadius: '7px', fontSize: '12px', fontWeight: 500, color: active ? 'var(--iris-t)' : 'var(--t2)', background: active ? 'var(--iris-dim)' : 'transparent' };
|
||
}
|
||
copyCodeText(which) {
|
||
const af = this.activeFrameNode();
|
||
const nm = which === 'tokens' ? 'tokens.css' : ((((af && af.name) || 'Screen').replace(/[^A-Za-z0-9]/g, '') || 'Screen') + '.tsx');
|
||
const txt = this.buildCode(which).lines.map(l => l.t).join('\n');
|
||
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(() => this.toast(nm + ' copied — Prettier-clean, imports @solstice/ui.'), () => this.toast('Copy blocked by the browser.', { glyph: '!' }));
|
||
}
|
||
|
||
// ---------- command palette ----------
|
||
cmdList() {
|
||
const q = this.state.cmdQuery.trim();
|
||
if (q.startsWith('✦') || q.toLowerCase().startsWith('ai:')) {
|
||
const rest = q.replace(/^✦\s*|^ai:\s*/i, '');
|
||
return [{ label: '✦ Compose: ' + (rest || '…'), hint: 'AI', run: () => { this.setState({ cmdk: false, aiText: rest }); this.after(60, () => { if (this.aiRef.current) this.aiRef.current.focus(); }); } }];
|
||
}
|
||
const all = [
|
||
{ label: 'Open preview', hint: '⟳', run: () => this.setState({ previewOpen: true }) },
|
||
{ label: 'Toggle code panel', hint: '</>', run: () => this.setState(s => ({ codeOpen: !s.codeOpen })) },
|
||
{ label: 'Run Polish', hint: '✧', run: () => this.runPolish() },
|
||
{ label: 'Export…', hint: '⇪', run: () => this.setState({ exportOpen: true }) },
|
||
{ label: 'Tokens', hint: 'left rail', run: () => this.setState({ leftTab: 'tokens' }) },
|
||
{ label: 'Library', hint: 'left rail', run: () => this.setState({ leftTab: 'library' }) },
|
||
{ label: 'Layers', hint: 'left rail', run: () => this.setState({ leftTab: 'layers' }) },
|
||
{ label: 'Toggle app theme', hint: '◐', run: () => this.toggleTheme() },
|
||
{ label: 'Toggle art-board backdrop', hint: 'canvas', run: () => this.setState(s => ({ deskLight: !s.deskLight })) },
|
||
{ label: 'Undo', hint: '⌘Z', run: () => this.undo() },
|
||
{ label: 'Go home — projects', hint: 'nav', run: () => this.go('home') },
|
||
{ label: 'Settings', hint: 'nav', run: () => this.go('settings') },
|
||
{ label: 'Profile', hint: 'nav', run: () => this.go('profile') },
|
||
{ label: 'System update — re-import tokens', hint: 'nav', run: () => { this.setState({ sysStage: 'paste' }); this.go('sysupdate'); } },
|
||
{ label: 'Back to onboarding', hint: 'demo', run: () => this.setState({ screen: 'onboarding', ob: 'welcome' }) }
|
||
];
|
||
if (!q) return all;
|
||
return all.filter(c => c.label.toLowerCase().includes(q.toLowerCase()));
|
||
}
|
||
runCmd(fn) { this.setState({ cmdk: false }); this.after(30, fn); }
|
||
|
||
// ---------- canvas rendering ----------
|
||
nodeLabel(n) {
|
||
if (n.name) return n.name;
|
||
if (n.type === 'heading' || n.type === 'text' || n.type === 'button' || n.type === 'badge') { const t = (n.props.text || ''); return t.length > 22 ? t.slice(0, 22) + '…' : t || n.type; }
|
||
return n.type.charAt(0).toUpperCase() + n.type.slice(1);
|
||
}
|
||
renderNode(n, ctx) {
|
||
const h = React.createElement;
|
||
const sys = this.getSystem();
|
||
const N = sys.n, R = sys.ramp;
|
||
const mode = ctx.mode;
|
||
const editor = mode === 'editor';
|
||
const ghost = mode === 'ghost';
|
||
const sel = editor && this.state.selId === n.id;
|
||
const hov = editor && this.state.hovId === n.id && !sel;
|
||
const now = Date.now();
|
||
const rippling = now - this.state.pulseStamp < 1100;
|
||
const born = this.state.bornIds[n.id];
|
||
const trans = 'background-color .4s,color .4s,border-color .4s,border-radius .4s,box-shadow .2s,gap .25s,padding .25s';
|
||
const st = { position: 'relative', transition: trans, fontFamily: 'var(--cf)' };
|
||
const overlaysHidden = this.state.overlaysHidden;
|
||
const isTarget = editor && this.state.aiTarget === n.id;
|
||
const picking = editor && this.state.editMode === 'pick';
|
||
if (editor && !overlaysHidden) {
|
||
if (isTarget) st.boxShadow = '0 0 0 1.5px #FFFFFF, 0 0 0 3px var(--warn)';
|
||
else if (sel) st.boxShadow = '0 0 0 1.5px #FFFFFF, 0 0 0 3.5px var(--iris)';
|
||
else if (hov) st.boxShadow = picking ? '0 0 0 2px rgba(224,160,58,.8)' : '0 0 0 1.5px rgba(61,123,250,.55)';
|
||
}
|
||
if (editor && this.state.proposal && this.state.proposal.kind === 'edit' && this.state.proposal.targetId === n.id) st.opacity = 0.4;
|
||
if (ghost) { st.opacity = 0.62; st.outline = '1.5px dashed var(--iris)'; st.outlineOffset = '2px'; st.pointerEvents = 'none'; }
|
||
if (rippling && !ghost) { ctx.i = (ctx.i || 0) + 1; st.animation = 'nodePulse .8s ease-out ' + (ctx.i * 26) + 'ms'; }
|
||
else if (born !== undefined) st.animation = 'riseIn .45s ' + born + 'ms both';
|
||
|
||
const props = {};
|
||
props.key = n.id;
|
||
if (editor) {
|
||
props.onClick = (e) => { e.stopPropagation(); if (this.state.editMode === 'pick') this.pickTarget(n, e); else this.selectNode(n.id); };
|
||
props.onDoubleClick = (e) => { e.stopPropagation(); this.inspectNode(n.id); };
|
||
props.onMouseOver = (e) => { e.stopPropagation(); if (this.state.hovId !== n.id) this.setState({ hovId: n.id }); };
|
||
if (n.type !== 'frame') {
|
||
props.draggable = true;
|
||
props.onDragStart = (e) => { e.stopPropagation(); e.dataTransfer.setData('text/wmove', n.id); e.dataTransfer.effectAllowed = 'move'; };
|
||
props.onDragEnd = () => this.clearDrop();
|
||
}
|
||
}
|
||
const kids = [];
|
||
if (sel && n.type !== 'frame' && !overlaysHidden) kids.push(h('span', { key: '__t', 'data-m': '1', style: { position: 'absolute', top: '-21px', left: '-2px', fontSize: '10px', fontFamily: this.MONO, background: 'var(--iris)', color: '#fff', padding: '2px 7px', borderRadius: '4px', zIndex: 6, pointerEvents: 'none', whiteSpace: 'nowrap', letterSpacing: '.02em' } }, this.nodeLabel(n)));
|
||
|
||
const centered = ctx.centered;
|
||
const type = n.type, p = n.props || {};
|
||
Object.assign(st, this.twStyles(p.tw));
|
||
|
||
if (type === 'frame' || type === 'stack' || type === 'card') {
|
||
const dir = p.dir || 'col';
|
||
const fw = type === 'frame' ? (ctx.frameW || p.w || 1280) : (ctx.frameW || 1280);
|
||
Object.assign(st, { display: 'flex', flexDirection: dir === 'row' ? 'row' : 'column', gap: (p.gap || 0) + 'px' });
|
||
if (dir === 'row' && fw <= 900) st.flexWrap = 'wrap';
|
||
if (p.pad !== undefined && p.pad !== null) st.padding = p.pad + 'px';
|
||
if (p.padX !== undefined) { st.paddingLeft = p.padX + 'px'; st.paddingRight = p.padX + 'px'; }
|
||
if (p.padY !== undefined) { st.paddingTop = p.padY + 'px'; st.paddingBottom = p.padY + 'px'; }
|
||
if (p.padBottom !== undefined) st.paddingBottom = p.padBottom + 'px';
|
||
if (p.align) st.alignItems = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' }[p.align] || 'stretch';
|
||
if (p.justify) st.justifyContent = { start: 'flex-start', center: 'center', end: 'flex-end', between: 'space-between' }[p.justify] || 'flex-start';
|
||
if (p.maxW) { st.maxWidth = p.maxW + 'px'; st.marginLeft = 'auto'; st.marginRight = 'auto'; st.width = '100%'; }
|
||
if (p.bg === 'soft') st.background = N[50];
|
||
if (p.bg === 'white') st.background = '#FFFFFF';
|
||
if (type === 'card') {
|
||
Object.assign(st, { background: '#FFFFFF', border: '1px solid ' + N[200], borderRadius: 'var(--cr)', boxShadow: (st.boxShadow ? st.boxShadow + ', ' : '') + '0 1px 2px rgba(28,25,23,.06)', flex: ctx.parentDir === 'row' ? (fw <= 900 ? '1 1 240px' : '1 1 0') : undefined, minWidth: 0 });
|
||
if (st.padding === undefined && p.pad === undefined) st.padding = '16px';
|
||
if (mode === 'preview') { props.onMouseEnter = (e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 18px rgba(28,25,23,.10)'; }; props.onMouseLeave = (e) => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = '0 1px 2px rgba(28,25,23,.06)'; }; }
|
||
}
|
||
if (type === 'frame') {
|
||
if (mode === 'preview' && fw >= 1000) Object.assign(st, { width: '100%', background: '#FFFFFF', color: N[800], minHeight: '100vh' });
|
||
else if (mode === 'preview') Object.assign(st, { width: '100%', background: '#FFFFFF', color: N[800], minHeight: '740px', borderRadius: '28px', overflow: 'hidden', border: '1px solid ' + N[200], boxShadow: '0 24px 80px rgba(26,23,20,.18)' });
|
||
else Object.assign(st, { width: '100%', minWidth: '320px', minHeight: '140px', background: '#FFFFFF', borderRadius: '14px', overflow: 'hidden', boxShadow: (st.boxShadow ? st.boxShadow + ', ' : '') + '0 2px 10px rgba(0,0,0,.28), 0 28px 80px rgba(0,0,0,.30)', color: N[800] });
|
||
}
|
||
if (editor && this.isStacky(n)) {
|
||
props.onDragOver = this.dragOverStack(n.id, dir);
|
||
props.onDrop = this.dropOnStack(n.id);
|
||
props.onDragLeave = (e) => { if (e.currentTarget === e.target) this.clearDrop(); };
|
||
}
|
||
const childCtx = { mode, parentDir: dir, centered: dir === 'col' && p.align === 'center', i: ctx.i, frameW: fw };
|
||
const prEdit = this.state.proposal && this.state.proposal.kind === 'edit' ? this.state.proposal : null;
|
||
const cs = [];
|
||
(n.children || []).forEach(c => {
|
||
cs.push(this.renderNode(c, childCtx));
|
||
if (editor && prEdit && prEdit.targetId === c.id) prEdit.nodes.forEach((g, gi) => cs.push(this.renderNode(g, { mode: 'ghost', parentDir: dir, key: 'ge' + gi })));
|
||
});
|
||
const drop = this.state.drop;
|
||
if (editor && drop && drop.pid === n.id) {
|
||
const marker = h('div', { key: '__m', 'data-m': '1', style: { flex: '0 0 auto', alignSelf: 'stretch', width: dir === 'row' ? '3px' : 'auto', minHeight: dir === 'row' ? '28px' : '3px', height: dir === 'row' ? 'auto' : '3px', background: 'var(--iris)', borderRadius: '2px', boxShadow: '0 0 8px rgba(61,123,250,.7)' } });
|
||
cs.splice(Math.min(drop.idx, cs.length), 0, marker);
|
||
}
|
||
if (this.state.proposal && !this.state.proposal.kind && this.state.proposal.targetId === n.id && (editor || mode === 'preview')) {
|
||
this.state.proposal.nodes.forEach((g, gi) => cs.push(this.renderNode(g, { mode: 'ghost', parentDir: dir, key: 'g' + gi })));
|
||
}
|
||
if (type === 'frame' && cs.length === 0) cs.push(h('div', { key: '__e', style: { padding: '110px 32px', textAlign: 'center', fontFamily: this.MONO, fontSize: '12px', color: N[400] } }, 'Drag a component here — or ✦ describe this screen.'));
|
||
props.style = st;
|
||
return h('div', props, kids.concat(cs));
|
||
}
|
||
|
||
if (type === 'heading') {
|
||
const fw2 = ctx.frameW || 1280;
|
||
const sizes = { xl: [fw2 <= 480 ? '34px' : fw2 <= 900 ? '44px' : '54px', '-1.5px', 1.08], lg: [fw2 <= 480 ? '29px' : '40px', '-0.8px', 1.1], md: [fw2 <= 480 ? '24px' : '28px', '-0.4px', 1.15], sm: ['19px', '-0.2px', 1.25] };
|
||
const [fs, ls, lh] = sizes[p.size || 'md'] || sizes.md;
|
||
const wt = { regular: 400, semibold: 600, bold: 700 }[p.weight || 'semibold'];
|
||
Object.assign(st, { fontSize: fs, letterSpacing: ls, lineHeight: lh, fontWeight: wt, color: N[900], maxWidth: p.size === 'xl' ? '820px' : undefined, textAlign: centered ? 'center' : undefined, textWrap: 'balance' });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([p.text || '']));
|
||
}
|
||
if (type === 'text') {
|
||
const sizes = { sm: ['14px', 1.6], md: ['16.5px', 1.6], lg: ['19px', 1.55] };
|
||
const [fs, lh] = sizes[p.size || 'md'] || sizes.md;
|
||
const tone = { default: N[800], muted: N[600], faint: N[400] }[p.tone || 'default'];
|
||
Object.assign(st, { fontSize: fs, lineHeight: lh, color: tone, maxWidth: '640px', textAlign: centered ? 'center' : undefined, textWrap: 'pretty' });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([p.text || '']));
|
||
}
|
||
if (type === 'button') {
|
||
const sizes = { sm: ['8px 14px', '13.5px'], md: ['11px 20px', '15px'], lg: ['14px 26px', '16.5px'] };
|
||
const [pad, fs] = sizes[p.size || 'md'] || sizes.md;
|
||
const v = p.variant || 'primary';
|
||
Object.assign(st, { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: pad, fontSize: fs, fontWeight: 600, borderRadius: 'var(--crb)', cursor: mode === 'preview' ? 'pointer' : undefined, whiteSpace: 'nowrap', userSelect: 'none' });
|
||
if (v === 'primary') Object.assign(st, { background: R[500], color: '#FFFFFF' });
|
||
if (v === 'secondary') Object.assign(st, { background: '#FFFFFF', color: N[800], border: '1px solid ' + N[300] });
|
||
if (v === 'ghost') Object.assign(st, { background: 'transparent', color: N[700] });
|
||
if (mode === 'preview') {
|
||
props.onMouseEnter = (e) => { e.currentTarget.style.background = v === 'primary' ? R[600] : v === 'secondary' ? N[50] : N[100]; };
|
||
props.onMouseLeave = (e) => { e.currentTarget.style.background = v === 'primary' ? R[500] : v === 'secondary' ? '#FFFFFF' : 'transparent'; };
|
||
props.tabIndex = 0;
|
||
props.onFocus = (e) => { e.currentTarget.style.boxShadow = '0 0 0 2px #fff, 0 0 0 4px ' + R[400]; };
|
||
props.onBlur = (e) => { e.currentTarget.style.boxShadow = 'none'; };
|
||
}
|
||
props.style = st;
|
||
return h('div', props, kids.concat([p.text || 'Button']));
|
||
}
|
||
if (type === 'badge') {
|
||
Object.assign(st, { display: 'inline-flex', alignItems: 'center', background: R[100], color: R[700], borderRadius: '999px', padding: '5px 13px', fontSize: '12.5px', fontWeight: 600, letterSpacing: '.01em', whiteSpace: 'nowrap' });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([p.text || 'Badge']));
|
||
}
|
||
if (type === 'input') {
|
||
Object.assign(st, { display: 'flex', flexDirection: 'column', gap: '6px', minWidth: '220px' });
|
||
props.style = st;
|
||
const boxStyle = { border: '1px solid ' + N[300], borderRadius: 'var(--crb)', padding: '10px 12px', fontSize: '14px', color: N[400], background: '#FFFFFF', transition: trans, outline: 'none', fontFamily: 'var(--cf)' };
|
||
const label = h('span', { key: 'l', style: { fontSize: '13px', fontWeight: 600, color: N[800] } }, p.label || 'Label');
|
||
const box = mode === 'preview'
|
||
? h('input', { key: 'b', placeholder: p.placeholder || '', style: Object.assign({}, boxStyle, { color: N[800] }), onFocus: (e) => { e.currentTarget.style.boxShadow = '0 0 0 3px ' + R[100]; e.currentTarget.style.borderColor = R[500]; }, onBlur: (e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = N[300]; } })
|
||
: h('div', { key: 'b', style: boxStyle }, p.placeholder || '');
|
||
return h('div', props, kids.concat([label, box]));
|
||
}
|
||
if (type === 'avatar') {
|
||
const initials = (p.name || 'A B').split(/\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||
Object.assign(st, { width: '42px', height: '42px', borderRadius: '999px', background: R[100], color: R[700], display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px', fontWeight: 600, flex: '0 0 auto' });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([initials]));
|
||
}
|
||
if (type === 'image') {
|
||
Object.assign(st, { width: '100%', aspectRatio: (p.ratio || '16/9').replace('/', ' / '), background: 'repeating-linear-gradient(45deg,' + N[100] + ' 0 12px,' + N[50] + ' 12px 24px)', border: '1px dashed ' + N[300], borderRadius: 'var(--cr)', display: 'flex', alignItems: 'center', justifyContent: 'center' });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([h('span', { key: 's', style: { fontFamily: this.MONO, fontSize: '11px', color: N[500] } }, p.label || 'image')]));
|
||
}
|
||
if (type === 'divider') {
|
||
Object.assign(st, { alignSelf: 'stretch', height: '1px', background: N[200], border: 'none', width: '100%' });
|
||
props.style = st;
|
||
return h('div', props, kids);
|
||
}
|
||
if (type === 'custom') {
|
||
Object.assign(st, { display: 'flex', flexDirection: 'column', gap: '6px', alignItems: 'flex-start', padding: '18px', border: '1.5px dashed ' + R[300], borderRadius: 'var(--cr)', background: R[50] });
|
||
props.style = st;
|
||
return h('div', props, kids.concat([
|
||
h('span', { key: 'n', style: { fontFamily: 'var(--cf)', fontSize: '15px', fontWeight: 600, color: N[900] } }, '<' + (p.comp || 'Component') + ' />'),
|
||
h('span', { key: 'f', style: { fontFamily: this.MONO, fontSize: '10px', color: N[500] } }, 'your code · ' + (p.file || '') + (p.propNames && p.propNames.length ? ' · props: ' + p.propNames.join(', ') : ''))
|
||
]));
|
||
}
|
||
props.style = st;
|
||
return h('div', props, kids.concat([type]));
|
||
}
|
||
|
||
canvasVars(sys) {
|
||
return { '--cr': sys.radius + 'px', '--crb': Math.max(4, Math.round(sys.radius * 0.66)) + 'px', '--cf': "'" + sys.font + "',sans-serif" };
|
||
}
|
||
buildCanvasEl() {
|
||
const h = React.createElement;
|
||
if (!this.state.tree || this.state.screen !== 'editor') return null;
|
||
if (!this.worldRef) this.worldRef = React.createRef();
|
||
const sys = this.getSystem();
|
||
const cam = this.state.cam;
|
||
const frames = this.frames();
|
||
const active = this.activeFrameNode();
|
||
const kids = [];
|
||
frames.forEach(f => {
|
||
const x = f.props.x || 0, y = f.props.y || 0;
|
||
const isActive = active && active.id === f.id;
|
||
const isSel = this.state.selId === f.id;
|
||
const views = this.viewsOf(f);
|
||
let count = 0; this.walk(f, () => count++);
|
||
const tabFs = Math.max(11, Math.min(17, 12 / cam.z));
|
||
kids.push(h('div', {
|
||
key: f.id + '-tab',
|
||
onPointerDown: this.startFrameMove(f.id),
|
||
title: 'Drag to move · click to select',
|
||
style: { position: 'absolute', left: x + 'px', top: y + 'px', transform: 'translateY(-115%)', display: 'flex', alignItems: 'baseline', gap: '10px', fontFamily: this.MONO, fontSize: tabFs + 'px', color: isSel ? 'var(--iris-t)' : isActive ? 'var(--t1)' : 'var(--t3)', cursor: 'grab', userSelect: 'none', padding: '4px 6px 8px 0', whiteSpace: 'nowrap', zIndex: 2 }
|
||
},
|
||
h('span', { style: { fontWeight: isActive ? 600 : 400 } }, f.name || 'Frame'),
|
||
h('span', { style: { color: 'var(--t3)', fontSize: (tabFs * 0.85) + 'px' } }, count + ' nodes · one codebase' + (views.length > 1 ? ' · ' + views.length + ' views' : ''))
|
||
));
|
||
let vx = x;
|
||
views.forEach((k, vi) => {
|
||
const vw2 = this.viewW(f, k);
|
||
kids.push(h('div', {
|
||
key: f.id + '-vcap-' + k,
|
||
onClick: (e) => { e.stopPropagation(); this.setState({ activeFrame: f.id }); this.mutate(t => { const n = this.findNode(t, f.id); if (n) n.props.view = k; }, { label: 'View → ' + k }); },
|
||
title: 'Set ' + k + ' as the active view',
|
||
style: { position: 'absolute', left: vx + 'px', top: (y - 8) + 'px', transform: 'translateY(-100%)', fontFamily: this.MONO, fontSize: Math.max(10, Math.min(14, 10.5 / cam.z)) + 'px', color: this.activeView(f) === k ? 'var(--iris-t)' : 'var(--t3)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', display: views.length > 1 || cam.z > 0.45 ? 'block' : 'none' }
|
||
}, k + ' · ' + vw2));
|
||
kids.push(h('div', {
|
||
key: f.id + '-v-' + k,
|
||
'data-fid': vi === 0 ? f.id : undefined,
|
||
style: { position: 'absolute', left: vx + 'px', top: y + 'px', width: vw2 + 'px' }
|
||
}, this.renderNode(f, { mode: 'editor', frameW: vw2 })));
|
||
vx += vw2 + 120;
|
||
});
|
||
});
|
||
if (this.state.strokes.length && !this.state.overlaysHidden) {
|
||
kids.push(h('svg', { key: '__strokes', style: { position: 'absolute', left: 0, top: 0, width: '10px', height: '10px', overflow: 'visible', pointerEvents: 'none', zIndex: 40 } },
|
||
this.state.strokes.map((sk, si) => h('polyline', { key: si, points: sk.pts.map(p => Math.round(p.x) + ',' + Math.round(p.y)).join(' '), fill: 'none', stroke: 'var(--warn)', strokeWidth: Math.max(3, 4 / cam.z), strokeLinecap: 'round', strokeLinejoin: 'round', opacity: 0.9 }))
|
||
));
|
||
}
|
||
return h('div', {
|
||
ref: this.worldRef, 'data-world': '1',
|
||
style: Object.assign({ position: 'absolute', left: 0, top: 0, width: '10px', height: '10px', transform: 'translate(' + cam.x + 'px,' + cam.y + 'px) scale(' + cam.z + ')', transformOrigin: '0 0' }, this.canvasVars(sys))
|
||
}, kids);
|
||
}
|
||
buildPreviewEl() {
|
||
const h = React.createElement;
|
||
if (!this.state.tree || !this.state.previewOpen) return null;
|
||
const sys = this.getSystem();
|
||
const frame = this.activeFrameNode(); if (!frame) return null;
|
||
const w = this.viewW(frame, this.activeView(frame));
|
||
const vw = this.state.vw || 1440;
|
||
const fill = w >= 1000;
|
||
const z = fill ? Math.max(0.2, vw / w) : Math.min(1, (vw - 48) / w);
|
||
return h('div', { style: Object.assign({ width: w + 'px', zoom: z, margin: fill ? '0' : '28px auto 48px' }, this.canvasVars(sys)) }, this.renderNode(frame, { mode: 'preview', frameW: w }));
|
||
}
|
||
|
||
// ---------- code generation ----------
|
||
spaceCls(prefix, px) {
|
||
if (px === undefined || px === null || px === 0) return '';
|
||
if (px % 4 === 0 && px / 4 <= 24) return prefix + '-' + (px / 4);
|
||
return prefix + '-[' + px + 'px]';
|
||
}
|
||
nn() { return this.getSystem().neutral === 'cool' ? 'slate' : 'stone'; }
|
||
stackCls(n) {
|
||
const p = n.props || {}, out = ['flex'];
|
||
if ((p.dir || 'col') === 'col') out.push('flex-col');
|
||
out.push(this.spaceCls('gap', p.gap));
|
||
if (p.pad !== undefined) out.push(this.spaceCls('p', p.pad));
|
||
if (p.padX !== undefined) out.push(this.spaceCls('px', p.padX));
|
||
if (p.padY !== undefined) out.push(this.spaceCls('py', p.padY));
|
||
if (p.padBottom !== undefined) out.push(this.spaceCls('pb', p.padBottom));
|
||
if (p.align === 'center') out.push('items-center');
|
||
if (p.align === 'start') out.push('items-start');
|
||
if (p.align === 'end') out.push('items-end');
|
||
if (p.justify === 'between') out.push('justify-between');
|
||
if (p.justify === 'center') out.push('justify-center');
|
||
if (p.maxW) out.push('max-w-[' + p.maxW + 'px] mx-auto w-full');
|
||
if (p.bg === 'soft') out.push('bg-' + this.nn() + '-50');
|
||
if (p.bg === 'white') out.push('bg-white');
|
||
out.push(this.twCls(p));
|
||
return out.filter(Boolean).join(' ');
|
||
}
|
||
buildCode(which) {
|
||
const sys = this.getSystem(), nn = this.nn();
|
||
const lines = [];
|
||
const L = (t, id) => lines.push({ t, id });
|
||
if (which === 'tokens') {
|
||
L('/* tokens.css — generated from your system · Solstice */');
|
||
L(':root {');
|
||
L(' --brand-500: ' + sys.ramp[500] + ';', 'tok-brand');
|
||
L(' --brand-600: ' + sys.ramp[600] + ';', 'tok-brand');
|
||
L(' --brand-100: ' + sys.ramp[100] + ';', 'tok-brand');
|
||
L(' --neutral-50: ' + sys.n[50] + ';');
|
||
L(' --neutral-200: ' + sys.n[200] + ';');
|
||
L(' --neutral-400: ' + sys.n[400] + ';');
|
||
L(' --neutral-600: ' + sys.n[600] + ';');
|
||
L(' --neutral-900: ' + sys.n[900] + ';');
|
||
L(' --radius-md: ' + sys.radius + 'px;', 'tok-radius');
|
||
L(' --font-display: "' + sys.font + '";');
|
||
L(' --space-base: ' + sys.space + 'px;');
|
||
L('}');
|
||
return { lines, mapped: [] };
|
||
}
|
||
const tree = this.activeFrameNode();
|
||
const comp = ((tree && tree.name) || 'Screen').replace(/[^A-Za-z0-9]/g, '') || 'Screen';
|
||
const used = new Set();
|
||
if (tree) this.walk(tree, n => { if (this.MAPNAME[n.type]) used.add(n.type); });
|
||
const mapped = [];
|
||
if (used.has('button')) mapped.push('Button');
|
||
if (used.has('card')) mapped.push('Card');
|
||
if (used.has('input')) mapped.push('TextField');
|
||
if (used.has('avatar')) mapped.push('Avatar');
|
||
if (used.has('badge') && this.state.badgeMapped) mapped.push('Badge');
|
||
if (used.has('badge') && !this.state.badgeMapped) L('import { Badge } from "@weave/base-kit"; // TODO: map Badge to your library', 'imp-badge');
|
||
if (mapped.length) L('import { ' + mapped.join(', ') + ' } from "@solstice/ui";', 'imp');
|
||
const customs = [];
|
||
if (tree) this.walk(tree, n2 => { if (n2.type === 'custom' && n2.props.comp && customs.indexOf(n2.props.comp) < 0) customs.push(n2.props.comp); });
|
||
customs.forEach(cn => {
|
||
const cc = (this.state.customComps || []).find(x => x.name === cn);
|
||
L('import ' + cn + ' from "./components/' + (((cc && cc.file) || cn).replace(/\.(jsx|tsx|js)$/i, '')) + '"; // your upload', 'imp-c-' + cn);
|
||
});
|
||
L('');
|
||
L('export default function ' + comp + '() {');
|
||
L(' return (');
|
||
if (tree) this.emitNode(tree, 2, L, nn);
|
||
L(' );');
|
||
L('}');
|
||
return { lines, mapped };
|
||
}
|
||
emitNode(n, depth, L, nn) {
|
||
const ind = ' '.repeat(depth);
|
||
const p = n.props || {};
|
||
const t = n.type;
|
||
if (t === 'frame') {
|
||
L(ind + '<main className="flex flex-col bg-white">', n.id);
|
||
(n.children || []).forEach(c => this.emitNode(c, depth + 1, L, nn));
|
||
L(ind + '</main>', n.id);
|
||
return;
|
||
}
|
||
if (t === 'stack') {
|
||
const tag = n.name === 'Nav' ? 'nav' : 'div';
|
||
L(ind + '<' + tag + ' className="' + this.stackCls(n) + '">', n.id);
|
||
(n.children || []).forEach(c => this.emitNode(c, depth + 1, L, nn));
|
||
L(ind + '</' + tag + '>', n.id);
|
||
return;
|
||
}
|
||
if (t === 'card') {
|
||
const cls = [this.spaceCls('p', p.pad === undefined ? 16 : p.pad), this.spaceCls('gap', p.gap), 'flex-1'].filter(Boolean).join(' ');
|
||
L(ind + '<Card className="' + cls + '">', n.id);
|
||
(n.children || []).forEach(c => this.emitNode(c, depth + 1, L, nn));
|
||
L(ind + '</Card>', n.id);
|
||
return;
|
||
}
|
||
if (t === 'heading') {
|
||
const sz = { xl: 'text-6xl', lg: 'text-4xl', md: 'text-3xl', sm: 'text-xl' }[p.size || 'md'];
|
||
const wt = { regular: 'font-normal', semibold: 'font-semibold', bold: 'font-bold' }[p.weight || 'semibold'];
|
||
const tag = p.size === 'xl' ? 'h1' : p.size === 'lg' ? 'h2' : 'h3';
|
||
L(ind + '<' + tag + ' className="font-display ' + sz + ' ' + wt + ' tracking-tight text-' + nn + '-900' + (this.twCls(p) ? ' ' + this.twCls(p) : '') + '">' + (p.text || '') + '</' + tag + '>', n.id);
|
||
return;
|
||
}
|
||
if (t === 'text') {
|
||
const sz = { sm: 'text-sm', md: 'text-base', lg: 'text-lg' }[p.size || 'md'];
|
||
const tone = { default: 'text-' + nn + '-800', muted: 'text-' + nn + '-600', faint: 'text-' + nn + '-400' }[p.tone || 'default'];
|
||
L(ind + '<p className="' + sz + ' ' + tone + ' leading-relaxed">' + (p.text || '') + '</p>', n.id);
|
||
return;
|
||
}
|
||
if (t === 'button') { L(ind + '<Button kind="' + (p.variant || 'primary') + '" size="' + (p.size || 'md') + '"' + (this.twCls(p) ? ' className="' + this.twCls(p) + '"' : '') + '>' + (p.text || '') + '</Button>', n.id); return; }
|
||
if (t === 'badge') { L(ind + '<Badge>' + (p.text || '') + '</Badge>', n.id); return; }
|
||
if (t === 'input') { L(ind + '<TextField label="' + (p.label || '') + '" placeholder="' + (p.placeholder || '') + '" />', n.id); return; }
|
||
if (t === 'avatar') { L(ind + '<Avatar name="' + (p.name || '') + '" />', n.id); return; }
|
||
if (t === 'image') { L(ind + '<div className="aspect-video w-full rounded-[var(--radius-md)] border border-dashed border-' + nn + '-300 bg-' + nn + '-100" /> {/* image slot */}', n.id); return; }
|
||
if (t === 'divider') { L(ind + '<hr className="w-full border-' + nn + '-200" />', n.id); return; }
|
||
if (t === 'custom') { L(ind + '<' + (p.comp || 'Component') + ' />', n.id); return; }
|
||
}
|
||
// syntax highlighting
|
||
hlLine(t) {
|
||
const h = React.createElement;
|
||
if (/^\s*(\/\/|\/\*)/.test(t)) return [h('span', { key: 0, style: { color: '#6B655E', fontStyle: 'italic' } }, t)];
|
||
const re = /("[^"]*")|(\/\/[^\n]*$)|\b(import|from|export|default|function|return|const)\b|(<\/?[A-Z][\w]*)|(<\/?[a-z][\w-]*\d?)|([\w-]+)(?==)|(\{\/\*.*?\*\/\})/g;
|
||
const out = [];
|
||
let last = 0, m, k = 0;
|
||
const plain = (s) => { if (s) out.push(h('span', { key: k++, style: { color: '#B8B0A6' } }, s)); };
|
||
while ((m = re.exec(t)) !== null) {
|
||
plain(t.slice(last, m.index));
|
||
const s = m[0];
|
||
let style = { color: '#B8B0A6' };
|
||
if (m[1]) style = { color: '#D9A961' };
|
||
else if (m[2] || m[8]) style = { color: '#6B655E', fontStyle: 'italic' };
|
||
else if (m[3]) style = { color: '#8FB3FF' };
|
||
else if (m[4]) style = { color: '#F1EEEA', fontWeight: 600 };
|
||
else if (m[5]) style = { color: '#8FB8A8' };
|
||
else if (m[6]) style = { color: '#948C82' };
|
||
out.push(h('span', { key: k++, style }, s));
|
||
last = m.index + s.length;
|
||
}
|
||
plain(t.slice(last));
|
||
return out;
|
||
}
|
||
buildCodeEl() {
|
||
const h = React.createElement;
|
||
if (this.state.screen !== 'editor' || !this.state.codeOpen) return null;
|
||
const which = this.state.codeFile;
|
||
const { lines } = this.buildCode(which);
|
||
const flashFresh = Date.now() - this.state.flashStamp < 1600;
|
||
const flash = new Set(flashFresh ? this.state.flashIds : []);
|
||
const selId = this.state.selId;
|
||
return h('div', { style: { fontFamily: this.MONO, fontSize: '11.5px', lineHeight: 1.75, minWidth: 0 } },
|
||
lines.map((ln, i) => h('div', {
|
||
key: i,
|
||
onClick: ln.id && !String(ln.id).startsWith('tok') && !String(ln.id).startsWith('imp') ? () => this.setState({ selId: ln.id, rightTab: 'inspector' }) : undefined,
|
||
style: {
|
||
display: 'flex', whiteSpace: 'pre', padding: '0 14px 0 0', cursor: ln.id ? 'pointer' : 'default',
|
||
background: selId && ln.id === selId ? 'rgba(61,123,250,.09)' : 'transparent',
|
||
animation: ln.id && flash.has(ln.id) ? 'lineFlash 1.5s ease-out' : 'none'
|
||
}
|
||
},
|
||
h('span', { style: { width: '34px', flex: '0 0 auto', color: '#57504A', textAlign: 'right', paddingRight: '12px', userSelect: 'none', fontSize: '10px', lineHeight: '20px' } }, String(i + 1)),
|
||
h('span', { style: { minWidth: 0 } }, this.hlLine(ln.t))
|
||
))
|
||
);
|
||
}
|
||
|
||
// ---------- AI compose (constrained) ----------
|
||
AI_TYPES = { stack: ['dir','gap','pad','padX','padY','align','justify','maxW','bg'], card: ['gap','pad'], heading: ['text','size','weight'], text: ['text','size','tone'], button: ['text','variant','size'], badge: ['text'], input: ['label','placeholder'], avatar: ['name'], image: ['label','ratio'], divider: [] };
|
||
UNMAPPABLE = /(3[\s-]?d|globe|chart|graph|map\b|video|carousel|webgl|lottie|spline|animation|game|calendar grid|drag[\s-]?and[\s-]?drop)/i;
|
||
|
||
aiSystemPrompt() {
|
||
return 'You are the constrained composer inside Weave, a design-system-native canvas. You compose UI ONLY from this registry (JSON): ' +
|
||
JSON.stringify({ types: this.AI_TYPES, enums: { dir: ['row','col'], align: ['start','center','end'], justify: ['start','center','end','between'], bg: ['white','soft'], headingSize: ['sm','md','lg','xl'], weight: ['regular','semibold','bold'], textSize: ['sm','md','lg'], tone: ['default','muted','faint'], variant: ['primary','secondary','ghost'], ratio: ['16/9','1/1','4/3'] } }) +
|
||
' Brand: Solstice, a warm, plainspoken team-scheduling product. gap/pad must be multiples of 4 (max 96). Nest stacks for layout; cards may contain children. Keep it restrained: no more than one primary button per group, real copy, no lorem, no emoji. ' +
|
||
'Respond with ONLY minified JSON, no markdown fences. Either {"action":"compose","summary":"…","nodes":[{"type":"stack","props":{…},"children":[…]}]} or, if the request needs anything outside the registry (charts, 3D, maps, video, complex media), {"action":"refuse","missing":"<the thing>","reason":"<one short sentence>"}. Max 30 nodes.';
|
||
}
|
||
sanitizeAi(nodes, depth) {
|
||
depth = depth || 0;
|
||
if (!Array.isArray(nodes) || depth > 6) return { bad: 'structure' };
|
||
const out = [];
|
||
for (const raw of nodes) {
|
||
if (!raw || typeof raw !== 'object') continue;
|
||
const type = String(raw.type || '');
|
||
if (!(type in this.AI_TYPES)) return { bad: type || 'unknown' };
|
||
const allowed = this.AI_TYPES[type];
|
||
const props = {};
|
||
for (const k of allowed) {
|
||
let v = raw.props ? raw.props[k] : undefined;
|
||
if (v === undefined || v === null) continue;
|
||
if (k === 'gap' || k === 'pad' || k === 'padX' || k === 'padY' || k === 'maxW') { v = Math.max(0, Math.min(k === 'maxW' ? 1200 : 96, Math.round(Number(v) / 4) * 4)); if (isNaN(v)) continue; }
|
||
else v = String(v).slice(0, 220);
|
||
props[k] = v;
|
||
}
|
||
const node = { id: this.nid('ai'), type, props, children: [] };
|
||
if ((type === 'stack' || type === 'card') && Array.isArray(raw.children)) {
|
||
const sub = this.sanitizeAi(raw.children, depth + 1);
|
||
if (sub.bad) return sub;
|
||
node.children = sub.nodes;
|
||
}
|
||
out.push(node);
|
||
if (out.length > 36) break;
|
||
}
|
||
return { nodes: out };
|
||
}
|
||
submitAi() {
|
||
const txt = this.state.aiText.trim();
|
||
if (!txt || this.state.aiStatus === 'thinking') return;
|
||
const atts = this.state.pendingAtts;
|
||
const tnode = this.state.aiTarget ? this.findNode(this.state.tree, this.state.aiTarget) : null;
|
||
this.pushChat('user', txt + (tnode ? ' · scoped to ' + this.aiTagFor(tnode) : ''), { atts });
|
||
this.setState({ aiText: '', pendingAtts: [] });
|
||
const attNote = atts.length ? ' (Attached: ' + atts.map(a => a.name).join(', ') + ' — treat as visual reference.)' : '';
|
||
const um = txt.match(this.UNMAPPABLE);
|
||
if (um) return this.refuseAi(um[0]);
|
||
if (tnode) return this.submitTargetedAi(txt + attNote, tnode);
|
||
const target = this.targetStack();
|
||
if (!target) return;
|
||
this.aiFrameName = (this.frameOf(target.id) || {}).name || 'the board';
|
||
this.setState({ aiStatus: 'thinking', refusal: null, proposal: null });
|
||
const useLive = this.props.aiLive !== false && typeof window !== 'undefined' && window.claude && window.claude.complete;
|
||
const fallback = () => this.cannedAi(txt, target.id);
|
||
if (!useLive) { this.after(900, fallback); return; }
|
||
const call = window.claude.complete({ model: this.state.aiModel, system: this.aiSystemPrompt(), messages: [{ role: 'user', content: txt + attNote }], max_tokens: 3200 });
|
||
Promise.race([call, new Promise((_, rj) => setTimeout(() => rj(new Error('timeout')), 18000))])
|
||
.then(res => {
|
||
if (this.state.aiStatus !== 'thinking') return;
|
||
let s = String(res).replace(/```(json)?/g, '').trim();
|
||
const a = s.indexOf('{'), b = s.lastIndexOf('}');
|
||
if (a < 0 || b < 0) throw new Error('no json');
|
||
const j = JSON.parse(s.slice(a, b + 1));
|
||
if (j.action === 'refuse') return this.refuseAi(j.missing || 'that', j.reason);
|
||
const sub = this.sanitizeAi(j.nodes || []);
|
||
if (sub.bad) return this.refuseAi(sub.bad);
|
||
if (!sub.nodes.length) throw new Error('empty');
|
||
this.setState({ aiStatus: 'proposing', proposal: { targetId: target.id, nodes: sub.nodes, summary: j.summary || '' } });
|
||
this.pushChat('assistant', 'Composed from your system — ' + this.countNodes(sub.nodes) + ' components staged on ' + this.aiFrameName + '. The ghost is on the canvas.', { kind: 'proposal' });
|
||
})
|
||
.catch(() => { if (this.state.aiStatus === 'thinking') fallback(); });
|
||
}
|
||
aiEditSystemPrompt(node) {
|
||
const stripped = this.clone(node);
|
||
const strip = (x) => { delete x.id; delete x.name; (x.children || []).forEach(strip); return x; };
|
||
return 'You are the constrained editor inside Weave, a design-system-native canvas. Registry (JSON): ' + JSON.stringify({ types: this.AI_TYPES }) +
|
||
' You are EDITING this existing element subtree: ' + JSON.stringify(strip(stripped)) +
|
||
' Apply the designer\'s instruction to it. Keep the same element type unless the instruction demands otherwise. Brand voice: Solstice — warm, plainspoken, no hype, no emoji. gap/pad multiples of 4. ' +
|
||
'Respond with ONLY minified JSON: {"action":"edit","node":{"type":…,"props":…,"children":[…]}} using registry types/props only — or {"action":"refuse","missing":"<thing>","reason":"<short>"} if it needs something outside the registry.';
|
||
}
|
||
submitTargetedAi(instr, node) {
|
||
this.setState({ aiStatus: 'thinking', refusal: null, proposal: null, bubble: null });
|
||
const useLive = this.props.aiLive !== false && typeof window !== 'undefined' && window.claude && window.claude.complete;
|
||
const fallback = () => this.cannedEdit(instr, node);
|
||
if (!useLive) { this.after(700, fallback); return; }
|
||
const strokeNote = this.state.strokes.length ? ' (The designer scribbled ' + this.state.strokes.length + ' annotation stroke(s) on the board for emphasis.)' : '';
|
||
const call = window.claude.complete({ model: this.state.aiModel, system: this.aiEditSystemPrompt(node), messages: [{ role: 'user', content: instr + strokeNote }], max_tokens: 2200 });
|
||
Promise.race([call, new Promise((_, rj) => setTimeout(() => rj(new Error('timeout')), 16000))])
|
||
.then(res => {
|
||
if (this.state.aiStatus !== 'thinking') return;
|
||
let str = String(res).replace(/```(json)?/g, '').trim();
|
||
const a = str.indexOf('{'), b = str.lastIndexOf('}');
|
||
if (a < 0 || b < 0) throw new Error('no json');
|
||
const j = JSON.parse(str.slice(a, b + 1));
|
||
if (j.action === 'refuse') return this.refuseAi(j.missing || 'that', j.reason);
|
||
const one = j.node || (Array.isArray(j.nodes) ? j.nodes[0] : null);
|
||
const sub = this.sanitizeAi([one].filter(Boolean));
|
||
if (sub.bad) return this.refuseAi(sub.bad);
|
||
if (!sub.nodes.length) throw new Error('empty');
|
||
this.setState({ aiStatus: 'proposing', proposal: { kind: 'edit', targetId: node.id, nodes: sub.nodes, summary: 'edit' } });
|
||
this.pushChat('assistant', 'Staged an edit to this ' + this.aiTagFor(node) + ' — the ghost sits beside the original. Accept to swap it in.', { kind: 'proposal' });
|
||
})
|
||
.catch(() => { if (this.state.aiStatus === 'thinking') fallback(); });
|
||
}
|
||
cannedEdit(instr, node) {
|
||
const low = instr.toLowerCase();
|
||
const id = node.id;
|
||
const apply = (fn, msg) => { this.mutate(t => { const n2 = this.findNode(t, id); if (n2) fn(n2); }, { flash: [id] }); this.setState({ aiStatus: 'idle', aiText: '' }); this.toast(msg); this.pushChat('assistant', msg); };
|
||
if (low.includes('short')) return apply(n2 => { const w = (n2.props.text || '').split(' '); n2.props.text = w.slice(0, Math.max(2, Math.ceil(w.length * 0.6))).join(' '); }, 'Shortened — offline heuristic.');
|
||
if (low.includes('title case')) return apply(n2 => { n2.props.text = (n2.props.text || '').replace(/\b\w/g, c => c.toUpperCase()); }, 'Title-cased.');
|
||
if (low.includes('demote')) return apply(n2 => { n2.props.variant = 'secondary'; }, 'Demoted to secondary.');
|
||
if (low.includes('emphas') || low.includes('primary')) return apply(n2 => { n2.props.variant = 'primary'; }, 'Promoted to primary.');
|
||
if (low.includes('tight')) return apply(n2 => { if (n2.props.gap) n2.props.gap = Math.max(8, n2.props.gap - 4); }, 'Tightened to the next spacing step.');
|
||
if (low.includes('bold')) return apply(n2 => { n2.props.weight = 'bold'; }, 'Weight → bold.');
|
||
this.setState({ aiStatus: 'idle' });
|
||
this.toast('Offline — try a quick-action chip instead.', { glyph: '!' });
|
||
}
|
||
quickFor(n) {
|
||
const t = n.type;
|
||
const ai = (label) => ({ label, run: () => { this.setState({ aiText: label }); this.after(40, () => this.submitAi()); } });
|
||
const local = (label, fn, msg) => ({ label, run: () => { const id = n.id; this.mutate(tr => { const x = this.findNode(tr, id); if (x) fn(x); }, { flash: [id] }); if (msg) this.toast(msg); } });
|
||
if (t === 'heading') return [ai('punchier'), local('shorter', x => { const w = (x.props.text || '').split(' '); x.props.text = w.slice(0, Math.max(2, Math.ceil(w.length * 0.6))).join(' '); }), local('bolder', x => { x.props.weight = 'bold'; })];
|
||
if (t === 'text') return [ai('friendlier'), local('shorter', x => { const w = (x.props.text || '').split(' '); x.props.text = w.slice(0, Math.max(3, Math.ceil(w.length * 0.65))).join(' '); }), local('quieter', x => { x.props.tone = 'muted'; })];
|
||
if (t === 'button') return [this.colorizeAction(n), local('demote', x => { x.props.variant = 'secondary'; }), ai('rename to a verb')];
|
||
if (t === 'badge') return [this.colorizeAction(n), ai('rewrite — three words max')];
|
||
if (t === 'stack' || t === 'card' || t === 'frame') return [local('tighten spacing', x => { if (x.props.gap) x.props.gap = Math.max(8, x.props.gap - 4); }, 'Stepped gap down your scale.'), ai('balance this layout')];
|
||
if (t === 'image') return [local('square', x => { x.props.ratio = '1/1'; }), local('wide', x => { x.props.ratio = '16/9'; })];
|
||
if (t === 'input') return [ai('better label + placeholder')];
|
||
return [ai('improve this')];
|
||
}
|
||
colorizeAction(n) {
|
||
const count = this.state.quickN;
|
||
return { label: 'colorize ▾' + (count ? ' ×' + count : ''), run: () => {
|
||
const id = n.id;
|
||
this.mutate(tr => { const x = this.findNode(tr, id); if (!x) return; const order = ['primary', 'secondary', 'ghost']; x.props.variant = order[(order.indexOf(x.props.variant || 'primary') + 1) % 3]; }, { flash: [id] });
|
||
this.setState({ quickN: count + 1 });
|
||
} };
|
||
}
|
||
refuseAi(missing, reason) {
|
||
const body = reason || ('Your registry maps 10 Solstice components — nothing in it renders a ' + missing + '. Weave never falls back to generic markup.');
|
||
this.setState({ aiStatus: 'refusal', proposal: null, refusal: { missing, reason: body } });
|
||
this.pushChat('assistant', "You don't have a component for this yet. " + body, { kind: 'refusal' });
|
||
}
|
||
cannedAi(txt, targetId) {
|
||
const lower = txt.toLowerCase();
|
||
let nodes;
|
||
if (lower.includes('pricing') || lower.includes('tier') || lower.includes('plan')) nodes = this.pricingNodes();
|
||
else if (lower.includes('testimonial') || lower.includes('quote')) nodes = this.testimonialNodes();
|
||
else nodes = this.trioNodes();
|
||
this.setState({ aiStatus: 'proposing', proposal: { targetId, nodes, summary: 'offline compose' } });
|
||
this.pushChat('assistant', 'Composed from your system — ' + this.countNodes(nodes) + ' components staged on ' + ((this.frameOf(targetId) || {}).name || 'the board') + '. The ghost is on the canvas. (offline compose)', { kind: 'proposal' });
|
||
}
|
||
pn(type, props, children) { return { id: this.nid('ai'), type, props: props || {}, children: children || [] }; }
|
||
pricingNodes() {
|
||
const tier = (name, price, blurb, feats, btn, variant, popular) => this.pn('card', { pad: 24, gap: 12 }, [
|
||
popular ? this.pn('badge', { text: 'Most popular' }) : null,
|
||
this.pn('heading', { text: name, size: 'sm', weight: 'semibold' }),
|
||
this.pn('heading', { text: price, size: 'lg', weight: 'bold' }),
|
||
this.pn('text', { text: blurb, size: 'sm', tone: 'muted' }),
|
||
this.pn('divider', {}),
|
||
...feats.map(f => this.pn('text', { text: '· ' + f, size: 'sm', tone: 'muted' })),
|
||
this.pn('button', { text: btn, variant, size: 'md' })
|
||
].filter(Boolean));
|
||
return [this.pn('stack', { dir: 'col', gap: 28, padX: 40, padY: 64, align: 'center' }, [
|
||
this.pn('heading', { text: 'Simple pricing', size: 'md', weight: 'semibold' }),
|
||
this.pn('text', { text: 'Start free. Grow when the team does.', size: 'md', tone: 'muted' }),
|
||
this.pn('stack', { dir: 'row', gap: 24 }, [
|
||
tier('Starter', '$0', 'For trying Solstice solo.', ['1 calendar', '2 integrations'], 'Start free', 'secondary'),
|
||
tier('Team', '$12', 'Per member, monthly.', ['Unlimited calendars', 'Focus-hours engine'], 'Start with Team', 'primary', true),
|
||
tier('Scale', '$29', 'SSO, audit, priority support.', ['Everything in Team', 'Dedicated support'], 'Talk to us', 'secondary')
|
||
])
|
||
])];
|
||
}
|
||
testimonialNodes() {
|
||
return [this.pn('stack', { dir: 'col', gap: 0, padX: 40, padY: 48, align: 'center' }, [
|
||
this.pn('card', { pad: 24, gap: 16 }, [
|
||
this.pn('text', { text: '“Solstice gave us our mornings back — scheduling just stopped being a job.”', size: 'lg', tone: 'default' }),
|
||
this.pn('stack', { dir: 'row', gap: 12, align: 'center' }, [
|
||
this.pn('avatar', { name: 'Ana Reyes' }),
|
||
this.pn('stack', { dir: 'col', gap: 4 }, [
|
||
this.pn('text', { text: 'Ana Reyes', size: 'sm', tone: 'default' }),
|
||
this.pn('text', { text: 'Head of Ops, Fieldwork', size: 'sm', tone: 'muted' })
|
||
])
|
||
])
|
||
])
|
||
])];
|
||
}
|
||
trioNodes() {
|
||
const card = (t, b) => this.pn('card', { pad: 16, gap: 12 }, [
|
||
this.pn('heading', { text: t, size: 'sm', weight: 'semibold' }),
|
||
this.pn('text', { text: b, size: 'sm', tone: 'muted' })
|
||
]);
|
||
return [this.pn('stack', { dir: 'col', gap: 24, padX: 40, padY: 56, align: 'center' }, [
|
||
this.pn('heading', { text: 'Composed from your system', size: 'md', weight: 'semibold' }),
|
||
this.pn('text', { text: 'A starting point assembled from mapped components — refine it on the canvas.', size: 'md', tone: 'muted' }),
|
||
this.pn('stack', { dir: 'row', gap: 24 }, [
|
||
card('Made of your parts', 'Every element here maps to @solstice/ui.'),
|
||
card('Bound to tokens', 'Colors, radius and type come from your system.'),
|
||
card('Real on export', 'The code imports your components, not clones.')
|
||
])
|
||
])];
|
||
}
|
||
acceptProposal() {
|
||
const pr = this.state.proposal; if (!pr) return;
|
||
if (pr.kind === 'edit') {
|
||
const flat = [];
|
||
pr.nodes.forEach(n2 => this.walk(n2, x => flat.push(x.id)));
|
||
this.mutate(t => {
|
||
const p = this.findParent(t, pr.targetId); if (!p) return;
|
||
const i = p.children.findIndex(c => c.id === pr.targetId);
|
||
if (i >= 0) p.children.splice(i, 1, ...pr.nodes);
|
||
}, { flash: flat.slice(0, 30), select: pr.nodes[0] ? pr.nodes[0].id : null, label: 'AI edit — accepted' });
|
||
this.setState({ proposal: null, aiStatus: 'idle', aiText: '', aiTarget: pr.nodes[0] ? pr.nodes[0].id : null, bubble: null });
|
||
this.toast('Edited in your system — nothing off-registry. ⌘Z to undo.');
|
||
this.pushChat('assistant', 'Applied — still 0 unregistered elements. ⌘Z to undo.');
|
||
return;
|
||
}
|
||
const born = {};
|
||
let bi = 0;
|
||
pr.nodes.forEach(n => this.walk(n, x => { born[x.id] = Math.min(bi++, 14) * 90; }));
|
||
const flat = [];
|
||
pr.nodes.forEach(n => this.walk(n, x => flat.push(x.id)));
|
||
this.mutate(t => {
|
||
const p = this.findNode(t, pr.targetId); if (!p) return;
|
||
pr.nodes.forEach(n => p.children.push(n));
|
||
}, { flash: flat.slice(0, 40), select: pr.nodes[0] ? pr.nodes[0].id : null, label: 'AI compose — accepted' });
|
||
let count = 0;
|
||
pr.nodes.forEach(n => this.walk(n, () => count++));
|
||
this.setState({ proposal: null, aiStatus: 'idle', aiText: '', bornIds: born });
|
||
this.after(1700, () => this.setState({ bornIds: {} }));
|
||
this.toast('Composed from your system — ' + count + ' components, 0 unregistered elements.');
|
||
this.pushChat('assistant', 'Accepted — ' + count + ' components, 0 unregistered elements.');
|
||
}
|
||
discardProposal() { this.setState({ proposal: null, aiStatus: 'idle' }); this.pushChat('assistant', 'Discarded — nothing was applied.'); }
|
||
dismissRefusal() { this.setState({ refusal: null, aiStatus: 'idle' }); }
|
||
composeFromPrimitives() {
|
||
const missing = (this.state.refusal && this.state.refusal.missing) || 'media';
|
||
const target = this.targetStack(); if (!target) return;
|
||
const block = this.pn('stack', { dir: 'col', gap: 0, padX: 40, padY: 40 }, [
|
||
this.pn('card', { pad: 20, gap: 12 }, [
|
||
this.pn('image', { label: missing + ' — drop real media', ratio: '16/9' }),
|
||
this.pn('heading', { text: 'Composed from primitives', size: 'sm', weight: 'semibold' }),
|
||
this.pn('text', { text: 'A token-bound placeholder for the ' + missing + '. Save it as a pattern when it feels right.', size: 'sm', tone: 'muted' })
|
||
])
|
||
]);
|
||
block.name = 'Primitive — ' + missing;
|
||
this.mutate(t => { const p = this.findNode(t, target.id); if (p) p.children.push(block); }, { flash: [block.id], select: block.id });
|
||
this.setState({ refusal: null, aiStatus: 'idle', aiText: '' });
|
||
this.toast('Composed from your primitives — bound to your tokens.');
|
||
this.pushChat('assistant', 'Composed a primitive block — image slot, heading and copy, all bound to your tokens. Save it as a pattern when it feels right.');
|
||
}
|
||
proposalChips() {
|
||
const pr = this.state.proposal; if (!pr) return [];
|
||
const counts = {};
|
||
pr.nodes.forEach(n => this.walk(n, x => { const k = x.type.charAt(0).toUpperCase() + x.type.slice(1); counts[k] = (counts[k] || 0) + 1; }));
|
||
return Object.keys(counts).slice(0, 6).map(k => ({ label: k + ' ×' + counts[k] }));
|
||
}
|
||
|
||
// ---------- Polish (Impeccable) ----------
|
||
runPolish() {
|
||
this.setState({ rightTab: 'polish', polishStatus: 'scanning' });
|
||
this.after(1150, () => {
|
||
const issues = this.detectIssues();
|
||
this.setState({ polishStatus: issues.length ? 'issues' : 'clean', issues });
|
||
});
|
||
}
|
||
detectIssues() {
|
||
const t = this.activeFrameNode(); if (!t) return [];
|
||
const sys = this.getSystem();
|
||
const out = [];
|
||
const snippet = (n) => { const s = (n.props.text || ''); return s.length > 30 ? s.slice(0, 30) + '…' : s; };
|
||
this.walk(t, (n) => {
|
||
const p = n.props || {};
|
||
if (n.type === 'heading' && (p.size === 'xl' || p.size === 'lg') && (p.weight || 'semibold') === 'regular') {
|
||
out.push({ id: 'hier-' + n.id, sev: 'HIGH', title: 'Weak hierarchy — display heading at body weight', why: '“' + snippet(n) + '” is display-size but weight 400. Hierarchy needs size and weight contrast — size alone reads as accident, not intent.', fixLabel: 'Fix — weight → font-bold', apply: { op: 'set', id: n.id, key: 'weight', val: 'bold' } });
|
||
}
|
||
if (this.isStacky(n)) {
|
||
const primaries = (n.children || []).filter(c => c.type === 'button' && (c.props.variant || 'primary') === 'primary');
|
||
if (primaries.length >= 2) {
|
||
out.push({ id: 'cta-' + n.id, sev: 'HIGH', title: 'Two primary actions compete', why: 'One focal point per group. “' + (primaries[1].props.text || 'the second button') + '” fights “' + (primaries[0].props.text || 'the first') + '” for attention — demote it and the hierarchy snaps into place.', fixLabel: 'Fix — demote to secondary', apply: { op: 'set', id: primaries[1].id, key: 'variant', val: 'secondary' } });
|
||
}
|
||
const gap = p.gap || 0;
|
||
if (gap % 4 !== 0) {
|
||
const to = Math.round(gap / 4) * 4;
|
||
out.push({ id: 'gap-' + n.id, sev: 'MED', title: 'Off-scale spacing — gap ' + gap + 'px', why: 'Your scale is ' + sys.space + 'px steps. ' + gap + 'px sits between steps — in code it becomes gap-[' + gap + 'px], and drift like that is how systems rot.', fixLabel: 'Fix — gap → ' + to + 'px (space-' + (to / 4) + ')', apply: { op: 'set', id: n.id, key: 'gap', val: to } });
|
||
}
|
||
}
|
||
if (n.type === 'text' && (p.tone || 'default') === 'faint') {
|
||
const r = this.ratio(sys.n[400], '#FFFFFF').toFixed(1);
|
||
const r2 = this.ratio(sys.n[600], '#FFFFFF').toFixed(1);
|
||
out.push({ id: 'con-' + n.id, sev: 'HIGH', title: 'Contrast below AA — neutral-400 on white', why: snippet(n) ? '“' + snippet(n) + '” sits at ' + r + ':1 — body text needs 4.5:1. Your neutral-600 passes at ' + r2 + ':1, and it is already in your system.' : 'This text sits at ' + r + ':1; AA needs 4.5:1.', fixLabel: 'Fix — tone → neutral-600', apply: { op: 'set', id: n.id, key: 'tone', val: 'muted' } });
|
||
}
|
||
if (this.isStacky(n)) {
|
||
const cards = (n.children || []).filter(c => c.type === 'card');
|
||
if (cards.length >= 2) {
|
||
const pads = cards.map(c => c.props.pad === undefined ? 16 : c.props.pad);
|
||
if (new Set(pads).size > 1) {
|
||
out.push({ id: 'pad-' + n.id, sev: 'LOW', title: 'Uneven card padding — ' + pads.join(' / '), why: 'Siblings should read as one family. The odd card breaks the row’s optical rhythm — same padding, same rhythm.', fixLabel: 'Fix — pad → 16px (space-4)', apply: { op: 'padAll', ids: cards.map(c => c.id), val: 16 } });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
const rank = { HIGH: 0, MED: 1, LOW: 2 };
|
||
out.sort((a, b) => rank[a.sev] - rank[b.sev]);
|
||
return out;
|
||
}
|
||
applyIssue(issue) {
|
||
if (!issue || issue.fixed) return;
|
||
const a = issue.apply;
|
||
const flash = a.op === 'padAll' ? a.ids : [a.id];
|
||
this.mutate(t => {
|
||
if (a.op === 'set') { const n = this.findNode(t, a.id); if (n) n.props[a.key] = a.val; }
|
||
if (a.op === 'padAll') a.ids.forEach(id => { const n = this.findNode(t, id); if (n) n.props.pad = a.val; });
|
||
}, { flash, label: 'Polish — ' + issue.title.split(' — ')[0] });
|
||
this.setState(s => ({ issues: s.issues.map(i => i.id === issue.id ? Object.assign({}, i, { fixed: true }) : i) }));
|
||
}
|
||
fixAllIssues() {
|
||
const open = this.state.issues.filter(i => !i.fixed);
|
||
open.forEach((iss, i) => this.after(i * 320, () => this.applyIssue(this.state.issues.find(x => x.id === iss.id))));
|
||
this.after(open.length * 320 + 250, () => this.toast(open.length + ' fixes applied — all in your tokens and components.'));
|
||
}
|
||
|
||
onboardingVals() {
|
||
const s = this.state;
|
||
const sys = this.getSystem();
|
||
const stepsDef = [
|
||
{ label: 'Color tokens', note: '7 parsed' },
|
||
{ label: 'Type', note: sys.font },
|
||
{ label: 'Radius', note: sys.radius + 'px' },
|
||
{ label: 'Spacing', note: sys.space + 'px scale' },
|
||
{ label: 'Contrast check', note: '3 pairs' }
|
||
];
|
||
const parseSteps = stepsDef.map((d, i) => {
|
||
const st = s.parseIdx > i || s.parseDone ? 'done' : (s.parseIdx === i && s.parseRun ? 'run' : 'idle');
|
||
return {
|
||
label: d.label, note: st === 'done' ? d.note : '',
|
||
glyph: st === 'done' ? '✓' : st === 'run' ? '◐' : '○',
|
||
glyphStyle: { fontFamily: this.MONO, fontSize: '12px', width: '14px', color: st === 'done' ? 'var(--iris-t)' : st === 'run' ? 'var(--iris-t)' : 'var(--t3)', animation: st === 'run' ? 'blinkq .8s infinite' : 'none' },
|
||
labelStyle: { fontSize: '12.5px', color: st === 'idle' ? 'var(--t3)' : 'var(--t1)' }
|
||
};
|
||
});
|
||
const w = '#FFFFFF';
|
||
const rBrand = this.ratio(sys.brand, w), rMut = this.ratio(sys.n[600], w), rInk = this.ratio('#FFFFFF', sys.ramp[600]);
|
||
const badge = (pass, warnLabel) => ({
|
||
badge: pass ? '✓ AA' : '! ' + warnLabel,
|
||
badgeStyle: { fontFamily: this.MONO, fontSize: '10.5px', padding: '2px 7px', borderRadius: '99px', color: pass ? 'var(--iris-t)' : 'var(--warn)', background: pass ? 'var(--iris-dim)' : 'rgba(224,160,58,.12)' }
|
||
});
|
||
const contrastRows = [
|
||
Object.assign({ pair: 'brand-500 / white', ratio: rBrand.toFixed(1) + ':1' }, badge(rBrand >= 4.5, 'large only')),
|
||
Object.assign({ pair: 'neutral-600 / white', ratio: rMut.toFixed(1) + ':1' }, badge(rMut >= 4.5, 'fails')),
|
||
Object.assign({ pair: 'white / brand-600', ratio: rInk.toFixed(1) + ':1' }, badge(rInk >= 4.5, 'large only'))
|
||
];
|
||
const q = s.quick;
|
||
const liveMini = this.sysVars(this.buildSystem(q));
|
||
return {
|
||
isOnboarding: s.screen === 'onboarding',
|
||
obWelcome: s.ob === 'welcome', obPaste: s.ob === 'paste', obQuick: s.ob === 'quick', obReveal: s.ob === 'reveal',
|
||
goPaste: () => this.setOb('paste'), goQuick: () => this.setOb('quick'), goWelcome: () => this.setOb('welcome'),
|
||
skipToEditor: () => this.enterEditor(true),
|
||
pasteText: s.pasteText, onPasteText: (e) => this.setState({ pasteText: e.target.value }),
|
||
parseSteps, contrastReady: s.parseDone, contrastRows,
|
||
parseOrReveal: () => s.parseDone ? this.goReveal() : this.startParse(),
|
||
parseBtnLabel: s.parseRun ? 'Parsing…' : s.parseDone ? 'See it live →' : 'Parse tokens',
|
||
parseBtnStyle: this.primaryBtn({ opacity: s.parseRun ? 0.6 : 1, marginTop: '2px', alignSelf: 'stretch', justifyContent: 'center' }),
|
||
brandSwatches: this.BRANDS.map(hex => ({ hex, onPick: () => this.setState({ quick: Object.assign({}, q, { brand: hex }) }), style: { width: '30px', height: '30px', borderRadius: '99px', background: hex, boxShadow: q.brand === hex ? '0 0 0 2px var(--bg), 0 0 0 4px var(--iris)' : 'inset 0 0 0 1px rgba(0,0,0,.25)' } })),
|
||
neutralSegs: [this.seg('Warm stone', q.neutral === 'warm', () => this.setState({ quick: Object.assign({}, q, { neutral: 'warm' }) })), this.seg('Cool slate', q.neutral === 'cool', () => this.setState({ quick: Object.assign({}, q, { neutral: 'cool' }) }))],
|
||
radiusVal: q.radius, radiusLabel: q.radius + 'px',
|
||
onRadius: (e) => this.setState({ quick: Object.assign({}, q, { radius: +e.target.value }) }),
|
||
fontCards: this.FONTS.map(f => ({ name: f, onPick: () => this.setState({ quick: Object.assign({}, q, { font: f }) }), cardStyle: { flex: 1, display: 'flex', flexDirection: 'column', gap: '6px', alignItems: 'flex-start', padding: '10px 12px', background: q.font === f ? 'var(--raised)' : 'transparent', border: q.font === f ? '1px solid var(--iris)' : '1px solid var(--line)', borderRadius: '9px' }, prevStyle: { fontFamily: "'" + f + "',sans-serif", fontSize: '22px', fontWeight: 600, color: 'var(--t1)', lineHeight: 1.1 } })),
|
||
spaceSegs: [this.seg('4px', q.space === 4, () => this.setState({ quick: Object.assign({}, q, { space: 4 }) })), this.seg('8px', q.space === 8, () => this.setState({ quick: Object.assign({}, q, { space: 8 }) }))],
|
||
bringToLife: () => { this.setState({ system: this.buildSystem(q) }); this.goReveal(); },
|
||
lifeBtnStyle: this.primaryBtn({ marginTop: '6px' }),
|
||
kitVarsMini: Object.assign({ width: '320px', maxWidth: '100%', flex: '0 0 auto', alignSelf: 'flex-start', background: '#FFFFFF', border: '1px solid #ECE8E2', borderRadius: '16px', padding: '20px', display: 'flex', flexDirection: 'column', gap: '14px' }, liveMini),
|
||
kitVars: Object.assign({ display: 'grid', gridTemplateColumns: (s.vw || 1440) < 760 ? 'repeat(2,1fr)' : 'repeat(4,1fr)', gap: '14px', marginTop: '28px' }, s.revealLive ? this.sysVars(sys) : this.grayVars()),
|
||
revealPre: !s.revealLive, revealLive: s.revealLive, revealDone: s.revealDone,
|
||
revealStat: s.revealLive ? '9 components · re-themed to your tokens · contrast checked · 0 hard stops' : 'the Weave base kit — semantic, accessible, waiting for your tokens',
|
||
revealFontNote: s.revealLive ? sys.font.toLowerCase() : 'unthemed',
|
||
openEditor: () => this.enterEditor(true)
|
||
};
|
||
}
|
||
|
||
editorVals() {
|
||
const s = this.state;
|
||
const sys = this.getSystem();
|
||
if (!this.deskRef) this.deskRef = React.createRef();
|
||
if (!this.aiRef) this.aiRef = React.createRef();
|
||
const sel = s.tree ? this.findNode(s.tree, s.selId) : null;
|
||
const GLYPH = { frame: '▣', stack: '▤', card: '▢', heading: 'Aa', text: '¶', button: '▭', badge: '◦', input: '⌷', avatar: '◉', image: '▧', divider: '—', custom: '◈' };
|
||
|
||
const layerRows = [];
|
||
const pushLayer = (n, d) => {
|
||
const kidsArr = (n.children || []);
|
||
const isBranch = this.isStacky(n) && kidsArr.length > 0;
|
||
const isCollapsed = !s.expandedL[n.id];
|
||
layerRows.push({
|
||
glyph: GLYPH[n.type] || '·', name: this.nodeLabel(n),
|
||
caret: isBranch ? (isCollapsed ? '▸' : '▾') : '',
|
||
caretStyle: { width: '11px', flex: '0 0 auto', fontSize: '8px', color: 'var(--t3)', textAlign: 'center', cursor: isBranch ? 'pointer' : 'default' },
|
||
onCaret: (e) => { e.stopPropagation(); if (!isBranch) return; const c = Object.assign({}, this.state.expandedL || {}); if (c[n.id]) delete c[n.id]; else c[n.id] = true; this.setState({ expandedL: c }); },
|
||
onClick: () => this.selectNode(n.id),
|
||
onOver: () => { if (s.hovId !== n.id) this.setState({ hovId: n.id }); },
|
||
style: { display: 'flex', alignItems: 'center', gap: '5px', width: '100%', padding: '5px 6px', paddingLeft: (6 + d * 12) + 'px', borderRadius: '6px', fontSize: '12px', fontWeight: s.selId === n.id ? 600 : 400, color: s.selId === n.id ? 'var(--t1)' : 'var(--t2)', background: s.selId === n.id ? 'var(--iris-dim)' : s.hovId === n.id ? 'var(--hoverbg)' : 'transparent' }
|
||
});
|
||
if (n.type === 'frame') {
|
||
const views = this.viewsOf(n);
|
||
this.VIEW_ORDER.forEach(k => {
|
||
const on = views.indexOf(k) >= 0;
|
||
const isAv = this.activeView(n) === k && on;
|
||
layerRows.push({
|
||
glyph: k === 'desktop' ? '⧉' : k === 'tablet' ? '▯' : '▮',
|
||
name: k + ' · ' + this.viewW(n, k) + (isAv ? ' — active' : ''),
|
||
caret: on ? '◉' : '◌',
|
||
caretStyle: { width: '13px', flex: '0 0 auto', fontSize: '9px', color: on ? 'var(--iris-t)' : 'var(--t3)', textAlign: 'center', cursor: 'pointer' },
|
||
onCaret: (e) => {
|
||
e.stopPropagation();
|
||
let next = on ? views.filter(v => v !== k) : views.concat(k);
|
||
if (!next.length) next = [k];
|
||
this.setFrameViews(n.id, next, next.indexOf(this.activeView(n)) >= 0 ? this.activeView(n) : next[0]);
|
||
},
|
||
onClick: () => { this.setState({ activeFrame: n.id }); const cur = this.viewsOf(n); this.setFrameViews(n.id, cur.indexOf(k) >= 0 ? cur : cur.concat(k), k); },
|
||
onOver: () => {},
|
||
style: { display: 'flex', alignItems: 'center', gap: '5px', width: '100%', padding: '3px 6px', paddingLeft: (18 + d * 12) + 'px', borderRadius: '6px', fontSize: '11px', fontFamily: this.MONO, color: on ? (isAv ? 'var(--t1)' : 'var(--t2)') : 'var(--t3)', background: 'transparent', opacity: on ? 1 : 0.62 }
|
||
});
|
||
});
|
||
}
|
||
if (!isCollapsed) kidsArr.forEach(c => pushLayer(c, d + 1));
|
||
};
|
||
if (s.tree) this.frames().forEach(f => pushLayer(f, 0));
|
||
|
||
const narrowRail = (s.vw || 1440) < 1100;
|
||
const chipBase = { fontFamily: this.MONO, fontSize: '9.5px', padding: '2px 7px', borderRadius: '99px', flex: '0 0 auto', maxWidth: narrowRail ? '54px' : '86px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||
const libItems = this.LIB.map(c => {
|
||
let chip, chipStyle, chipTitle, onChip;
|
||
if (c.prim) { chip = 'html'; chipStyle = Object.assign({}, chipBase, { color: 'var(--t3)', border: '1px solid var(--line)', background: 'transparent' }); chipTitle = 'Layout primitive — emitted as semantic HTML'; onChip = () => this.toast(c.name + ' is a layout primitive — emitted as semantic HTML, no import needed.'); }
|
||
else if (c.unmappable && !s.badgeMapped) { chip = narrowRail ? '!map' : 'unmapped'; chipStyle = Object.assign({}, chipBase, { color: 'var(--warn)', border: '1px solid rgba(224,160,58,.4)', background: 'rgba(224,160,58,.08)' }); chipTitle = 'Map Badge → @solstice/ui/Badge'; onChip = () => this.mapBadge(); }
|
||
else { chip = narrowRail ? '→ui' : '@solstice/ui'; chipStyle = Object.assign({}, chipBase, { color: 'var(--t3)', border: '1px solid var(--line)', background: 'var(--chip)' }); chipTitle = '→ @solstice/ui/' + this.MAPNAME[c.type]; onChip = () => this.toast(c.name + ' → @solstice/ui/' + this.MAPNAME[c.type] + ' — export emits your real import.'); }
|
||
return { name: c.name, glyph: c.glyph, chip, chipStyle, chipTitle, onChip, onIso: () => this.setState({ isolate: c.type }), onDrag: this.onLibDrag(c.type), onAdd: () => this.addFromLibrary(c.type) };
|
||
});
|
||
const patternRows = s.patterns.map((p, i) => ({ name: p.name, onAdd: () => { const node = this.reId(this.clone(p.node)); const target = this.targetStack(); if (target) this.insertNode(node, target.id); } }));
|
||
|
||
const rampKeys = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||
const swRing = (on) => on ? '0 0 0 2px var(--raised), 0 0 0 4px var(--iris)' : 'inset 0 0 0 1px rgba(0,0,0,.25)';
|
||
|
||
const selStacky = this.isStacky(sel);
|
||
const selTexty = sel && ['heading', 'text', 'button', 'badge'].includes(sel.type);
|
||
const mapInfo = () => {
|
||
if (!sel) return {};
|
||
if (sel.type === 'badge' && !s.badgeMapped) return { text: '! unmapped — map → @solstice/ui/Badge', style: { color: 'var(--warn)', border: '1px solid rgba(224,160,58,.4)', background: 'rgba(224,160,58,.08)' }, title: 'Click to map', on: () => this.mapBadge() };
|
||
if (this.MAPNAME[sel.type]) return { text: '→ @solstice/ui/' + this.MAPNAME[sel.type], style: { color: 'var(--iris-t)', background: 'var(--iris-dim)', border: '1px solid transparent' }, title: 'Mapped to your library — export emits this import', on: () => this.toast(this.nodeLabel(sel) + ' exports as your real component' + (sel.type === 'button' ? ' · prop variant→kind' : '') + '.') };
|
||
return { text: '→ semantic HTML', style: { color: 'var(--t3)', background: 'var(--chip)', border: '1px solid var(--line)' }, title: 'Layout primitive', on: () => {} };
|
||
};
|
||
const mi = mapInfo();
|
||
const tokenChips = [];
|
||
if (sel) {
|
||
const p = sel.props || {};
|
||
const add = (prop, tok) => tokenChips.push({ prop, tok });
|
||
if (sel.type === 'button') { if ((p.variant || 'primary') === 'primary') add('background', 'brand-500'); else add('border', 'neutral-300'); add('radius', 'radius-ctl'); add('font', 'font-display'); }
|
||
if (sel.type === 'heading') { add('color', 'neutral-900'); add('font', 'font-display'); }
|
||
if (sel.type === 'text') add('color', (p.tone || 'default') === 'muted' ? 'neutral-600' : p.tone === 'faint' ? 'neutral-400' : 'neutral-800');
|
||
if (sel.type === 'badge') { add('background', 'brand-100'); add('color', 'brand-700'); }
|
||
if (sel.type === 'card') { add('border', 'neutral-200'); add('radius', 'radius-md'); }
|
||
if (sel.type === 'stack' || sel.type === 'frame') { const g = p.gap || 0; add('gap', g % 4 === 0 ? 'space-' + (g / 4) : g + 'px — off scale'); }
|
||
if (sel.type === 'input') { add('border', 'neutral-300'); add('radius', 'radius-ctl'); }
|
||
if (sel.type === 'avatar') { add('background', 'brand-100'); }
|
||
if (sel.type === 'image') { add('border', 'neutral-300'); }
|
||
if (sel.type === 'divider') { add('color', 'neutral-200'); }
|
||
}
|
||
const toneRatio = sel && sel.type === 'text' ? this.ratio({ default: sys.n[800], muted: sys.n[600], faint: sys.n[400] }[sel.props.tone || 'default'], '#FFFFFF') : 0;
|
||
|
||
const issuesOpen = s.issues.filter(i => !i.fixed).length;
|
||
const vw = s.vw || 1440;
|
||
const codeOverlay = vw < 1340;
|
||
const railOverlay = vw < 1000;
|
||
const phone = vw < 760;
|
||
const tiny = vw < 560;
|
||
const effL = Math.min(s.leftW, Math.round(vw * 0.3));
|
||
const effR = Math.min(s.rightW, Math.round(vw * 0.34));
|
||
const effC = Math.min(s.codeW, Math.round(vw * 0.4));
|
||
const afNode = this.activeFrameNode();
|
||
const activeName = ((afNode && afNode.name) || 'Screen').replace(/[^A-Za-z0-9]/g, '') || 'Screen';
|
||
const qtgt = s.aiTarget && s.tree ? this.findNode(s.tree, s.aiTarget) : null;
|
||
const chatOpen = s.chatOpen === undefined ? vw >= 1180 : s.chatOpen;
|
||
const effChat = Math.min(s.chatW || 300, Math.round(vw * 0.5));
|
||
const railBase = { flex: '0 0 auto', background: 'var(--panel)', display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative' };
|
||
const codeBase = { borderLeft: '1px solid var(--line)', background: '#161311', display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0, position: 'relative' };
|
||
|
||
return {
|
||
isEditor: s.screen === 'editor',
|
||
railOverlay,
|
||
leftRailVisible: railOverlay ? s.leftOpen : !s.leftCollapsed,
|
||
rightRailVisible: railOverlay ? s.rightOpen : !s.rightCollapsed,
|
||
leftTabTitle: ({ layers: 'LAYERS', library: 'LIBRARY — YOUR SYSTEM', tokens: 'TOKENS', history: 'HISTORY' })[s.leftTab] || '',
|
||
rightTabTitle: s.rightTab === 'polish' ? '✧ POLISH — CRAFT PASS' : 'INSPECTOR',
|
||
activityBarStyle: { position: 'fixed', left: 0, top: '42px', bottom: 0, width: '50px', zIndex: 30, background: 'var(--panel)', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', padding: '8px 0', overflowY: 'auto', overflowX: 'hidden' },
|
||
activityItems: (() => {
|
||
const leftShown = railOverlay ? s.leftOpen : !s.leftCollapsed;
|
||
const rightShown = railOverlay ? s.rightOpen : !s.rightCollapsed;
|
||
const fit = () => this.after(70, () => this.fitView());
|
||
const openL = (k) => () => {
|
||
if (leftShown && s.leftTab === k) this.setState(railOverlay ? { leftOpen: false } : { leftCollapsed: true });
|
||
else this.setState(railOverlay ? { leftOpen: true, rightOpen: phone ? false : s.rightOpen, chatOpen: false, leftTab: k } : { leftCollapsed: false, leftTab: k });
|
||
fit();
|
||
};
|
||
const openR = (k) => () => {
|
||
if (rightShown && s.rightTab === k) this.setState(railOverlay ? { rightOpen: false } : { rightCollapsed: true });
|
||
else {
|
||
this.setState(railOverlay ? { rightOpen: true, leftOpen: phone ? false : s.leftOpen, chatOpen: false, rightTab: k } : { rightCollapsed: false, rightTab: k });
|
||
if (k === 'polish' && s.polishStatus === 'idle') this.runPolish();
|
||
}
|
||
fit();
|
||
};
|
||
const mk = (glyph, label, active, onClick, title) => ({
|
||
isBtn: true, isDivider: false, glyph, label, title: title || label, onClick,
|
||
style: { width: '42px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', padding: '7px 0 6px', borderRadius: '9px', flex: '0 0 auto', fontSize: '13px', color: active ? 'var(--iris-t)' : 'var(--t3)', background: active ? 'var(--iris-dim)' : 'transparent', position: 'relative' }
|
||
});
|
||
const div = () => ({ isDivider: true, isBtn: false, glyph: '', label: '', title: '', onClick: () => {}, style: {} });
|
||
const items = [
|
||
mk(this.icon('chat'), 'Chat', chatOpen, () => { this.setState(railOverlay ? { chatOpen: !chatOpen, leftOpen: false, rightOpen: false } : { chatOpen: !chatOpen }); fit(); }, 'Chat — compose with AI'),
|
||
div(),
|
||
mk(this.icon('layers'), 'Layers', leftShown && s.leftTab === 'layers', openL('layers')),
|
||
mk(this.icon('library'), 'Library', leftShown && s.leftTab === 'library', openL('library')),
|
||
mk(this.icon('tokens'), 'Tokens', leftShown && s.leftTab === 'tokens', openL('tokens')),
|
||
mk(this.icon('history'), 'History', leftShown && s.leftTab === 'history', openL('history')),
|
||
div(),
|
||
mk(this.icon('inspect'), 'Inspect', rightShown && s.rightTab === 'inspector', openR('inspector'), 'Inspector — selection details'),
|
||
mk(this.icon('polish'), 'Polish', rightShown && s.rightTab === 'polish', openR('polish'), 'Polish — craft critique'),
|
||
div(),
|
||
mk(this.icon('code'), 'Code', !!s.codeOpen, () => { this.setState({ codeOpen: !s.codeOpen }); fit(); }, 'Code — live TSX')
|
||
];
|
||
return items;
|
||
})(),
|
||
collapseLeft: () => { if (railOverlay) this.setState({ leftOpen: false }); else this.setState({ leftCollapsed: true }); this.after(70, () => this.fitView()); },
|
||
expandLeft: () => { this.setState({ leftCollapsed: false }); this.after(70, () => this.fitView()); },
|
||
collapseRight: () => { if (railOverlay) this.setState({ rightOpen: false }); else this.setState({ rightCollapsed: true }); this.after(70, () => this.fitView()); },
|
||
expandRight: () => { this.setState({ rightCollapsed: false }); this.after(70, () => this.fitView()); },
|
||
collapseCode: () => { this.setState({ codeOpen: false }); this.after(70, () => this.fitView()); },
|
||
expandCode: () => { this.setState({ codeOpen: true }); this.after(70, () => this.fitView()); },
|
||
bodyRowStyle: { flex: 1, display: 'flex', minHeight: 0, position: 'relative', overflow: 'hidden', paddingLeft: (50 + ((!railOverlay && chatOpen) ? effChat : 0)) + 'px', transition: 'padding-left .2s' },
|
||
toggleLeftRail: () => this.setState({ leftOpen: !s.leftOpen, rightOpen: phone && !s.leftOpen ? false : s.rightOpen }),
|
||
toggleRightRail: () => this.setState({ rightOpen: !s.rightOpen, leftOpen: phone && !s.rightOpen ? false : s.leftOpen }),
|
||
leftToggleStyle: Object.assign({}, this.chromeBtn(s.leftOpen), { fontSize: '13px', padding: '6px 9px' }),
|
||
rightToggleStyle: Object.assign({}, this.chromeBtn(s.rightOpen), { fontSize: '13px', padding: '6px 9px' }),
|
||
projLabel: phone ? 'Solstice' : 'Solstice — Marketing site',
|
||
previewLabel: phone ? '' : 'Preview',
|
||
polishLabel: phone ? '' : 'Polish',
|
||
codeLabel: phone ? '' : 'Code',
|
||
exportLabel: tiny ? '' : 'Export',
|
||
showSave: !phone, showBreakpoint: vw >= 1150, showAvatar: !tiny,
|
||
showRailHandles: !railOverlay,
|
||
startLeftResize: this.startResize('left'),
|
||
startRightResize: this.startResize('right'),
|
||
startCodeResize: this.startResize('code'),
|
||
aiWrapStyle: { position: 'absolute', left: '50%', transform: 'translateX(-50%)', bottom: phone ? '60px' : '16px', width: phone ? 'calc(100% - 20px)' : 'min(660px, calc(100% - 150px))', zIndex: 25, display: 'flex', flexDirection: 'column', gap: '8px', pointerEvents: 'none' },
|
||
leftRailStyle: railOverlay
|
||
? Object.assign({}, railBase, { position: 'fixed', left: '50px', top: '42px', bottom: 0, zIndex: 27, width: 'min(' + effL + 'px, calc(84vw - 50px))', borderRight: '1px solid var(--line2)', boxShadow: '20px 0 60px rgba(0,0,0,.45)' })
|
||
: Object.assign({}, railBase, { width: effL + 'px', borderRight: '1px solid var(--line)' }),
|
||
rightRailStyle: railOverlay
|
||
? Object.assign({}, railBase, { position: 'absolute', right: 0, top: 0, bottom: 0, zIndex: 27, width: 'min(' + effR + 'px, 84vw)', borderLeft: '1px solid var(--line2)', boxShadow: '-20px 0 60px rgba(0,0,0,.45)' })
|
||
: Object.assign({}, railBase, { width: effR + 'px', borderLeft: '1px solid var(--line)' }),
|
||
codePanelStyle: codeOverlay
|
||
? Object.assign({}, codeBase, { position: 'absolute', right: 0, top: 0, bottom: 0, width: 'min(' + effC + 'px, 82vw)', zIndex: 28, boxShadow: '-20px 0 60px rgba(0,0,0,.45)' })
|
||
: Object.assign({}, codeBase, { width: effC + 'px', flex: '0 0 auto' }),
|
||
onProj: () => this.setState(st => ({ projMenu: !st.projMenu })),
|
||
projMenuOpen: s.projMenu,
|
||
projMenuItems: [
|
||
{ glyph: '▣', label: 'All projects', hint: 'home', run: () => this.go('home') },
|
||
{ glyph: '+', label: 'New project', hint: 'onboarding', run: () => this.setState({ screen: 'onboarding', ob: 'welcome', projMenu: false }) },
|
||
{ divider: true },
|
||
{ glyph: '⇪', label: 'Export…', hint: 'react + zip', run: () => this.setState({ exportOpen: true }) },
|
||
{ glyph: '⌘', label: 'Copy Home.tsx', hint: 'clipboard', run: () => this.copyCodeText('home') },
|
||
{ glyph: '⟳', label: 'Import tokens', hint: 'system update', run: () => { this.setState({ sysStage: 'paste' }); this.go('sysupdate'); } },
|
||
{ glyph: '◈', label: 'Upload component', hint: '.tsx / .jsx', run: () => { const el = this.compFileRef && this.compFileRef.current; if (el) el.click(); } },
|
||
{ divider: true },
|
||
{ glyph: '⚙', label: 'Settings', hint: 'mappings · plan', run: () => this.go('settings') },
|
||
{ glyph: '◉', label: 'Profile', hint: 'account', run: () => this.go('profile') },
|
||
{ glyph: '✦', label: 'Onboarding demo', hint: 'the reveal', run: () => this.setState({ screen: 'onboarding', ob: 'welcome', projMenu: false }) }
|
||
].map(it => it.divider
|
||
? { isDivider: true, isItem: false, glyph: '', label: '', hint: '', onRun: () => {} }
|
||
: { isDivider: false, isItem: true, glyph: it.glyph, label: it.label, hint: it.hint || '', onRun: () => { this.setState({ projMenu: false }); this.after(30, it.run); } }),
|
||
goProfile: () => this.go('profile'),
|
||
bpSegs: (() => {
|
||
const views = afNode ? this.viewsOf(afNode) : ['desktop'];
|
||
const av = afNode ? this.activeView(afNode) : 'desktop';
|
||
const allOn = views.length === 3;
|
||
const segs = [['Desktop', 'desktop'], ['Tablet', 'tablet'], ['Mobile', 'mobile']].map(([l, k]) => {
|
||
const active = !allOn && views.length === 1 && av === k;
|
||
const shown = views.indexOf(k) >= 0;
|
||
return { label: l, title: l + ' — same tree, ' + this.viewW(afNode || { props: {} }, k) + 'px view', onClick: () => this.setBreakpoint(k), style: { fontFamily: this.MONO, fontSize: '10.5px', padding: '3px 10px', borderRadius: '5px', letterSpacing: '.02em', color: active ? 'var(--t1)' : shown ? 'var(--t2)' : 'var(--t3)', background: active ? 'var(--raised)' : 'transparent', border: active ? '1px solid var(--line2)' : '1px solid transparent' } };
|
||
});
|
||
segs.push({ label: '⧉ All', title: 'Show all three variants side by side — one codebase', onClick: () => this.setBreakpoint('all'), style: { fontFamily: this.MONO, fontSize: '10.5px', padding: '3px 10px', borderRadius: '5px', letterSpacing: '.02em', color: allOn ? 'var(--iris-t)' : 'var(--t3)', background: allOn ? 'var(--iris-dim)' : 'transparent', border: allOn ? '1px solid transparent' : '1px solid transparent' } });
|
||
return segs;
|
||
})(),
|
||
saveLabel: s.saveState === 'saving' ? 'saving…' : '✓ saved',
|
||
onBreakpoint: () => this.toast('Mobile is a near-term follow-up — the layout model is already responsive-ready.'),
|
||
liveText: tiny ? '' : (s.syncing ? 'Syncing…' : 'Live'),
|
||
openPreview: () => this.setState({ previewOpen: true }),
|
||
togglePolish: () => { if (s.rightTab === 'polish' && (!railOverlay || s.rightOpen)) this.setState({ rightTab: 'inspector' }); else { this.setState({ rightTab: 'polish', rightOpen: true }); if (s.polishStatus === 'idle') this.runPolish(); } },
|
||
polishBtnStyle: this.chromeBtn(s.rightTab === 'polish'),
|
||
toggleCode: () => this.setState({ codeOpen: !s.codeOpen }),
|
||
codeBtnStyle: this.chromeBtn(s.codeOpen),
|
||
openExport: () => this.setState({ exportOpen: true }),
|
||
toggleTheme: () => this.toggleTheme(),
|
||
|
||
leftTabs: (() => {
|
||
const tight = effL < 226;
|
||
return [
|
||
this.tabBtn('Layers', '▤', s.leftTab === 'layers', () => this.setState({ leftTab: 'layers' }), tight),
|
||
this.tabBtn('Library', '◇', s.leftTab === 'library', () => this.setState({ leftTab: 'library' }), tight),
|
||
this.tabBtn('Tokens', '●', s.leftTab === 'tokens', () => this.setState({ leftTab: 'tokens' }), tight),
|
||
this.tabBtn('History', '⟲', s.leftTab === 'history', () => this.setState({ leftTab: 'history' }), true)
|
||
];
|
||
})(),
|
||
tabLayers: s.leftTab === 'layers', tabLibrary: s.leftTab === 'library', tabTokens: s.leftTab === 'tokens',
|
||
layerRows, libItems,
|
||
hasPatterns: s.patterns.length > 0, noPatterns: s.patterns.length === 0, patternRows,
|
||
|
||
openBrandPop: () => this.setState({ brandPop: !s.brandPop, hexDraft: sys.brand }),
|
||
brandPopOpen: s.brandPop,
|
||
closeBrandPop: () => this.setState({ brandPop: false }),
|
||
brandSwatchStyle: { width: '16px', height: '16px', borderRadius: '99px', background: sys.brand, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.25)', flex: '0 0 auto' },
|
||
brandHex: sys.brand.toUpperCase(),
|
||
brandOptions: this.BRANDS.map(hex => ({ hex, onPick: () => this.applyBrand(hex), style: { width: '26px', height: '26px', borderRadius: '99px', background: hex, boxShadow: swRing(sys.brand === hex) } })),
|
||
hexDraft: s.hexDraft, onHexDraft: (e) => this.setState({ hexDraft: e.target.value }),
|
||
applyHex: () => this.applyBrand(s.hexDraft.trim()),
|
||
brandRamp: rampKeys.map(k => ({ t: 'brand-' + k + ' · ' + sys.ramp[k], style: { flex: 1, height: '22px', background: sys.ramp[k], transition: 'background .4s' } })),
|
||
neutralRamp: rampKeys.map(k => ({ t: 'neutral-' + k + ' · ' + sys.n[k], style: { flex: 1, height: '22px', background: sys.n[k] } })),
|
||
whereUsed: 'brand-500 · used by ' + this.countBrandUse() + ' components',
|
||
fontPrevStyle: { fontFamily: "'" + sys.font + "',sans-serif", fontSize: '20px', fontWeight: 600, lineHeight: 1 },
|
||
fontName: sys.font,
|
||
radMinus: () => this.setRadius(-2), radPlus: () => this.setRadius(2),
|
||
radiusChip: sys.radius + 'px', spaceChip: sys.space + 'px',
|
||
onFontPick: (e) => { this.setState({ system: this.buildSystem(Object.assign({}, sys, { font: e.target.value })), pulseStamp: Date.now(), syncing: true }); this.after(450, () => this.setState({ syncing: false })); },
|
||
neutralTokenSegs: [['warm', 'Warm stone'], ['cool', 'Cool slate']].map(([v, l]) => this.seg(l, sys.neutral === v, () => { this.setState({ system: this.buildSystem(Object.assign({}, sys, { neutral: v })), pulseStamp: Date.now() }); })),
|
||
spaceTokenSegs: [4, 8].map(v => this.seg(v + 'px base', sys.space === v, () => { this.setState({ system: this.buildSystem(Object.assign({}, sys, { space: v })) }); this.toast('Spacing steppers now move in ' + v + 'px steps.'); })),
|
||
|
||
deskRef: this.deskRef,
|
||
onDeskOver: () => { if (s.hovId) this.setState({ hovId: null }); },
|
||
onDeskClick: () => { if (this.suppressDeskClick) return; this.setState({ selId: null, brandPop: false }); },
|
||
onDeskPan: (e) => this.onDeskPanDown(e),
|
||
deskStyle: { flex: 1, overflow: 'hidden', position: 'relative', cursor: s.panning ? 'grabbing' : s.editMode === 'pick' ? 'crosshair' : 'default', background: s.deskLight ? '#E7E3DC' : 'var(--bg)', backgroundImage: 'radial-gradient(' + (s.deskLight ? '#D3CCC1' : 'var(--line)') + ' 1px, transparent 1px)', backgroundSize: '26px 26px', backgroundPosition: s.cam.x + 'px ' + s.cam.y + 'px', overscrollBehavior: 'contain', touchAction: 'none' },
|
||
canvasEl: this.buildCanvasEl(),
|
||
zoomOut: () => this.zoomStep(0.8),
|
||
zoomIn: () => this.zoomStep(1.25),
|
||
zoomReset: () => this.setZoom(1),
|
||
zoomPct: Math.round(s.cam.z * 100) + '%',
|
||
fitView: () => this.fitView(),
|
||
addFrame: () => this.addFrame(),
|
||
showPanHint: vw >= 1320,
|
||
showBoardChips: vw >= 780,
|
||
toggleDesk: () => this.setState({ deskLight: !s.deskLight }),
|
||
deskLabel: s.deskLight ? 'board · paper' : 'board · deep',
|
||
togglePick: () => this.setState({ editMode: s.editMode === 'pick' ? null : 'pick', bubble: null }),
|
||
toggleDraw: () => this.setState({ editMode: s.editMode === 'draw' ? null : 'draw', bubble: null }),
|
||
toggleOverlays: () => this.setState({ overlaysHidden: !s.overlaysHidden }),
|
||
micStub: () => this.toast('Voice input is stubbed in this prototype — type it instead.', { glyph: '!' }),
|
||
pickBtnStyle: this.modeBtn(s.editMode === 'pick'),
|
||
drawBtnStyle: this.modeBtn(s.editMode === 'draw'),
|
||
overlaysBtnStyle: this.modeBtn(s.overlaysHidden),
|
||
pickBtnLbl: vw < 940 ? '◎' : '◎ Pick',
|
||
drawBtnLbl: vw < 940 ? '✎' : '✎ Draw',
|
||
voiceBtnLbl: vw < 940 ? '◌' : '◌ voice',
|
||
overlaysLabel: vw < 940 ? (s.overlaysHidden ? '◌' : '◉') : (s.overlaysHidden ? '◌ overlays' : '◉ overlays'),
|
||
micBtnStyle: this.modeBtn(false),
|
||
drawMode: s.editMode === 'draw',
|
||
hasStrokes: s.strokes.length > 0,
|
||
clearStrokes: () => this.setState({ strokes: [] }),
|
||
strokeStart: (e) => this.strokeStart(e),
|
||
bubbleOpen: !!(s.bubble && qtgt),
|
||
bubbleStyle: { position: 'absolute', left: (s.bubble ? s.bubble.x : 0) + 'px', top: (s.bubble ? s.bubble.y : 0) + 'px', zIndex: 26, display: 'flex', alignItems: 'center', gap: '7px', width: '310px', maxWidth: 'calc(100% - 20px)', background: 'var(--raised)', border: '1px solid var(--line2)', borderRadius: '10px', padding: '8px 10px', boxShadow: '0 16px 48px rgba(0,0,0,.5)', animation: 'popIn .18s both' },
|
||
bubbleTag: this.aiTagFor(qtgt),
|
||
bubbleText: s.bubbleText,
|
||
onBubbleText: (e) => this.setState({ bubbleText: e.target.value }),
|
||
onBubbleKey: (e) => { if (e.key === 'Enter') { e.preventDefault(); this.submitBubble(); } },
|
||
submitBubble: () => this.submitBubble(),
|
||
closeBubble: () => this.setState({ bubble: null }),
|
||
hasAiTarget: !!qtgt,
|
||
clearAiTarget: () => this.setState({ aiTarget: null, bubble: null, quickN: 0 }),
|
||
aiTargetChipStyle: { display: 'flex', alignItems: 'center', gap: '5px', flex: '0 0 auto', maxWidth: '190px', overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: this.MONO, fontSize: '10.5px', color: 'var(--warn)', background: 'rgba(224,160,58,.08)', border: '1px solid rgba(224,160,58,.45)', borderRadius: '6px', padding: '3px 8px' },
|
||
aiTargetLabel: qtgt ? this.aiTagFor(qtgt) + ' · ' + (() => { const l = this.nodeLabel(qtgt); return l.length > 14 ? l.slice(0, 13).replace(/\s+\S*$/, '') + '…' : l; })() : '',
|
||
aiPlaceholder: qtgt ? 'refine further — scoped to this ' + this.aiTagFor(qtgt) + '…' : 'Describe a section — composed only from your system…',
|
||
aiIdleQuick: s.aiStatus === 'idle' && !!qtgt,
|
||
aiIdleNoTarget: s.aiStatus === 'idle' && !qtgt,
|
||
quickChips: qtgt ? this.quickFor(qtgt).map(a => ({ label: a.label, onPick: () => a.run() })) : [],
|
||
chatHeaderShow: true,
|
||
chatThreadShow: true,
|
||
clearChat: () => this.setState({ chat: [], proposal: null, refusal: null, aiStatus: 'idle' }),
|
||
chatRef: (this.chatRef = this.chatRef || React.createRef()),
|
||
chatMsgs: s.chat.map(m => {
|
||
const user = m.role === 'user';
|
||
return {
|
||
roleLabel: user ? '' : '✦ weave',
|
||
roleStyle: user ? { display: 'none' } : { fontFamily: this.MONO, fontSize: '9px', letterSpacing: '.06em', color: 'var(--iris-t)' },
|
||
text: m.text,
|
||
rowStyle: { display: 'flex', justifyContent: user ? 'flex-end' : 'flex-start' },
|
||
bubbleStyle: { maxWidth: '86%', display: 'flex', flexDirection: 'column', gap: '6px', padding: '8px 11px', borderRadius: user ? '12px 12px 4px 12px' : '12px 12px 12px 4px', background: user ? 'var(--iris-dim)' : 'var(--chip)', border: user ? '1px solid rgba(61,123,250,.3)' : '1px solid var(--line)' },
|
||
textStyle: { fontSize: '12.5px', lineHeight: 1.55, color: 'var(--t1)', whiteSpace: 'pre-wrap' },
|
||
hasAtts: (m.atts || []).length > 0,
|
||
atts: (m.atts || []).map(a => ({ name: a.name })),
|
||
isProposal: m.kind === 'proposal' && !!s.proposal && m.id === this.lastProposalMsgId,
|
||
isRefusal: m.kind === 'refusal' && !!s.refusal && m.id === this.lastRefusalMsgId
|
||
};
|
||
}),
|
||
composerStyle: { padding: '9px 13px', display: 'flex', flexDirection: 'column', gap: '8px', borderTop: '1px solid var(--line)', flex: '0 0 auto' },
|
||
sendGlyph: s.aiStatus === 'thinking' ? '◐' : '↑',
|
||
sendBtnStyle: { width: '26px', height: '26px', borderRadius: '99px', background: s.aiText.trim() && s.aiStatus !== 'thinking' ? 'var(--iris)' : 'var(--chip)', color: s.aiText.trim() && s.aiStatus !== 'thinking' ? '#fff' : 'var(--t3)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px', fontWeight: 700, flex: '0 0 auto', animation: s.aiStatus === 'thinking' ? 'blinkq 1s infinite' : 'none' },
|
||
aiModel: s.aiModel,
|
||
onModel: (e) => this.setState({ aiModel: e.target.value }),
|
||
pickAttach: () => { const el = this.fileRef && this.fileRef.current; if (el) el.click(); },
|
||
fileRef: (this.fileRef = this.fileRef || React.createRef()),
|
||
onFiles: (e) => this.onFilesPicked(e),
|
||
pendingAttChips: s.pendingAtts.map((a, i) => ({ name: a.name, onRemove: () => this.setState(st => ({ pendingAtts: st.pendingAtts.filter((x, j) => j !== i) })) })),
|
||
customRows: (s.customComps || []).map(cc => ({
|
||
name: cc.name, file: cc.file,
|
||
onIso: () => this.setState({ isolate: cc.name }),
|
||
onAdd: () => { const target = this.targetStack(); if (!target) return; this.insertNode({ id: this.nid('cu'), type: 'custom', name: cc.name, props: { comp: cc.name, file: cc.file, propNames: cc.props }, children: [] }, target.id); }
|
||
})),
|
||
pickCompFile: () => { const el = this.compFileRef && this.compFileRef.current; if (el) el.click(); },
|
||
compFileRef: (this.compFileRef = this.compFileRef || React.createRef()),
|
||
onCompFile: (e) => this.onCompFilePicked(e),
|
||
isoOpen: !!s.isolate,
|
||
isoName: s.isolate ? (s.isolate.charAt(0).toUpperCase() + s.isolate.slice(1)) : '',
|
||
isoMap: (() => { const iso = s.isolate; if (!iso) return ''; const cc = (s.customComps || []).find(c => c.name === iso); if (cc) return 'your code · ' + cc.file; const lib = this.LIB.find(l => l.type === iso); if (!lib) return ''; if (lib.prim) return '→ semantic HTML'; if (lib.unmappable && !s.badgeMapped) return 'unmapped — Weave fallback'; return '→ @solstice/ui/' + this.MAPNAME[iso]; })(),
|
||
exitIso: () => this.setState({ isolate: null }),
|
||
isoEl: this.buildIsoEl(),
|
||
codeEditing: !!s.codeEditing,
|
||
codeReadonly: !s.codeEditing,
|
||
codeDraft: s.codeDraft,
|
||
onCodeDraft: (e) => this.setState({ codeDraft: e.target.value }),
|
||
codeHlRef: (this.codeHlRef = this.codeHlRef || React.createRef()),
|
||
onCodeScroll: (e) => { const hl = this.codeHlRef && this.codeHlRef.current; if (hl) { hl.scrollTop = e.target.scrollTop; hl.scrollLeft = e.target.scrollLeft; } },
|
||
codeDraftEl: s.codeEditing ? React.createElement('div', null, String(s.codeDraft || '').split('\n').map((ln, i) => React.createElement('div', { key: i, style: { whiteSpace: 'pre', minHeight: '19.8px' } }, this.hlLine(ln)))) : null,
|
||
toggleCodeEdit: () => this.toggleCodeEdit(),
|
||
codeEditLabel: s.codeEditing ? 'cancel' : 'edit',
|
||
applyCodeEdit: () => this.applyCodeEdit(),
|
||
tabHistory: s.leftTab === 'history',
|
||
historyEmpty: s.history.length === 0,
|
||
historyRows: s.history.slice().reverse().slice(0, 40).map((en, i) => {
|
||
const idx = s.history.length - 1 - i;
|
||
const secs = Math.max(1, Math.round((Date.now() - (en.t || Date.now())) / 1000));
|
||
const time = secs < 60 ? secs + 's' : Math.round(secs / 60) + 'm';
|
||
const dim = s.histCursor !== null && s.histCursor !== undefined && idx >= s.histCursor;
|
||
const isCursor = idx === s.histCursor;
|
||
return {
|
||
label: en.label || 'Edit', time: time + ' ago', step: '#' + (idx + 1),
|
||
onJump: () => this.jumpToHistory(idx),
|
||
rowStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 8px', borderRadius: '7px', width: '100%', opacity: dim && !isCursor ? 0.35 : 1, background: isCursor ? 'var(--iris-dim)' : 'transparent', transition: 'opacity .25s' }
|
||
};
|
||
}),
|
||
chatPanelVisible: chatOpen,
|
||
chatPanelStyle: { position: 'fixed', left: '50px', top: '42px', bottom: 0, zIndex: railOverlay ? 27 : 12, width: 'min(' + effChat + 'px, 88vw)', display: 'flex', flexDirection: 'column', pointerEvents: 'auto', boxShadow: railOverlay ? '22px 0 44px rgba(0,0,0,.25)' : 'none' },
|
||
startChatResize: this.startResize('chat'),
|
||
toggleChatPanel: () => { this.setState({ chatOpen: !chatOpen }); this.after(70, () => this.fitView()); },
|
||
chatBtnStyle: this.chromeBtn(chatOpen),
|
||
chatLabel: phone ? '' : 'Chat',
|
||
chatEmpty: s.chat.length === 0 && s.aiStatus !== 'thinking',
|
||
zoomBarStyle: { position: 'absolute', left: (chatOpen && railOverlay ? effChat + 12 : 12) + 'px', bottom: '14px', display: 'flex', gap: '6px', zIndex: 20, alignItems: 'center', flexWrap: 'wrap', transition: 'left .2s' },
|
||
pillWrapStyle: { position: 'absolute', left: 'calc(50% + ' + (chatOpen && railOverlay ? Math.round(effChat / 2) : 0) + 'px)', transform: 'translateX(-50%)', bottom: '80px', zIndex: 25, display: s.isolate ? 'none' : 'flex', justifyContent: 'center', pointerEvents: 'none', maxWidth: 'calc(100% - 24px)' },
|
||
|
||
aiRefusal: s.aiStatus === 'refusal',
|
||
refusalBody: s.refusal ? s.refusal.reason : '',
|
||
composePrimitives: () => this.composeFromPrimitives(),
|
||
dismissRefusal: () => this.dismissRefusal(),
|
||
aiProposing: s.aiStatus === 'proposing',
|
||
propChips: this.proposalChips(),
|
||
discardProposal: () => this.discardProposal(),
|
||
acceptProposal: () => this.acceptProposal(),
|
||
aiRef: this.aiRef,
|
||
aiText: s.aiText,
|
||
onAiText: (e) => this.setState({ aiText: e.target.value }),
|
||
onAiKey: (e) => { if (e.key === 'Enter') { e.preventDefault(); if (s.proposal) this.acceptProposal(); else this.submitAi(); } },
|
||
aiThinking: s.aiStatus === 'thinking',
|
||
aiIdle: s.aiStatus === 'idle',
|
||
submitAi: () => this.submitAi(),
|
||
aiChips: [
|
||
{ label: 'pricing — three tiers', q: 'A pricing section with three tiers' },
|
||
{ label: 'a testimonial', q: 'Add a customer testimonial section' },
|
||
{ label: 'a 3D globe hero', q: 'A hero with a spinning 3D globe' }
|
||
].map(c => ({ label: c.label, onPick: () => { this.setState({ aiText: c.q }); this.after(60, () => this.submitAi()); } })),
|
||
|
||
rightTabs: [
|
||
this.tabBtn('Inspector', '◨', s.rightTab === 'inspector', () => this.setState({ rightTab: 'inspector' }), false),
|
||
this.tabBtn('Polish', '✧', s.rightTab === 'polish', () => { this.setState({ rightTab: 'polish' }); if (s.polishStatus === 'idle') this.runPolish(); }, false)
|
||
],
|
||
rightInspector: s.rightTab === 'inspector', rightPolish: s.rightTab === 'polish',
|
||
noSel: !sel, hasSel: !!sel,
|
||
selGlyph: sel ? (GLYPH[sel.type] || '·') : '·',
|
||
selName: sel ? this.nodeLabel(sel) : '',
|
||
selType: sel ? sel.type + (selStacky && sel.props.dir ? ' · ' + sel.props.dir : '') : '',
|
||
onSelMap: mi.on || (() => {}), selMapText: mi.text || '', selMapTitle: mi.title || '',
|
||
selMapStyle: Object.assign({ display: 'flex', alignItems: 'center', gap: '6px', fontFamily: this.MONO, fontSize: '10.5px', padding: '6px 9px', borderRadius: '7px', width: '100%' }, mi.style || {}),
|
||
selIsFrame: sel ? sel.type === 'frame' : false,
|
||
frameNameVal: sel && sel.type === 'frame' ? (sel.name || '') : '',
|
||
onFrameName: (e) => this.setSelName(e.target.value),
|
||
frameWVal: sel && sel.type === 'frame' ? (sel.props.w || 1280) + 'px' : '',
|
||
frameWMinus: () => this.stepSel('w', -80, 640, 1920),
|
||
frameWPlus: () => this.stepSel('w', 80, 640, 1920),
|
||
selIsTexty: !!selTexty,
|
||
selTextVal: selTexty ? (sel.props.text || '') : '',
|
||
onSelText: (e) => this.setSelProp('text', e.target.value, true),
|
||
selIsButton: sel ? sel.type === 'button' : false,
|
||
variantSegs: ['primary', 'secondary', 'ghost'].map(v => this.seg(v === 'primary' ? 'Primary' : v === 'secondary' ? 'Second' : 'Ghost', sel && sel.props.variant === v, () => this.setSelProp('variant', v))),
|
||
selIsHeading: sel ? sel.type === 'heading' : false,
|
||
sizeSegs: ['sm', 'md', 'lg', 'xl'].map(v => this.seg(v, sel && (sel.props.size || 'md') === v, () => this.setSelProp('size', v))),
|
||
weightSegs: [['regular', '400'], ['semibold', '600'], ['bold', '700']].map(([v, l]) => this.seg(l, sel && (sel.props.weight || 'semibold') === v, () => this.setSelProp('weight', v))),
|
||
selIsText: sel ? sel.type === 'text' : false,
|
||
toneSegs: ['default', 'muted', 'faint'].map(v => this.seg(v.charAt(0).toUpperCase() + v.slice(1), sel && (sel.props.tone || 'default') === v, () => this.setSelProp('tone', v))),
|
||
toneNote: sel && sel.type === 'text' ? 'on white — ' + toneRatio.toFixed(1) + ':1 ' + (toneRatio >= 4.5 ? '· AA ✓' : '· fails AA') : '',
|
||
selIsStacky: !!selStacky,
|
||
dirSegs: [['row', 'Row'], ['col', 'Column']].map(([v, l]) => this.seg(l, sel && (sel.props.dir || 'col') === v, () => this.setSelProp('dir', v))),
|
||
selGap: sel && selStacky ? (sel.props.gap || 0) + 'px' : '0px',
|
||
gapMinus: () => this.stepSel('gap', -sys.space, 0, 96), gapPlus: () => this.stepSel('gap', sys.space, 0, 96),
|
||
selPad: (() => { if (!sel || !selStacky) return '0px'; const p = sel.props; if (p.pad !== undefined) return p.pad + 'px'; if (p.padX !== undefined || p.padY !== undefined) return (p.padY || 0) + '·' + (p.padX || 0); return '0px'; })(),
|
||
padMinus: () => this.stepPad(-sys.space), padPlus: () => this.stepPad(sys.space),
|
||
twOpen: !!s.twOpen,
|
||
toggleTwOpen: () => this.setState({ twOpen: !s.twOpen }),
|
||
twCaret: s.twOpen ? '▾' : '▸',
|
||
twSummary: (() => { const tw = sel && sel.props.tw; if (!tw) return ''; const n2 = Object.keys(tw).filter(k => tw[k]).length; return n2 ? n2 + ' set' : ''; })(),
|
||
twRows: (() => {
|
||
const tw = (sel && sel.props.tw) || {};
|
||
const cats = [
|
||
['w', 'width', ['w-full', 'w-1/2', 'w-1/3', 'w-2/3', 'w-64', 'w-96']],
|
||
['h', 'height', ['h-auto', 'h-24', 'h-48', 'h-64', 'h-96']],
|
||
['maxw', 'max-w', ['max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-2xl']],
|
||
['shadow', 'shadow', ['shadow-sm', 'shadow-md', 'shadow-lg']],
|
||
['opacity', 'opacity', ['opacity-75', 'opacity-50']]
|
||
];
|
||
return cats.map(([k, cat, opts]) => ({
|
||
cat,
|
||
val: tw[k] || '',
|
||
onPick: (e) => this.setTw(k, e.target.value),
|
||
opts: [{ v: '', l: '—' }].concat(opts.map(o => ({ v: o, l: o })))
|
||
}));
|
||
})(),
|
||
twCustom: (sel && sel.props.tw && sel.props.tw.custom) || '',
|
||
onTwCustom: (e) => { const id = s.selId; const v = e.target.value; if (!id) return; const t = this.clone(s.tree); const n = this.findNode(t, id); if (!n) return; n.props.tw = Object.assign({}, n.props.tw || {}, { custom: v }); this.setState({ tree: t, flashIds: [id], flashStamp: Date.now() }); },
|
||
tokenChips: tokenChips.slice(0, 4),
|
||
duplicateSel: () => this.duplicateSel(),
|
||
canSavePattern: !!(sel && (sel.type === 'stack' || sel.type === 'card')),
|
||
savePattern: () => this.savePattern(),
|
||
deleteSel: () => this.deleteSel(),
|
||
|
||
polishIdle: s.polishStatus === 'idle',
|
||
runPolish: () => this.runPolish(),
|
||
runPolishStyle: this.primaryBtn(),
|
||
polishScanning: s.polishStatus === 'scanning',
|
||
polishHasIssues: s.polishStatus === 'issues',
|
||
polishCount: issuesOpen + ' of ' + s.issues.length + ' open · ranked',
|
||
fixAll: () => this.fixAllIssues(),
|
||
polishIssues: s.issues.map(issue => ({
|
||
sev: issue.sev,
|
||
sevStyle: { fontFamily: this.MONO, fontSize: '9px', padding: '2px 6px', borderRadius: '99px', letterSpacing: '.05em', flex: '0 0 auto', color: issue.fixed ? 'var(--t3)' : issue.sev === 'HIGH' ? 'var(--err)' : issue.sev === 'MED' ? 'var(--warn)' : 'var(--t3)', background: issue.fixed ? 'var(--chip)' : issue.sev === 'HIGH' ? 'rgba(229,72,77,.1)' : issue.sev === 'MED' ? 'rgba(224,160,58,.1)' : 'var(--chip)' },
|
||
title: issue.title,
|
||
titleStyle: { fontSize: '12.5px', fontWeight: 600, lineHeight: 1.35, textDecoration: issue.fixed ? 'line-through' : 'none', color: issue.fixed ? 'var(--t3)' : 'var(--t1)' },
|
||
why: issue.why,
|
||
fixLabel: issue.fixed ? '✓ Fixed — in your tokens' : issue.fixLabel,
|
||
fixStyle: issue.fixed ? { color: 'var(--iris-t)', fontFamily: this.MONO, fontSize: '10.5px', padding: '2px 0', cursor: 'default' } : { border: '1px solid var(--line2)', color: 'var(--t1)', padding: '5px 9px', borderRadius: '7px', fontSize: '11.5px', fontWeight: 500, alignSelf: 'flex-start' },
|
||
onFix: () => this.applyIssue(this.state.issues.find(x => x.id === issue.id))
|
||
})),
|
||
polishClean: s.polishStatus === 'clean',
|
||
|
||
codeOpen: s.codeOpen && s.screen === 'editor',
|
||
codeFileSegs: [['home', activeName + '.tsx'], ['tokens', 'tokens.css']].map(([k, l]) => ({ label: l, onClick: () => this.setState({ codeFile: k }), style: { fontFamily: this.MONO, fontSize: '10.5px', padding: '4px 9px', borderRadius: '6px', color: s.codeFile === k ? '#F1EEEA' : '#7A7268', background: s.codeFile === k ? '#221D18' : 'transparent', border: s.codeFile === k ? '1px solid #2B2724' : '1px solid transparent' } })),
|
||
syncText: s.syncing ? 'syncing…' : 'in sync',
|
||
copyCode: () => this.copyCodeText(s.codeFile),
|
||
codeEl: this.buildCodeEl()
|
||
};
|
||
}
|
||
overlayVals() {
|
||
const s = this.state;
|
||
let usedBadge = false;
|
||
if (s.tree) this.walk(s.tree, n => { if (n.type === 'badge') usedBadge = true; });
|
||
const mapped = [];
|
||
if (s.tree) {
|
||
const used = new Set();
|
||
this.walk(s.tree, n => used.add(n.type));
|
||
if (used.has('button')) mapped.push('Button');
|
||
if (used.has('card')) mapped.push('Card');
|
||
if (used.has('input')) mapped.push('TextField');
|
||
if (used.has('avatar')) mapped.push('Avatar');
|
||
if (used.has('badge') && s.badgeMapped) mapped.push('Badge');
|
||
}
|
||
const treeRows = [
|
||
{ d: 0, name: 'solstice-marketing/', note: '' },
|
||
{ d: 1, name: 'package.json', note: '' },
|
||
{ d: 1, name: 'tailwind.config.ts', note: 'your tokens' },
|
||
{ d: 1, name: 'src/', note: '' },
|
||
{ d: 2, name: 'tokens.css', note: 'generated' },
|
||
{ d: 2, name: 'lib/', note: '' },
|
||
{ d: 3, name: 'solstice-ui.ts', note: '→ @solstice/ui' },
|
||
{ d: 2, name: 'app/', note: '' },
|
||
{ d: 3, name: '__ACTIVE__', note: 'this screen', hi: true }
|
||
];
|
||
const af = this.activeFrameNode();
|
||
const activeName = ((af && af.name) || 'Screen').replace(/[^A-Za-z0-9]/g, '') || 'Screen';
|
||
treeRows[treeRows.length - 1].name = activeName + '.tsx';
|
||
const cmds = this.cmdList();
|
||
return {
|
||
showPreviewNote: (s.vw || 1440) >= 620,
|
||
exportTitle: 'Export — ' + ((af && af.name) || 'Screen'),
|
||
exportFileName: activeName + '.tsx',
|
||
previewOpen: s.previewOpen,
|
||
closePreview: () => this.setState({ previewOpen: false }),
|
||
previewEl: this.buildPreviewEl(),
|
||
exportOpen: s.exportOpen,
|
||
closeExport: () => this.setState({ exportOpen: false }),
|
||
eatClick: (e) => e.stopPropagation(),
|
||
exportHasUnmapped: usedBadge && !s.badgeMapped,
|
||
mapFromExport: () => this.mapBadge(),
|
||
exportTreeRows: treeRows.map(r => ({
|
||
name: r.name, note: r.note || '',
|
||
style: { display: 'flex', alignItems: 'center', gap: '6px', fontFamily: this.MONO, fontSize: '11.5px', padding: '2.5px 6px', paddingLeft: (6 + r.d * 14) + 'px', color: r.hi ? 'var(--iris-t)' : 'var(--t2)', background: r.hi ? 'var(--iris-dim)' : 'transparent', borderRadius: '5px' }
|
||
})),
|
||
exportImportsLine: mapped.length ? 'imports { ' + mapped.join(', ') + ' } from @solstice/ui' : 'imports your mapped components',
|
||
copyPage: () => this.copyCodeText('home'),
|
||
downloadZip: () => this.toast('The runnable .zip is stubbed in this prototype — Copy Home.tsx to inspect the real output.', { glyph: '!' }),
|
||
cmdkOpen: s.cmdk,
|
||
closeCmdk: () => this.setState({ cmdk: false }),
|
||
cmdQuery: s.cmdQuery,
|
||
onCmdQuery: (e) => this.setState({ cmdQuery: e.target.value, cmdSel: 0 }),
|
||
onCmdKey: (e) => {
|
||
const list = this.cmdList();
|
||
if (e.key === 'Enter') { e.preventDefault(); const c = list[Math.min(s.cmdSel, list.length - 1)]; if (c) this.runCmd(c.run); }
|
||
else if (e.key === 'ArrowDown') { e.preventDefault(); this.setState({ cmdSel: Math.min(s.cmdSel + 1, list.length - 1) }); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState({ cmdSel: Math.max(s.cmdSel - 1, 0) }); }
|
||
},
|
||
cmdRows: cmds.map((c, i) => ({
|
||
label: c.label, hint: c.hint || '',
|
||
onRun: () => this.runCmd(c.run),
|
||
style: { display: 'flex', alignItems: 'center', gap: '8px', width: '100%', padding: '9px 10px', borderRadius: '8px', fontSize: '13px', color: 'var(--t1)', background: i === s.cmdSel ? 'var(--hoverbg)' : 'transparent' }
|
||
})),
|
||
toastRows: s.toasts.map(t => ({
|
||
glyph: t.glyph,
|
||
glyphStyle: { color: t.glyph === '!' ? 'var(--warn)' : 'var(--iris-t)', fontSize: '12px', fontWeight: 700, flex: '0 0 auto' },
|
||
text: t.text,
|
||
hasAct: !!t.act, actLabel: t.act || '',
|
||
onAct: () => { if (t.onAct) t.onAct(); this.closeToast(t.id); },
|
||
onClose: () => this.closeToast(t.id)
|
||
}))
|
||
};
|
||
}
|
||
|
||
screensVals() {
|
||
const s = this.state;
|
||
const sys = this.getSystem();
|
||
const q = (s.homeQuery || '').toLowerCase();
|
||
const projects = [
|
||
{ name: 'Solstice — Marketing site', meta: (this.frames().length || 2) + ' frames · edited 2h ago', chip: 'Solstice', live: true },
|
||
{ name: 'Solstice — App shell', meta: '6 frames · edited 3d ago', chip: 'Solstice', live: false },
|
||
{ name: 'Fieldwork — Docs', meta: '4 frames · edited 2w ago', chip: 'placeholder', live: false }
|
||
].filter(p => !q || p.name.toLowerCase().includes(q));
|
||
const thumbBlock = (bg, w, h2, ml, mt, r) => ({ position: 'absolute', left: ml, top: mt, width: w, height: h2, background: bg, borderRadius: r || '4px' });
|
||
const projectCards = projects.map(p => ({
|
||
name: p.name, meta: p.meta,
|
||
chip: p.chip,
|
||
chipStyle: { fontFamily: this.MONO, fontSize: '9.5px', padding: '2px 8px', borderRadius: '99px', flex: '0 0 auto', color: p.live ? 'var(--iris-t)' : 'var(--t3)', background: p.live ? 'var(--iris-dim)' : 'var(--chip)', border: p.live ? '1px solid transparent' : '1px solid var(--line)' },
|
||
onOpen: p.live ? () => this.enterEditor(false) : () => this.toast('Placeholder project — open "Solstice — Marketing site" to explore the editor.', { glyph: '!' }),
|
||
cardStyle: { background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: '14px', overflow: 'hidden', display: 'flex', flexDirection: 'column', alignItems: 'stretch', textAlign: 'left', transition: 'border-color .15s, transform .15s', cursor: 'pointer' },
|
||
thumbStyle: { position: 'relative', height: '140px', background: p.live ? sys.n[50] : 'var(--chip)', borderBottom: '1px solid var(--line)', overflow: 'hidden' },
|
||
thumbA: thumbBlock(p.live ? sys.ramp[500] : 'var(--line2)', '34%', '10px', '8%', '14%', '99px'),
|
||
thumbB: thumbBlock(p.live ? sys.n[200] : 'var(--line)', '60%', '8px', '8%', '34%'),
|
||
thumbC: thumbBlock(p.live ? sys.ramp[100] : 'var(--line)', '84%', '46px', '8%', '52%', '8px')
|
||
}));
|
||
const rampKeys = [100, 300, 500, 700, 900];
|
||
const mapRows = this.LIB.map(c => {
|
||
const unmapped = c.unmappable && !s.badgeMapped;
|
||
const chipBase = { fontFamily: this.MONO, fontSize: '9.5px', padding: '3px 9px', borderRadius: '99px', flex: '0 0 auto' };
|
||
return {
|
||
glyph: c.glyph, name: c.name,
|
||
path: c.prim ? 'semantic HTML — no import' : unmapped ? '@weave/base-kit (fallback)' : '@solstice/ui/' + this.MAPNAME[c.type] + (c.type === 'button' ? ' · variant→kind' : ''),
|
||
chip: c.prim ? 'built-in' : unmapped ? 'map now' : 'mapped',
|
||
chipStyle: Object.assign(chipBase, c.prim ? { color: 'var(--t3)', border: '1px solid var(--line)' } : unmapped ? { color: 'var(--warn)', border: '1px solid rgba(224,160,58,.4)', background: 'rgba(224,160,58,.08)', cursor: 'pointer' } : { color: 'var(--iris-t)', background: 'var(--iris-dim)', border: '1px solid transparent' }),
|
||
onChip: unmapped ? () => this.mapBadge() : () => {}
|
||
};
|
||
});
|
||
const craft = s.craft || 'impeccable';
|
||
const craftCards = [
|
||
{ k: 'weave', name: 'Weave default', desc: 'Spacing, hierarchy and contrast checks tuned to the base kit.', tag: 'built-in' },
|
||
{ k: 'impeccable', name: 'Impeccable', desc: 'The anti-slop standard — type contrast, 4px scale, one focal point, every state designed.', tag: 'recommended' },
|
||
{ k: 'custom', name: 'Custom', desc: 'Bring your own ruleset — a DESIGN.md your Polish pass enforces.', tag: 'upload' }
|
||
].map(c => ({
|
||
name: c.name, desc: c.desc, tag: c.tag,
|
||
tagStyle: { fontFamily: this.MONO, fontSize: '9px', letterSpacing: '.05em', color: craft === c.k ? 'var(--iris-t)' : 'var(--t3)', marginTop: '2px' },
|
||
onPick: () => { if (c.k === 'custom') this.toast('Custom rulesets are stubbed in this prototype.', { glyph: '!' }); else { this.setState({ craft: c.k }); this.toast('Polish now critiques against ' + c.name + '.'); } },
|
||
style: { flex: '1 1 200px', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '6px', padding: '13px 14px', background: craft === c.k ? 'var(--raised)' : 'var(--panel)', border: craft === c.k ? '1px solid var(--iris)' : '1px solid var(--line)', borderRadius: '12px', textAlign: 'left' }
|
||
}));
|
||
const prefChip = (on) => ({ fontFamily: this.MONO, fontSize: '9.5px', padding: '3px 9px', borderRadius: '99px', flex: '0 0 auto', color: on ? 'var(--iris-t)' : 'var(--t3)', background: on ? 'var(--iris-dim)' : 'var(--chip)', border: on ? '1px solid transparent' : '1px solid var(--line)' });
|
||
const diffSw = (hex) => hex && hex.startsWith('#') ? { width: '13px', height: '13px', borderRadius: '4px', background: hex, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.25)', flex: '0 0 auto' } : { width: '0px', flex: '0 0 auto' };
|
||
const tagS = (c, bg) => ({ fontFamily: this.MONO, fontSize: '8.5px', letterSpacing: '.06em', padding: '2px 7px', borderRadius: '99px', color: c, background: bg, flex: '0 0 auto', width: '64px', textAlign: 'center' });
|
||
const diffRows = [
|
||
{ tag: 'CHANGED', tagStyle: tagS('var(--warn)', 'rgba(224,160,58,.1)'), tok: 'brand-600', from: sys.ramp[600], to: '#B23A18', fromSw: diffSw(sys.ramp[600]), toSw: diffSw('#B23A18') },
|
||
{ tag: 'CHANGED', tagStyle: tagS('var(--warn)', 'rgba(224,160,58,.1)'), tok: 'radius-md', from: sys.radius + 'px', to: '10px', fromSw: diffSw(''), toSw: diffSw('') },
|
||
{ tag: 'ADDED', tagStyle: tagS('var(--iris-t)', 'var(--iris-dim)'), tok: 'space-7', from: '—', to: '28px', fromSw: diffSw(''), toSw: diffSw('') },
|
||
{ tag: 'REMOVED', tagStyle: tagS('var(--err)', 'rgba(229,72,77,.1)'), tok: 'shadow-soft', from: '0 1px 2px …', to: '—', fromSw: diffSw(''), toSw: diffSw('') }
|
||
];
|
||
let brandUse = 0;
|
||
if (s.tree) this.walk(s.tree, n => { if (['button', 'badge', 'avatar', 'card', 'input'].includes(n.type)) brandUse++; });
|
||
return {
|
||
isSignin: s.screen === 'signin', isHome: s.screen === 'home', isSettings: s.screen === 'settings', isProfile: s.screen === 'profile', isSysupdate: s.screen === 'sysupdate',
|
||
goBack: () => this.goBack(),
|
||
signinEmail: s.signinEmail,
|
||
onSigninEmail: (e) => this.setState({ signinEmail: e.target.value }),
|
||
onSigninKey: (e) => { if (e.key === 'Enter') this.go('home'); },
|
||
signinGo: () => this.go('home'),
|
||
signinBtnStyle: this.primaryBtn({ justifyContent: 'center' }),
|
||
ssoStub: () => this.toast('SSO is stubbed in this prototype — Continue with email.', { glyph: '!' }),
|
||
homeQuery: s.homeQuery,
|
||
onHomeQuery: (e) => this.setState({ homeQuery: e.target.value }),
|
||
newProject: () => this.setState({ screen: 'onboarding', ob: 'welcome' }),
|
||
goOnboardingHome: () => this.setState({ screen: 'onboarding', ob: 'welcome' }),
|
||
homeMeta: '1 live · 2 placeholders',
|
||
projectCards,
|
||
homeRamp: rampKeys.map(k => ({ style: { flex: 1, height: '20px', background: sys.ramp[k] } })),
|
||
homeSysMeta: '10 components · ' + sys.font + ' · ' + sys.radius + 'px radius',
|
||
openTokensFromHome: () => { this.enterEditor(false); this.setState({ leftTab: 'tokens' }); },
|
||
reimportGo: () => { this.setState({ sysStage: 'paste' }); this.go('sysupdate'); },
|
||
setSwatchStyle: { width: '30px', height: '30px', borderRadius: '9px', background: sys.ramp[500], boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.25)', flex: '0 0 auto' },
|
||
setSysMeta: 'tokens.css · pasted July 9 · ' + sys.font,
|
||
mapRows, craftCards,
|
||
exportPrefRows: [
|
||
{ label: 'Framework', note: 'fixed for the MVP', chip: 'React + Tailwind', chipStyle: prefChip(true) },
|
||
{ label: 'Tokens', note: 'CSS variables + Tailwind theme', chip: 'on', chipStyle: prefChip(true) },
|
||
{ label: 'Prettier + tsc + eslint', note: 'runs before every export', chip: 'on', chipStyle: prefChip(true) },
|
||
{ label: 'Fidelity badge', note: 'honest labelling per screen', chip: 'production / prototype', chipStyle: prefChip(false) }
|
||
],
|
||
dangerStub: () => this.toast('Deletion is stubbed in this prototype.', { glyph: '!' }),
|
||
manageSeats: () => this.toast('Seats are provisioned manually during the design-partner MVP.', { glyph: '!' }),
|
||
profThemeSegs: [this.seg('Dark', s.theme === 'dark', () => { if (s.theme !== 'dark') this.toggleTheme(); }), this.seg('Light', s.theme === 'light', () => { if (s.theme !== 'light') this.toggleTheme(); })],
|
||
memberRows: [
|
||
{ initials: 'MK', name: 'Maya Kade', role: 'product designer — composes & ships', chip: 'owner', on: true },
|
||
{ initials: 'DP', name: 'Devon Park', role: 'design engineer — owns @solstice/ui, holds the merge veto', chip: 'reviewer', on: false }
|
||
].map(m => ({ initials: m.initials, name: m.name, role: m.role, chip: m.chip, chipStyle: prefChip(m.on), avStyle: { width: '30px', height: '30px', borderRadius: '99px', background: 'var(--chip)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: this.MONO, fontSize: '10px', color: 'var(--t2)', flex: '0 0 auto' } })),
|
||
inviteStub: () => this.toast('Single-player MVP — invites land with share links.', { glyph: '!' }),
|
||
signOut: () => this.setState({ screen: 'signin', projMenu: false }),
|
||
sysPaste: (s.sysStage || 'paste') === 'paste', sysDiff: s.sysStage === 'diff', sysApplied: s.sysStage === 'applied',
|
||
sysText: s.sysText, onSysText: (e) => this.setState({ sysText: e.target.value }),
|
||
sysCompare: () => this.setState({ sysStage: 'diff' }),
|
||
sysCancel: () => this.setState({ sysStage: 'paste' }),
|
||
diffRows,
|
||
sysImpact: 'affects ' + brandUse + ' components across ' + this.frames().length + ' frames · bindings preserved · nothing breaks',
|
||
sysApply: () => {
|
||
this.prevRadius = sys.radius;
|
||
this.setState({ system: this.buildSystem(Object.assign({}, sys, { radius: 10 })), sysStage: 'applied', pulseStamp: Date.now(), flashIds: ['tok-radius'], flashStamp: Date.now() });
|
||
},
|
||
sysUndo: () => {
|
||
this.setState({ system: this.buildSystem(Object.assign({}, sys, { radius: this.prevRadius || 12 })), sysStage: 'diff', pulseStamp: Date.now() });
|
||
this.toast('Reverted — radius-md back to ' + (this.prevRadius || 12) + 'px.');
|
||
},
|
||
sysBackEditor: () => this.enterEditor(false)
|
||
};
|
||
}
|
||
|
||
renderVals() {
|
||
return Object.assign({}, this.onboardingVals(), this.editorVals(), this.screensVals(), this.overlayVals());
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|