Files
agentic-dev/docs/product/reference/project/Weave - warm dark (backup).dc.html
Danijel Martinek 1483a45406 docs(product): commit design references under docs/product/reference/
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
2026-07-12 14:09:30 +02:00

3731 lines
282 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;">&lt;/&gt;</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="{&quot;aiLive&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Demo&quot;},&quot;defaultTheme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;dark&quot;,&quot;light&quot;],&quot;default&quot;:&quot;dark&quot;,&quot;tsType&quot;:&quot;\&quot;dark\&quot; | \&quot;light\&quot;&quot;,&quot;section&quot;:&quot;Demo&quot;},&quot;startAt&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;signin&quot;,&quot;home&quot;,&quot;onboarding&quot;,&quot;editor&quot;],&quot;default&quot;:&quot;signin&quot;,&quot;tsType&quot;:&quot;\&quot;signin\&quot; | \&quot;home\&quot; | \&quot;onboarding\&quot; | \&quot;editor\&quot;&quot;,&quot;section&quot;:&quot;Demo&quot;}}">
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 rows 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>