Files
agentic-dev/docs/product/reference/project/Veect.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

4143 lines
314 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:#0E0E0D;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
--bg:#0E0E0D;--panel:#0E0E0D;--raised:#161615;--line:#262624;--line2:#3A3A37;
--t1:#F4F4F2;--t2:#A6A6A1;--t3:#73736E;
--iris:#F4F4F2;--iris-t:#F4F4F2;--iris-dim:rgba(244,244,242,.09);
--live:#8BA88E;--warn:#C2A36B;--err:#C97070;
--btn-ink:#0E0E0D;
--chip:#161615;--hoverbg:rgba(244,244,242,.06);
}
body[data-th="light"]{background:#FCFCFB;
--bg:#FCFCFB;--panel:#FCFCFB;--raised:#FFFFFF;--line:#E7E6E2;--line2:#CFCEC8;
--t1:#161513;--t2:#63615B;--t3:#9A978F;
--iris:#161513;--iris-t:#161513;--iris-dim:rgba(22,21,19,.06);
--live:#5B7F5E;--warn:#9A7B3F;--err:#B05E5E;
--btn-ink:#FFFFFF;
--chip:#F4F3F0;--hoverbg:rgba(22,21,19,.045);
}
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(127,127,120,.30)}
*:focus-visible{outline:1.5px solid var(--t1);outline-offset:1px;border-radius:0}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--line2);border:3px 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(127,127,120,.25)}100%{background:transparent}}
@keyframes nodePulse{0%{box-shadow:0 0 0 0 rgba(127,127,120,.45)}100%{box-shadow:0 0 0 12px rgba(127,127,120,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="{{ cxChoose }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:9px;">
<span style="display:flex;align-items:baseline;gap:3px;flex:0 0 auto;"><span style="font-size:15px;font-weight:600;letter-spacing:-.035em;line-height:1;">veect</span><span style="width:4px;height:4px;background:currentColor;flex:0 0 auto;"></span></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;">Connect your repository.</h1>
<p style="font-size:16px;color:var(--t2);line-height:1.55;max-width:600px;margin:14px 0 0;">Veect reads your components and tokens straight from the source — then composes production React on a branch you can publish. Nothing to paste, nothing to upload.</p>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:34px;">
<sc-for list="{{ sourceCards }}" as="item" hint-placeholder-count="3">
<button onClick="{{ item.onPick }}" style="display:flex;align-items:flex-start;gap:14px;background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:18px 20px;text-align:left;transition:border-color .15s,transform .15s;" style-hover="border-color:var(--iris);transform:translateY(-1px)">
<span style="color:var(--t2);margin-top:1px;flex:0 0 auto;">{{ item.icon }}</span>
<span style="display:flex;flex-direction:column;gap:4px;flex:1;">
<span style="font-size:15px;font-weight:600;">{{ item.name }}</span>
<span style="font-size:13px;color:var(--t2);line-height:1.5;">{{ item.desc }}</span>
</span>
<span style="color:var(--t3);font-size:16px;align-self:center;"></span>
</button>
</sc-for>
</div>
<div style="margin-top:26px;display:flex;align-items:center;gap:8px;">
<button onClick="{{ playgroundHint }}" style="font-size:13px;color:var(--iris-t);font-weight:500;" style-hover="color:var(--t1)">No repo handy? Open the Playground sandbox →</button>
</div>
</sc-if>
<sc-if value="{{ cxDevice }}" hint-placeholder-val="{{ false }}">
<button onClick="{{ backToChoose }}" 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;">Authorize Veect on GitHub</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:520px;">Enter this code at github.com to grant access. Veect requests the narrowest scope that still lets it read components and open pull requests.</p>
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-top:22px;align-items:stretch;">
<div style="flex:1 1 300px;background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:22px;display:flex;flex-direction:column;gap:16px;">
<span style="{{ monoLbl }}">DEVICE CODE</span>
<button onClick="{{ copyDevice }}" title="Copy code" style="display:flex;align-items:center;gap:12px;align-self:flex-start;" style-hover="opacity:.8">
<span style="font-family:'Fragment Mono',monospace;font-size:34px;font-weight:600;letter-spacing:.14em;color:var(--t1);">{{ deviceCode }}</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);border:1px solid var(--line2);border-radius:2px;padding:2px 6px;">copy</span>
</button>
<a href="#" onClick="{{ deviceApprove }}" style="font-size:13.5px;font-weight:500;color:var(--iris-t);" style-hover="color:var(--t1)">Approve in browser →</a>
<div style="display:flex;align-items:center;gap:8px;margin-top:2px;">
<span style="width:7px;height:7px;border-radius:2px;background:var(--live);flex:0 0 auto;animation:blinkq 1.2s infinite;"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);">waiting for approval…</span>
</div>
</div>
<div style="flex:1 1 240px;display:flex;flex-direction:column;gap:10px;">
<sc-if value="{{ hasCxErr }}" hint-placeholder-val="{{ false }}">
<div style="background:rgba(224,160,58,.08);border:1px solid rgba(224,160,58,.4);border-radius:2px;padding:12px 13px;font-size:12.5px;line-height:1.55;color:var(--t1);animation:riseIn .25s both;">{{ cxErr }}</div>
</sc-if>
<button onClick="{{ deviceApprove }}" style="{{ lifeBtnStyle }}">I&#39;ve approved it →</button>
<button onClick="{{ deviceOrgWall }}" style="{{ secBtnStyle }}">Simulate: org hasn&#39;t authorized</button>
<button onClick="{{ deviceExpire }}" style="font-size:12px;color:var(--t3);padding:6px;text-align:left;" style-hover="color:var(--warn)">Simulate: code expired</button>
</div>
</div>
</sc-if>
<sc-if value="{{ cxOrgwall }}" hint-placeholder-val="{{ false }}">
<button onClick="{{ backToChoose }}" 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;">Your org hasn&#39;t authorized Veect yet</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:540px;">Sign-in worked, but <span style="font-family:'Fragment Mono',monospace;color:var(--t1);">solstice/web</span> is behind an organization policy. An owner needs to approve Veect once — then every seat can connect.</p>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:18px;margin-top:20px;display:flex;flex-direction:column;gap:12px;max-width:560px;">
<div style="display:flex;flex-direction:column;gap:3px;">
<span style="font-size:13px;font-weight:600;">You can</span>
<span style="font-size:13px;color:var(--t2);line-height:1.55;">Open the org&#39;s third-party access settings and approve Veect, or send the request to an admin.</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<a href="#" onClick="{{ orgRetry }}" style="{{ lifeBtnStyle }}">Open approval settings →</a>
<button onClick="{{ orgRetry }}" style="{{ secBtnStyle }}">Ask an admin</button>
</div>
</div>
<button onClick="{{ orgRetry }}" style="font-size:12.5px;color:var(--iris-t);margin-top:16px;align-self:flex-start;" style-hover="color:var(--t1)">Approved just now? Retry →</button>
</sc-if>
<sc-if value="{{ cxFingerprint }}" hint-placeholder-val="{{ false }}">
<button onClick="{{ backToChoose }}" 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;">Confirm the host key</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:540px;">First connection to <span style="font-family:'Fragment Mono',monospace;color:var(--t1);">git.solstice.dev</span> over SSH. Check this fingerprint matches what your host published before trusting it.</p>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:18px;margin-top:20px;display:flex;flex-direction:column;gap:12px;max-width:560px;">
<span style="{{ monoLbl }}">ED25519 FINGERPRINT</span>
<span style="font-family:'Fragment Mono',monospace;font-size:13px;color:var(--t1);word-break:break-all;">SHA256:kR8Hq3vL0pR2mNbXcZ1wY7tSh6dKfJ4aQeUoIgP5s</span>
<div style="display:flex;gap:10px;margin-top:2px;">
<button onClick="{{ fpAccept }}" style="{{ lifeBtnStyle }}">Accept &amp; continue</button>
<button onClick="{{ fpReject }}" style="{{ secBtnStyle }}">Reject</button>
</div>
</div>
</sc-if>
<sc-if value="{{ cxTrustS }}" hint-placeholder-val="{{ false }}">
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Trust this repository?</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:560px;">Veect will run <span style="font-family:'Fragment Mono',monospace;color:var(--t1);">{{ cxRepoName }}</span>&#39;s install scripts and dev server on your machine to render real components. Only connect repositories you trust.</p>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:18px;margin-top:22px;display:flex;flex-direction:column;gap:10px;max-width:560px;">
<button onClick="{{ trustContinue }}" style="{{ lifeBtnStyle }}">Trust &amp; continue</button>
<button onClick="{{ trustNoScripts }}" style="{{ secBtnStyle }}">Install without running scripts</button>
<button onClick="{{ trustCancel }}" style="font-size:12.5px;color:var(--t3);padding:8px;text-align:center;" style-hover="color:var(--t1)">Cancel</button>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);line-height:1.6;margin-top:2px;">the &ldquo;without scripts&rdquo; choice is remembered per repository</span>
</div>
</sc-if>
<sc-if value="{{ cxClone }}" hint-placeholder-val="{{ false }}">
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Connecting {{ cxRepoName }}</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:520px;">This runs on your machine and can take a few minutes on first connect. You can leave it running.</p>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:20px 22px;margin-top:22px;display:flex;flex-direction:column;gap:14px;max-width:520px;">
<sc-for list="{{ cloneRows }}" as="item" hint-placeholder-count="4">
<div style="display:flex;align-items:center;gap:11px;">
<span style="{{ item.tickStyle }}">{{ item.tick }}</span>
<span style="{{ item.labelStyle }}">{{ item.label }}</span>
<span style="{{ item.noteStyle }}">{{ item.note }}</span>
</div>
</sc-for>
<div style="height:1px;background:var(--line);margin-top:2px;"></div>
<sc-if value="{{ hasCxErr }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12.5px;line-height:1.55;color:var(--err);animation:riseIn .25s both;">{{ cxErr }}</div>
<div style="display:flex;gap:10px;">
<button onClick="{{ cloneRetry }}" style="{{ lifeBtnStyle }}">Retry</button>
<button onClick="{{ cloneCancel }}" style="{{ secBtnStyle }}">Cancel</button>
</div>
</sc-if>
<sc-if value="{{ cxClone }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:12px;">
<button onClick="{{ cloneCancel }}" style="font-size:12px;color:var(--t3);" style-hover="color:var(--t1)">Cancel</button>
<span style="flex:1"></span>
<button onClick="{{ cloneFailDemo }}" style="font-size:11px;color:var(--t3);" style-hover="color:var(--warn)">Simulate install failure</button>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ cxWizard }}" hint-placeholder-val="{{ false }}">
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Point Veect at your components</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:560px;">No <span style="font-family:'Fragment Mono',monospace;color:var(--t1);">veect.config.json</span> yet — pick the folders to read. This config rides your first published branch.</p>
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-top:22px;align-items:flex-start;">
<div style="flex:1.3 1 320px;background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:12px;display:flex;flex-direction:column;gap:4px;">
<span style="{{ monoLbl }}">COMPONENT FOLDERS</span>
<sc-for list="{{ compFolders }}" as="item" hint-placeholder-count="3">
<button onClick="{{ item.onToggle }}" style="{{ item.rowStyle }}">
<span style="{{ item.boxStyle }}"></span>
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0;text-align:left;">
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t1);">{{ item.path }}</span>
<span style="font-size:11px;color:var(--t3);">{{ item.meta }}</span>
</span>
</button>
</sc-for>
</div>
<div style="flex:1 1 240px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;flex-direction:column;gap:6px;">
<span style="{{ monoLbl }}">APP DIRECTORY (MONOREPO)</span>
<input value="{{ wizardApp }}" onChange="{{ onWizardApp }}" style="background:var(--chip);border:1px solid var(--line);border-radius:2px;padding:8px 10px;font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t1);outline:none;">
</div>
<div style="display:flex;flex-direction:column;gap:7px;">
<span style="{{ monoLbl }}">DETECTED</span>
<sc-for list="{{ wizardChips }}" as="item" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:8px;">
<span style="color:var(--live);font-size:11px;"></span>
<span style="font-size:12px;color:var(--t2);">{{ item.k }}</span>
<span style="flex:1"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t1);">{{ item.v }}</span>
</div>
</sc-for>
</div>
<button onClick="{{ wizardContinue }}" style="{{ lifeBtnStyle }}">Continue →</button>
</div>
</div>
</sc-if>
<sc-if value="{{ cxEnv }}" hint-placeholder-val="{{ false }}">
<h2 style="font-size:24px;font-weight:600;letter-spacing:-.3px;margin:18px 0 6px;">Environment</h2>
<p style="font-size:13.5px;color:var(--t2);margin:0;max-width:560px;">Your preview needs a few keys. They stay on this machine, encrypted — Veect never sends them anywhere.</p>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:12px;margin-top:22px;display:flex;flex-direction:column;max-width:560px;">
<sc-for list="{{ envRows }}" as="item" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:10px;padding:9px 8px;">
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t1);flex:0 0 auto;">{{ item.key }}</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ item.src }}</span>
<span style="flex:1"></span>
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
</div>
</sc-for>
</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:16px;">
<button onClick="{{ envContinue }}" style="{{ lifeBtnStyle }}">Continue →</button>
<button onClick="{{ envImport }}" style="{{ secBtnStyle }}">Import from .env.local</button>
<button onClick="{{ envSkip }}" style="font-size:12.5px;color:var(--t3);" style-hover="color:var(--t1)">Skip for now</button>
</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;">Reading your repository…</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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;padding:18px;display:flex;align-items:center;gap:12px;justify-content:flex-start;min-height:112px;">
<div style="width:36px;height:21px;border-radius:2px;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:2px;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:2px;padding:18px;display:flex;flex-direction:column;gap:10px;justify-content:center;min-height:112px;">
<div style="position:relative;height:5px;border-radius:2px;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:2px;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:2px;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:2px;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:36px;flex:0 0 auto;display:flex;align-items:center;gap:4px;padding:0 8px;border-bottom:1px solid var(--line);position:relative;z-index:45;background:var(--bg);">
<span style="display:flex;align-items:baseline;gap:3px;flex:0 0 auto;"><span style="font-size:14px;font-weight:600;letter-spacing:-.035em;line-height:1;">veect</span><span style="width:4px;height:4px;background:currentColor;flex:0 0 auto;"></span></span>
<div style="position:relative;">
<button onClick="{{ onProj }}" style="display:flex;align-items:center;gap:5px;padding:4px 7px;border-radius:2px;font-size:12.5px;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 onClick="{{ onProj }}" style="position:fixed;inset:0;z-index:59;"></div>
<div style="position:absolute;top:32px;left:0;z-index:60;width:240px;background:var(--raised);border:1px solid var(--line2);border-radius:2px;box-shadow:0 0 0 1px var(--line2);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:2px;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</span>
<span style="font-family:'Fragment Mono',monospace;font-size:9px;color:var(--t3);">workspace · solstice/web · main</span>
</div>
</div>
<div style="padding:6px 5px 4px;display:flex;flex-direction:column;border-bottom:1px solid var(--line);">
<span style="font-family:'Fragment Mono',monospace;font-size:9px;letter-spacing:.08em;color:var(--t3);padding:3px 9px 5px;">PROJECTS · EACH ON ITS BRANCH</span>
<sc-for list="{{ designRows }}" as="item" hint-placeholder-count="3">
<button onClick="{{ item.onOpen }}" style="{{ item.rowStyle }}" style-hover="background:var(--hoverbg)">
<span style="{{ item.dotStyle }}"></span>
<span style="{{ item.nameStyle }}">{{ item.name }}</span>
<span style="{{ item.metaStyle }}">{{ item.status }}</span>
</button>
</sc-for>
<button onClick="{{ newDesign }}" style="display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:2px;width:100%;color:var(--t2);" style-hover="background:var(--hoverbg);color:var(--t1)">
<span style="font-family:'Fragment Mono',monospace;font-size:11px;width:6px;flex:0 0 auto;text-align:center;">+</span>
<span style="font-size:12.5px;">New project</span>
</button>
</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:2px;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:2px;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:5px;padding:4px 8px;border-radius:2px;font-size:11.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="{{ openExport }}" style="display:flex;align-items:center;gap:5px;background:var(--iris);color:var(--btn-ink);padding:4px 10px;border-radius:2px;font-size:11.5px;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:4px 7px;border-radius:2px;color:var(--t2);font-size:12px;" style-hover="background:var(--hoverbg);color:var(--t1)"></button>
<sc-if value="{{ showAvatar }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ goProfile }}" title="Profile" style="width:22px;height:22px;border-radius:2px;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:22px;background:var(--line);margin:3px 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:20px;height:20px;border-radius:2px;color:var(--t3);display:flex;align-items:center;justify-content:center;font-size:14px;" style-hover="color:var(--t1);background:var(--hoverbg)">«</button>
<div style="display:flex;align-items:center;gap:7px;margin:10px 32px 6px 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">
<sc-if value="{{ item.isViews }}" hint-placeholder-val="{{ false }}">
<div style="{{ item.vRowStyle }}">
<button onClick="{{ item.chipD.onClick }}" title="{{ item.chipD.title }}" style="{{ item.chipD.style }}">
<span onClick="{{ item.chipD.onDot }}" title="{{ item.chipD.dotTitle }}" style="{{ item.chipD.dotStyle }}">{{ item.chipD.dot }}</span>{{ item.chipD.label }}
</button>
<button onClick="{{ item.chipT.onClick }}" title="{{ item.chipT.title }}" style="{{ item.chipT.style }}">
<span onClick="{{ item.chipT.onDot }}" title="{{ item.chipT.dotTitle }}" style="{{ item.chipT.dotStyle }}">{{ item.chipT.dot }}</span>{{ item.chipT.label }}
</button>
<button onClick="{{ item.chipM.onClick }}" title="{{ item.chipM.title }}" style="{{ item.chipM.style }}">
<span onClick="{{ item.chipM.onDot }}" title="{{ item.chipM.dotTitle }}" style="{{ item.chipM.dotStyle }}">{{ item.chipM.dot }}</span>{{ item.chipM.label }}
</button>
</div>
</sc-if>
<sc-if value="{{ item.isRow }}" hint-placeholder-val="{{ true }}">
<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-if>
</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:2px;cursor:grab;margin-bottom:6px;" style-hover="border-color:var(--line2)">
<span style="width:22px;height:22px;border-radius:2px;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:2px;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:2px;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:2px;margin-bottom:6px;">
<span style="width:22px;height:22px;border-radius:2px;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:2px;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:2px;margin-bottom:6px;">
<span style="width:22px;height:22px;border-radius:2px;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:2px;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:2px;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:2px;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:2px;padding:7px 9px;" 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:2px;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:2px;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:var(--btn-ink);border-radius:2px;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:2px;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:2px;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:2px;padding:8px 10px;">
<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:2px;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:2px;padding:7px 9px;">
<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:2px;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:2px;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:2px;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:2px;padding:6px 10px;box-shadow:0 0 0 1px var(--line2);" 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:2px;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:2px;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:2px;padding:2px 6px;flex:0 0 auto;">{{ bubbleTag }}</span>
<input autoFocus="{{ true }}" value="{{ bubbleText }}" onChange="{{ onBubbleText }}" onKeyDown="{{ onBubbleKey }}" placeholder="Tell Veect 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:2px;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:2px;overflow:hidden;">
<button onClick="{{ zoomOut }}" title="Zoom out (⌘−)" style="padding:3px 8px;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:3px 6px;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:3px 8px;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:2px;padding:3px 8px;" 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:2px;padding:3px 8px;" 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:2px;padding:3px 8px;" 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:stretch;gap:2px;background:var(--raised);border:1px solid var(--line2);border-radius:2px;padding:2px;pointer-events:auto;max-width:100%;overflow-x:auto;">
<sc-for list="{{ toolItems }}" as="item" hint-placeholder-count="6">
<sc-if value="{{ item.isDivider }}" hint-placeholder-val="{{ false }}">
<span style="width:1px;background:var(--line);margin:4px 3px;flex:0 0 auto;"></span>
</sc-if>
<sc-if value="{{ item.isBtn }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ item.onClick }}" title="{{ item.title }}" style="{{ item.style }}" style-hover="background:var(--hoverbg)">
<span style="display:flex;align-items:center;justify-content:center;flex:0 0 auto;">{{ item.icon }}</span>
<span style="{{ item.lblStyle }}">{{ item.label }}</span>
<span style="{{ item.keyStyle }}">{{ item.key }}</span>
</button>
</sc-if>
</sc-for>
</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:2px;" 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:2px;" 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 — Veect 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:2px;" 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:2px;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:var(--btn-ink);border-radius:2px;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:2px;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:2px;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:2px;" 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:2px;" 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:2px;padding:9px 10px;min-height:54px;">
<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:2px;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:2px;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:2px;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:20px;height:20px;border-radius:2px;color:var(--t3);display:flex;align-items:center;justify-content:center;font-size:14px;" style-hover="color:var(--t1);background:var(--hoverbg)">»</button>
<div style="display:flex;align-items:center;gap:7px;margin:10px 10px 6px 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:22px;height:22px;border-radius:2px;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:2px;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:2px;padding:4px;">
<button onClick="{{ frameWMinus }}" style="width:20px;height:20px;border-radius:2px;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:2px;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="display:flex;flex-direction:column;gap:5px;margin-top:2px;">
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">active view</span>
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t1);">{{ frameViewInfo }}</span>
</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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;padding:4px;">
<button onClick="{{ gapMinus }}" style="width:20px;height:20px;border-radius:2px;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:2px;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:2px;padding:4px;">
<button onClick="{{ padMinus }}" style="width:20px;height:20px;border-radius:2px;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:2px;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>
{{ item.selectEl }}
</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:2px;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:2px;padding:5px 8px;">
<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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:34px;flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:0 8px;border-bottom:1px solid #262019;">
<button onClick="{{ collapseCode }}" title="Collapse panel" style="width:20px;height:20px;border-radius:2px;color:#7A7268;display:flex;align-items:center;justify-content:center;font-size:14px;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:2px;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:2px;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:var(--btn-ink);border-radius:2px;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;">
<span style="display:flex;align-items:baseline;gap:3px;flex:0 0 auto;"><span style="font-size:16px;font-weight:600;letter-spacing:-.035em;line-height:1;">veect</span><span style="width:4px;height:4px;background:currentColor;flex:0 0 auto;"></span></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:2px;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:2px;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:2px;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);">
<span style="display:flex;align-items:baseline;gap:3px;flex:0 0 auto;"><span style="font-size:15px;font-weight:600;letter-spacing:-.035em;line-height:1;">veect</span><span style="width:4px;height:4px;background:currentColor;flex:0 0 auto;"></span></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:2px;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:var(--btn-ink);padding:8px 14px;border-radius:2px;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:4px 7px;border-radius:2px;color:var(--t2);font-size:12px;" style-hover="background:var(--hoverbg);color:var(--t1)"></button>
<div style="position:relative;flex:0 0 auto;">
<button onClick="{{ onWs }}" title="Switch workspace" style="display:flex;align-items:center;gap:7px;border:1px solid var(--line2);border-radius:2px;padding:4px 8px 4px 6px;" style-hover="border-color:var(--t3)">
<span style="width:18px;height:18px;border-radius:2px;background:var(--iris-dim);color:var(--iris-t);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:600;flex:0 0 auto;">{{ wsInitial }}</span>
<span style="font-size:12px;font-weight:500;color:var(--t1);max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ wsLabel }}</span>
<span style="color:var(--t3);font-size:9px;"></span>
</button>
<sc-if value="{{ wsMenuOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ onWs }}" style="position:fixed;inset:0;z-index:59;"></div>
<div style="position:absolute;top:32px;right:0;z-index:60;width:250px;background:var(--raised);border:1px solid var(--line2);border-radius:2px;box-shadow:0 0 0 1px var(--line2);overflow:hidden;display:flex;flex-direction:column;animation:popIn .13s both;">
<span style="font-family:'Fragment Mono',monospace;font-size:9px;letter-spacing:.08em;color:var(--t3);padding:9px 12px 5px;">WORKSPACE · ONE REPO EACH</span>
<sc-for list="{{ wsItems }}" as="item" hint-placeholder-count="2">
<button onClick="{{ item.onPick }}" style="{{ item.rowStyle }}" style-hover="background:var(--hoverbg)">
<span style="{{ item.avStyle }}">{{ item.initial }}</span>
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:0;text-align:left;">
<span style="font-size:12.5px;font-weight:500;color:var(--t1);">{{ 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.repo }}</span>
</span>
<sc-if value="{{ item.active }}" hint-placeholder-val="{{ false }}"><span style="color:var(--live);font-size:11px;flex:0 0 auto;"></span></sc-if>
</button>
</sc-for>
<div style="height:1px;background:var(--line);margin:4px 0;"></div>
<button onClick="{{ connectWs }}" style="display:flex;align-items:center;gap:9px;padding:9px 12px;width:100%;color:var(--t2);" style-hover="background:var(--hoverbg);color:var(--t1)">
<span style="font-family:'Fragment Mono',monospace;font-size:11px;width:18px;text-align:center;flex:0 0 auto;">+</span>
<span style="font-size:12.5px;">Connect a workspace</span>
</button>
</div>
</sc-if>
</div>
<button onClick="{{ goProfile }}" title="Profile" style="width:22px;height:22px;border-radius:2px;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:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:12px 14px;flex-wrap:wrap;">
<span style="width:26px;height:26px;border-radius:2px;background:var(--iris-dim);display:flex;align-items:center;justify-content:center;color:var(--iris-t);flex:0 0 auto;">{{ connIcon }}</span>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:160px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:14px;font-weight:600;">Solstice</span>
<span style="{{ connAccessStyle }}">{{ connAccess }}</span>
</div>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">workspace · one repo · solstice/web · {{ connMeta }}</span>
</div>
<button onClick="{{ goSettingsConn }}" style="border:1px solid var(--line2);color:var(--t1);padding:6px 11px;border-radius:2px;font-size:11.5px;font-weight:500;flex:0 0 auto;" style-hover="border-color:var(--t3)">Connection</button>
</div>
<div style="display:flex;align-items:baseline;gap:12px;margin-top:26px;">
<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:5px;padding:12px 14px 13px;width:100%;">
<div style="display:flex;align-items:center;gap:8px;width:100%;">
<span style="font-size:13.5px;font-weight:600;flex:0 0 auto;">{{ item.name }}</span>
<span style="flex:1"></span>
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
</div>
<div style="display:flex;align-items:center;gap:7px;width:100%;">
<span style="width:5px;height:5px;border-radius:2px;background:var(--line2);flex:0 0 auto;"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t2);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.branch }}</span>
</div>
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">{{ item.meta }}</span>
</div>
</button>
</sc-for>
<button onClick="{{ newDesign }}" style="min-height:210px;border:1px dashed var(--line2);border-radius:2px;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;">a fresh board on its own branch</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:2px;padding:16px;display:flex;flex-direction:column;gap:11px;">
<div style="display:flex;border-radius:2px;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:2px;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:2px;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:2px;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:2px;" 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:22px;height:22px;border-radius:2px;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;">CONNECTION</span>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:14px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="color:var(--t2);flex:0 0 auto;">{{ connIcon }}</span>
<div style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:180px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-family:'Fragment Mono',monospace;font-size:13px;font-weight:600;color:var(--t1);">solstice/web</span>
<span style="{{ connAccessStyle }}">{{ connAccess }}</span>
</div>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ connMeta }}</span>
</div>
<button onClick="{{ reimportGo }}" style="border:1px solid var(--line2);color:var(--t1);padding:7px 12px;border-radius:2px;font-size:12px;font-weight:500;" style-hover="border-color:var(--t3)">Rescan</button>
<button onClick="{{ reauthStub }}" style="border:1px solid var(--line2);color:var(--t1);padding:7px 12px;border-radius:2px;font-size:12px;font-weight:500;" style-hover="border-color:var(--t3)">Re-authorize</button>
</div>
<div style="display:flex;align-items:center;gap:10px;background:var(--chip);border:1px solid var(--line);border-radius:2px;padding:9px 12px;">
<span style="{{ setSwatchStyle }}"></span>
<span style="font-size:12px;color:var(--t2);flex:1;min-width:0;">Tokens &amp; components discovered from this repo — no paste, no upload.</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">{{ setSysMeta }}</span>
</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;">ENVIRONMENT</span>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:6px;display:flex;flex-direction:column;">
<sc-for list="{{ envSettingsRows }}" as="item" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:10px;padding:9px 10px;">
<span style="font-family:'Fragment Mono',monospace;font-size:12px;color:var(--t1);flex:0 0 auto;">{{ item.key }}</span>
<span style="font-family:'Fragment Mono',monospace;font-size:11px;color:var(--t3);flex:1;min-width:40px;">{{ item.masked }}</span>
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:var(--t3);">{{ item.src }}</span>
<span style="{{ item.chipStyle }}">{{ item.chip }}</span>
</div>
</sc-for>
</div>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:var(--t3);">stays on this machine, encrypted · Veect never transmits your keys</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;">DOCTOR — CONNECTION HEALTH</span>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;padding:6px;display:flex;flex-direction:column;">
<sc-for list="{{ doctorRows }}" as="item" hint-placeholder-count="4">
<div style="display:flex;align-items:center;gap:10px;padding:9px 10px;">
<span style="{{ item.dotStyle }}">{{ item.dot }}</span>
<span style="font-size:12.5px;color:var(--t1);flex:0 0 auto;width:120px;">{{ item.name }}</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:var(--t3);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ item.detail }}</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;">COMPONENT MAPPINGS — THE MERGE BAR</span>
<div style="background:var(--panel);border:1px solid var(--line);border-radius:2px;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:2px;" style-hover="background:var(--hoverbg)">
<span style="width:22px;height:22px;border-radius:2px;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 Veect 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:2px;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:2px;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:2px;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:2px;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:2px;" 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:2px;padding:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<span style="width:52px;height:52px;border-radius:2px;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:2px;">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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;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:2px;" 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 — Veect 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:2px;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:2px;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:2px;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:2px;padding:7px 11px;font-family:'Fragment Mono',monospace;font-size:10.5px;color:#55504A;box-shadow:0 0 0 1px var(--line);">preview — real components, real states</span>
</sc-if>
<button onClick="{{ closePreview }}" style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:2px;padding:7px 11px;font-size:11.5px;font-weight:500;color:#1A1714;box-shadow:0 0 0 1px var(--line);" 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:2px;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:2px;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:2px;" 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:2px;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 Veect 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:2px;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:2px;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:2px;font-size:12.5px;font-weight:500;" style-hover="border-color:var(--t3)">Copy {{ exportFileName }}</button>
<button onClick="{{ downloadZip }}" style="background:var(--iris);color:var(--btn-ink);padding:9px 14px;border-radius:2px;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:2px;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:2px;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;light&quot;,&quot;dark&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;home&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, wsMenu: false, activeWs: 'solstice', 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) || 'light',
ob: 'welcome',
cx: 'choose', cxSource: 'github', cloneStage: 0, cloneStart: 0, cloneNow: 0,
trustScripts: true, cxErr: null,
wizardSel: { 'components/ui': true, 'components/marketing': true, 'components/primitives': false },
wizardApp: 'apps/web',
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: 216, rightW: 244, codeW: 380,
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: 284,
codeOpen: false, codeFile: 'home',
drop: null, bornIds: {}, pulseStamp: 0, flashIds: [], flashStamp: 0,
saveState: 'saved', syncing: false,
zoom: 'fit', fit: 0.62, deskLight: true,
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')],
pick: [h('circle', { cx: 8, cy: 8, r: 3, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }), P('M8 1.6v2.2'), P('M8 12.2v2.2'), P('M1.6 8h2.2'), P('M12.2 8h2.2')],
draw: [P('M3.2 12.8l.9-3.1 6.6-6.6a1.35 1.35 0 0 1 1.9 0l.3.3a1.35 1.35 0 0 1 0 1.9l-6.6 6.6-3.1.9z'), P('M9.7 4.1l2.2 2.2')],
eye: [P('M1.9 8s2.3-4.1 6.1-4.1S14.1 8 14.1 8s-2.3 4.1-6.1 4.1S1.9 8 1.9 8z'), h('circle', { cx: 8, cy: 8, r: 1.8, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 })],
eyeoff: [P('M1.9 8s2.3-4.1 6.1-4.1S14.1 8 14.1 8s-2.3 4.1-6.1 4.1S1.9 8 1.9 8z', { opacity: 0.55 }), P('M2.6 13.4l10.8-10.8')],
mic: [P('M8 2.3a1.9 1.9 0 0 1 1.9 1.9v3.5a1.9 1.9 0 1 1-3.8 0V4.2A1.9 1.9 0 0 1 8 2.3z'), P('M3.9 7.9a4.1 4.1 0 0 0 8.2 0'), P('M8 12v2')]
};
return h('svg', { width: size || 14, height: size || 14, viewBox: '0 0 16 16', style: { display: 'block' } }, paths[name] || []);
}
modeBtn(active) {
return { display: 'flex', alignItems: 'center', gap: '5px', padding: '5px 11px', borderRadius: '2px', 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: 'var(--btn-ink)', padding: '10px 18px', borderRadius: '2px', 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: '2px', 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: '4px 7px', borderRadius: '2px', fontSize: '11px', 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.wsMenu) return this.setState({ wsMenu: false });
if (this.state.projMenu) return this.setState({ projMenu: 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; }
const typing0 = e.target && /INPUT|TEXTAREA|SELECT/i.test(e.target.tagName || '');
if (!typing0 && !e.metaKey && !e.ctrlKey && !e.altKey && this.state.screen === 'editor' && !this.state.previewOpen) {
const k0 = e.key.toLowerCase();
if (k0 === 'v') { e.preventDefault(); this.setState({ editMode: this.state.editMode === 'pick' ? null : 'pick', bubble: null }); return; }
if (k0 === 'd') { e.preventDefault(); this.setState({ editMode: this.state.editMode === 'draw' ? null : 'draw', bubble: null }); return; }
if (k0 === 'o') { e.preventDefault(); this.setState({ overlaysHidden: !this.state.overlaysHidden }); 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' }));
}
ancestorsOf(id) {
const path = [];
const dfs = (n, trail) => {
if (n.id === id) { path.push.apply(path, trail.concat(n)); return true; }
for (const c of (n.children || [])) if (dfs(c, trail.concat(n))) return true;
return false;
};
if (this.state.tree) dfs(this.state.tree, []);
return path.filter(n => n.type !== 'board');
}
inspectNode(id) {
this.selectNode(id);
const exp = Object.assign({}, this.state.expandedL || {});
this.ancestorsOf(id).forEach(a => { if (this.isStacky(a) && (a.children || []).length) exp[a.id] = true; });
const patch = { rightCollapsed: false, rightTab: 'inspector', expandedL: exp, leftTab: 'layers' };
if ((this.state.vw || 1440) < 1000) patch.rightOpen = true;
else patch.leftCollapsed = false;
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: '2px', padding: '36px', display: 'flex', flexWrap: 'wrap', gap: '40px', alignItems: 'flex-start', maxWidth: '860px', width: '100%', boxShadow: '0 0 0 1px #E7E6E2' }, 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: '4px 7px', borderRadius: '2px', fontSize: '11.5px', 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', cx: 'choose' }) }
];
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 3px var(--t1)';
else if (hov) st.boxShadow = picking ? '0 0 0 2px rgba(224,160,58,.8)' : '0 0 0 1.5px rgba(127,127,120,.6)';
}
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: 'var(--btn-ink)', padding: '2px 7px', borderRadius: '2px', 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: '2px', overflow: 'hidden', border: '1px solid ' + N[200], boxShadow: '0 0 0 1px #E7E6E2' });
else Object.assign(st, { width: '100%', minWidth: '320px', minHeight: '140px', background: '#FFFFFF', borderRadius: '2px', overflow: 'hidden', boxShadow: (st.boxShadow ? st.boxShadow + ', ' : '') + '0 0 0 1px rgba(0,0,0,.14)', 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: '2px', 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: '2px', 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));
const multi = views.length > 1;
const capFs = Math.max(10, Math.min(14, 10.5 / 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: (multi ? y - 14 - capFs * 1.5 : y) + 'px', transform: 'translateY(-100%)', 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' } }, multi ? count + ' nodes · one codebase · ' + views.length + ' views' : this.viewW(f, views[0]) + ' × auto · ' + count + ' nodes')
));
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: capFs + 'px', color: this.activeView(f) === k ? 'var(--iris-t)' : 'var(--t3)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', display: multi ? '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 "@veect/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 Veect, 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 Veect, 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 + '. Veect 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.'));
}
// ---------- Connect Repository (repo-native onboarding) ----------
CLONE_STAGES = [
{ key: 'clone', label: 'cloning repository', done: 'github.com/solstice/web' },
{ key: 'install', label: 'installing (pnpm)', done: '1,284 packages' },
{ key: 'scan', label: 'reading components', done: '12 found' },
{ key: 'preview', label: 'starting preview', done: 'vite :5173' }
];
startConnect(source) {
this.setState({ cxSource: source, cxErr: null });
if (source === 'github') this.setState({ cx: 'device' });
else if (source === 'giturl') this.setState({ cx: 'fingerprint' });
else this.cxTrust(true);
}
cxSetStage(cx) { this.stopClone(); this.setState({ cx: cx, cxErr: null }); }
cxFail(msg) { this.stopClone(); this.setState({ cxErr: msg }); }
cxTrust(withScripts) { this.setState({ trustScripts: !!withScripts }); this.beginClone(); }
beginClone() {
this.setState({ cx: 'clone', cloneStage: 0, cloneStart: Date.now(), cloneNow: Date.now(), cxErr: null });
if (this.cloneTimer) clearInterval(this.cloneTimer);
this.cloneTimer = setInterval(() => { if (this.state.cx === 'clone' && !this.state.cxErr) this.setState({ cloneNow: Date.now() }); }, 250);
const advance = (i) => {
if (this.state.cx !== 'clone') return;
if (i > 3) { this.stopClone(); this.after(450, () => this.setState({ cx: 'wizard' })); return; }
this.setState({ cloneStage: i });
this.after(1150, () => advance(i + 1));
};
this.after(500, () => advance(0));
}
stopClone() { if (this.cloneTimer) { clearInterval(this.cloneTimer); this.cloneTimer = null; } }
finishConnect() {
if (!this.state.system) this.setState({ system: this.buildSystem(this.state.quick) });
this.setState({ ob: 'reveal', cx: 'reveal', revealLive: false, revealDone: false });
this.after(750, () => this.setState({ revealLive: true }));
this.after(2300, () => this.setState({ revealDone: true }));
}
connectVals() {
const s = this.state;
const src = s.cxSource;
const srcName = src === 'github' ? 'GitHub' : src === 'giturl' ? 'Git URL' : 'local folder';
const repoName = src === 'local' ? '~/dev/solstice-web' : 'solstice/web';
const monoLbl = { fontFamily: this.MONO, fontSize: '10.5px', letterSpacing: '.08em', color: 'var(--t3)' };
const secBtn = { border: '1px solid var(--line2)', color: 'var(--t1)', padding: '9px 15px', borderRadius: '2px', fontSize: '13px', fontWeight: 500, background: 'var(--panel)' };
// staged progress rows (§5.1)
const elapsed = Math.max(0, Math.round((s.cloneNow - s.cloneStart) / 1000));
const cloneRows = this.CLONE_STAGES.map((st, i) => {
const state = s.cxErr && i === s.cloneStage ? 'fail' : i < s.cloneStage ? 'done' : i === s.cloneStage ? 'run' : 'idle';
return {
label: st.label,
tick: state === 'done' ? '✓' : state === 'fail' ? '✕' : state === 'run' ? '▸' : '·',
tickStyle: { fontFamily: this.MONO, fontSize: '11px', width: '14px', flex: '0 0 auto', color: state === 'done' ? 'var(--live)' : state === 'fail' ? 'var(--err)' : state === 'run' ? 'var(--t1)' : 'var(--t3)' },
labelStyle: { fontFamily: this.MONO, fontSize: '12px', color: state === 'idle' ? 'var(--t3)' : state === 'fail' ? 'var(--err)' : 'var(--t1)', flex: 1 },
note: state === 'done' ? st.done : state === 'run' ? elapsed + 's' : '',
noteStyle: { fontFamily: this.MONO, fontSize: '10px', color: 'var(--t3)' }
};
});
const compFolders = [
{ path: 'components/ui', meta: '38 components · Button, Card, Input…' },
{ path: 'components/marketing', meta: '11 components · Hero, PricingCard…' },
{ path: 'components/primitives', meta: '9 components · Box, Stack, Text' }
].map(f => ({
path: f.path, meta: f.meta, on: !!s.wizardSel[f.path],
onToggle: () => { const w = Object.assign({}, s.wizardSel); w[f.path] = !w[f.path]; this.setState({ wizardSel: w }); },
boxStyle: { width: '16px', height: '16px', borderRadius: '2px', flex: '0 0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '10px', color: 'var(--btn-ink)', border: s.wizardSel[f.path] ? '1px solid var(--iris)' : '1px solid var(--line2)', background: s.wizardSel[f.path] ? 'var(--iris)' : 'transparent' },
rowStyle: { display: 'flex', alignItems: 'center', gap: '10px', padding: '9px 11px', borderRadius: '2px', cursor: 'pointer', background: s.wizardSel[f.path] ? 'var(--iris-dim)' : 'transparent' }
}));
const envKeys = [
{ key: 'DATABASE_URL', src: 'turbo/env', has: true },
{ key: 'STRIPE_SECRET_KEY', src: '.env.example', has: false },
{ key: 'NEXT_PUBLIC_APP_URL', src: 'next.config', has: true }
];
return {
isConnect: s.screen === 'onboarding' && s.ob !== 'reveal',
cxChoose: s.cx === 'choose', cxDevice: s.cx === 'device', cxOrgwall: s.cx === 'orgwall',
cxFingerprint: s.cx === 'fingerprint', cxTrustS: s.cx === 'trust', cxClone: s.cx === 'clone',
cxWizard: s.cx === 'wizard', cxEnv: s.cx === 'env',
cxErr: s.cxErr, hasCxErr: !!s.cxErr,
cxSourceName: srcName, cxRepoName: repoName,
monoLbl, secBtnStyle: secBtn,
// source chooser
sourceCards: [
{ id: 'github', name: 'GitHub', desc: 'Device flow — approve in your browser. The default path.', icon: this.icon('code', 15) },
{ id: 'giturl', name: 'Git URL', desc: 'Any remote over token or SSH. Self-hosted GitLab, Bitbucket, Gitea.', icon: this.icon('layers', 15) },
{ id: 'local', name: 'Local folder', desc: 'A checkout already on this machine. Instant attach, no clone.', icon: this.icon('library', 15) }
].map(c => ({ id: c.id, name: c.name, desc: c.desc, icon: c.icon, onPick: () => this.startConnect(c.id) })),
playgroundHint: () => this.enterEditor(true),
// device flow
deviceCode: 'WVCT-7F3K',
copyDevice: () => { if (navigator.clipboard) navigator.clipboard.writeText('WVCT-7F3K'); this.toast('Code copied.'); },
deviceApprove: () => this.setState({ cx: 'trust', cxErr: null }),
deviceOrgWall: () => this.setState({ cx: 'orgwall' }),
deviceExpire: () => this.cxFail('Device code expired. Codes last 15 minutes — start again to get a fresh one.'),
backToChoose: () => this.setState({ cx: 'choose', cxErr: null }),
// org wall
orgRetry: () => this.setState({ cx: 'device', cxErr: null }),
// fingerprint
fpAccept: () => this.setState({ cx: 'trust', cxErr: null }),
fpReject: () => this.setState({ cx: 'choose', cxErr: null }),
// trust
trustContinue: () => this.cxTrust(true),
trustNoScripts: () => this.cxTrust(false),
trustCancel: () => this.setState({ cx: 'choose', cxErr: null }),
// clone
cloneRows, cloneCancel: () => this.setState({ cx: 'choose', cxErr: null }),
cloneFailDemo: () => this.cxFail('Install failed — a native module needs build tools this machine lacks. Use Docker mode, or install Xcode CLT and retry.'),
cloneRetry: () => this.beginClone(),
// wizard
compFolders, wizardApp: s.wizardApp,
onWizardApp: (e) => this.setState({ wizardApp: e.target.value }),
wizardChips: [
{ k: 'framework', v: 'Next.js 14 · app router' },
{ k: 'tokens', v: 'tailwind.config.ts + CSS vars' },
{ k: 'package manager', v: 'pnpm' }
],
wizardContinue: () => this.setState({ cx: 'env', cxErr: null }),
// env
envRows: envKeys.map(k => ({
key: k.key, src: k.src, has: k.has,
chip: k.has ? 'set' : 'needed',
chipStyle: { fontFamily: this.MONO, fontSize: '9.5px', padding: '2px 8px', borderRadius: '2px', flex: '0 0 auto', color: k.has ? 'var(--live)' : 'var(--warn)', background: k.has ? 'rgba(91,127,94,.14)' : 'rgba(224,160,58,.12)' }
})),
envImport: () => this.toast('Imported 2 keys from your checkout\u2019s .env.local — encrypted, stays on this machine.'),
envContinue: () => this.finishConnect(),
envSkip: () => this.finishConnect()
};
}
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: '2px', 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: '2px', 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: '2px' }, 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: '2px', 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 ? '12 components discovered · tokens read from tailwind.config · contrast checked' : 'reading solstice/web — components and tokens streaming in',
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({
isRow: true, isViews: false,
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: '2px', 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);
const av = this.activeView(n);
const mkChip = (k) => {
const on = views.indexOf(k) >= 0;
const isAv = av === k && on;
return {
label: k,
dot: this.icon(on ? 'eye' : 'eyeoff', 10),
title: 'Set ' + k + ' (' + this.viewW(n, k) + 'px) as the active view',
dotTitle: on ? 'Hide this variant' : 'Show this variant',
onClick: () => { this.selectNode(n.id); this.setFrameViews(n.id, on ? views : views.concat(k), k); },
onDot: (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(av) >= 0 ? av : next[0]);
},
style: { display: 'flex', alignItems: 'center', gap: '4px', flex: '0 0 auto', fontFamily: this.MONO, fontSize: '9px', letterSpacing: '.02em', padding: '2.5px 7px 2.5px 5px', borderRadius: '2px', fontWeight: isAv ? 600 : 400, border: isAv ? '1px solid var(--iris)' : on ? '1px solid var(--line2)' : '1px dashed var(--line)', color: isAv ? 'var(--btn-ink)' : on ? 'var(--t2)' : 'var(--t3)', background: isAv ? 'var(--iris)' : 'transparent', opacity: on ? 1 : 0.65 },
dotStyle: { display: 'flex', alignItems: 'center', color: isAv ? 'var(--btn-ink)' : on ? 'var(--t2)' : 'var(--t3)', cursor: 'pointer', padding: '1px' }
};
};
layerRows.push({
isRow: false, isViews: true,
vRowStyle: { display: 'flex', alignItems: 'center', gap: '4px', padding: '3px 6px 7px', paddingLeft: (19 + d * 12) + 'px', flexWrap: 'wrap' },
chipD: mkChip('desktop'), chipT: mkChip('tablet'), chipM: mkChip('mobile')
});
}
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: '2px', 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: '36px', bottom: 0, width: '44px', 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: '38px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px', padding: '5px 0 4px', borderRadius: '2px', 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: (44 + ((!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: (() => { const nm = { marketing: 'Marketing site', appshell: 'App shell', pricing: 'Pricing' }[s.activeDesign || 'marketing'] || 'Marketing site'; return phone ? nm : 'Solstice / ' + nm; })(),
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: '44px', top: '36px', bottom: 0, zIndex: 27, width: 'min(' + effL + 'px, calc(84vw - 44px))', borderRight: '1px solid var(--line2)', boxShadow: '0 0 0 1px var(--line2)' })
: 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: '0 0 0 1px var(--line2)' })
: 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: '0 0 0 1px var(--line2)' })
: Object.assign({}, codeBase, { width: effC + 'px', flex: '0 0 auto' }),
onProj: () => this.setState(st => ({ projMenu: !st.projMenu })),
projMenuOpen: s.projMenu,
designRows: [
{ id: 'marketing', name: 'Marketing site', branch: 'veect/marketing-refresh', status: '4 checkpoints · 2 behind' },
{ id: 'appshell', name: 'App shell', branch: 'veect/app-shell', status: 'published · PR #218 open' },
{ id: 'pricing', name: 'Pricing', branch: 'veect/pricing-v2', status: '1 checkpoint · in sync' }
].map(d => {
const active = (s.activeDesign || 'marketing') === d.id;
return {
name: d.name, branch: d.branch, status: d.status, active,
onOpen: () => { this.setState({ activeDesign: d.id, projMenu: false }); if (!active) this.toast('Switched to “' + d.name + '” · ' + d.branch); },
rowStyle: { display: 'flex', alignItems: 'center', gap: '9px', padding: '7px 9px', borderRadius: '2px', width: '100%', background: active ? 'var(--iris-dim)' : 'transparent' },
dotStyle: { width: '6px', height: '6px', borderRadius: '2px', flex: '0 0 auto', background: active ? 'var(--live)' : 'var(--line2)' },
nameStyle: { fontSize: '12.5px', fontWeight: active ? 600 : 500, color: 'var(--t1)', flex: '0 0 auto' },
metaStyle: { fontFamily: this.MONO, fontSize: '9px', color: 'var(--t3)', flex: 1, minWidth: 0, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
};
}),
newDesign: () => { this.setState({ projMenu: false }); this.toast('New project — a fresh board on its own branch.', { glyph: '+' }); },
projMenuItems: [
{ glyph: '▣', label: 'All projects', hint: 'home', run: () => this.go('home') },
{ glyph: '', label: 'New project', hint: 'onboarding', run: () => this.setState({ screen: 'onboarding', ob: 'welcome', cx: 'choose', 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', cx: 'choose', 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: '2px', 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: '2px', 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: '2px', 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: '2px', 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)', 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',
toolItems: (() => {
const narrow2 = vw < 940;
const mkT = (ico, label, key2, active, onClick, title) => ({
isBtn: true, isDivider: false, icon: ico, label, key: key2 || '', title, onClick,
style: { display: 'flex', alignItems: 'center', gap: '6px', padding: narrow2 ? '5px 8px' : '4px 10px', borderRadius: '2px', fontSize: '11.5px', fontWeight: 500, whiteSpace: 'nowrap', color: active ? 'var(--t1)' : 'var(--t2)', background: active ? 'var(--iris-dim)' : 'transparent' },
lblStyle: { display: narrow2 ? 'none' : 'inline' },
keyStyle: { display: narrow2 || !key2 ? 'none' : 'inline', fontFamily: this.MONO, fontSize: '8.5px', color: 'var(--t3)', border: '1px solid var(--line)', borderRadius: '2px', padding: '0 3px', lineHeight: '1.5' }
});
const dv = () => ({ isBtn: false, isDivider: true, icon: null, label: '', key: '', title: '', onClick: () => {}, style: {}, lblStyle: {}, keyStyle: {} });
return [
mkT(this.icon('pick', 13), 'Pick', 'V', s.editMode === 'pick', () => this.setState({ editMode: s.editMode === 'pick' ? null : 'pick', bubble: null }), 'Pick an element, then tell Veect what to change'),
mkT(this.icon('draw', 13), 'Draw', 'D', s.editMode === 'draw', () => this.setState({ editMode: s.editMode === 'draw' ? null : 'draw', bubble: null }), 'Scribble an annotation over the board'),
dv(),
mkT(this.icon(s.overlaysHidden ? 'eyeoff' : 'eye', 13), 'Overlays', 'O', s.overlaysHidden, () => this.setState({ overlaysHidden: !s.overlaysHidden }), s.overlaysHidden ? 'Overlays hidden — press to show' : 'Overlays shown — press to hide'),
dv(),
mkT(this.icon('mic', 13), 'Voice', '', false, () => this.toast('Voice input is stubbed in this prototype — type it instead.', { glyph: '!' }), 'Voice input — stubbed in this prototype')
];
})(),
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: '2px', padding: '8px 10px', boxShadow: '0 0 0 1px var(--line2)', 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: '2px', 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 ? '' : '✦ veect',
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: '2px', 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 — Veect 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: '2px', width: '100%', opacity: dim && !isCursor ? 0.35 : 1, background: isCursor ? 'var(--iris-dim)' : 'transparent', transition: 'opacity .25s' }
};
}),
chatPanelVisible: chatOpen,
chatPanelStyle: { position: 'fixed', left: '44px', top: '36px', 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: '2px', 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),
frameViewInfo: (() => {
if (!sel || sel.type !== 'frame') return '';
const avf = this.activeView(sel);
const nviews = this.viewsOf(sel).length;
return avf + ' · ' + this.viewW(sel, avf) + 'px' + (nviews > 1 ? ' · ' + nviews + ' views on' : '');
})(),
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]) => {
const all = [{ v: '', l: '—' }].concat(opts.map(o => ({ v: o, l: o })));
return {
cat,
val: tw[k] || '',
onPick: (e) => this.setTw(k, e.target.value),
opts: all,
selectEl: React.createElement('select', {
value: tw[k] || '',
onChange: (e) => this.setTw(k, e.target.value),
style: { flex: 1, minWidth: 0, background: 'var(--chip)', border: '1px solid var(--line)', borderRadius: '2px', color: 'var(--t1)', fontFamily: this.MONO, fontSize: '10.5px', padding: '4px 6px', outline: 'none', cursor: 'pointer' }
}, all.map(o => React.createElement('option', { key: o.v, value: o.v }, o.l)))
};
});
})(),
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: '2px', 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: '2px', 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: '2px', 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: '2px' }
})),
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: '2px', 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 = [
{ id: 'marketing', name: 'Marketing site', branch: 'veect/marketing-refresh', meta: (this.frames().length || 2) + ' screens · edited 2h ago', state: 'behind', status: '2 behind base', live: true },
{ id: 'appshell', name: 'App shell', branch: 'veect/app-shell', meta: '6 screens · edited 3d ago', state: 'published', status: 'PR #218 open', live: true },
{ id: 'pricing', name: 'Pricing', branch: 'veect/pricing-v2', meta: '3 screens · edited 5d ago', state: 'sync', status: 'in sync', live: true }
].filter(p => !q || p.name.toLowerCase().includes(q) || p.branch.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 stColor = (st) => st === 'published' ? 'var(--iris-t)' : st === 'behind' ? 'var(--warn)' : 'var(--live)';
const projectCards = projects.map(p => ({
name: p.name, meta: p.meta, branch: p.branch,
chip: p.status,
chipStyle: { fontFamily: this.MONO, fontSize: '9px', padding: '2px 8px', borderRadius: '2px', flex: '0 0 auto', color: stColor(p.state), background: p.state === 'behind' ? 'rgba(224,160,58,.12)' : p.state === 'published' ? 'var(--iris-dim)' : 'rgba(91,127,94,.14)' },
onOpen: () => { this.setState({ activeDesign: p.id }); this.enterEditor(false); },
cardStyle: { background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: '2px', overflow: 'hidden', display: 'flex', flexDirection: 'column', alignItems: 'stretch', textAlign: 'left', transition: 'border-color .15s, transform .15s', cursor: 'pointer' },
thumbStyle: { position: 'relative', height: '140px', background: sys.n[50], borderBottom: '1px solid var(--line)', overflow: 'hidden' },
thumbA: thumbBlock(sys.ramp[500], '34%', '10px', '8%', '14%', '99px'),
thumbB: thumbBlock(sys.n[200], '60%', '8px', '8%', '34%'),
thumbC: thumbBlock(sys.ramp[100], '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: '2px', flex: '0 0 auto' };
return {
glyph: c.glyph, name: c.name,
path: c.prim ? 'semantic HTML — no import' : unmapped ? '@veect/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: 'veect', name: 'Veect 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: '2px', textAlign: 'left' }
}));
const prefChip = (on) => ({ fontFamily: this.MONO, fontSize: '9.5px', padding: '3px 9px', borderRadius: '2px', 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: '2px', 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: '2px', 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', cx: 'choose' }),
goOnboardingHome: () => this.setState({ screen: 'onboarding', ob: 'welcome', cx: 'choose' }),
homeMeta: '3 projects · each on its branch',
goSettingsConn: () => this.go('settings'),
onWs: () => this.setState(st => ({ wsMenu: !st.wsMenu })),
wsMenuOpen: s.wsMenu,
wsLabel: ({ solstice: 'Solstice', fieldwork: 'Fieldwork' }[s.activeWs || 'solstice']) || 'Solstice',
wsInitial: (({ solstice: 'S', fieldwork: 'F' }[s.activeWs || 'solstice']) || 'S'),
wsItems: [
{ id: 'solstice', name: 'Solstice', repo: 'github.com/solstice/web', initial: 'S' },
{ id: 'fieldwork', name: 'Fieldwork', repo: 'github.com/fieldwork/app', initial: 'F' }
].map(w => {
const active = (s.activeWs || 'solstice') === w.id;
return {
name: w.name, repo: w.repo, initial: w.initial, active,
onPick: () => { this.setState({ activeWs: w.id, wsMenu: false }); if (!active) this.toast('Switched workspace → ' + w.name + ' · ' + w.repo); },
rowStyle: { display: 'flex', alignItems: 'center', gap: '9px', padding: '8px 12px', width: '100%', background: active ? 'var(--iris-dim)' : 'transparent' },
avStyle: { width: '22px', height: '22px', borderRadius: '2px', background: 'var(--chip)', border: '1px solid var(--line2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: this.MONO, fontSize: '10px', color: 'var(--t2)', flex: '0 0 auto' }
};
}),
connectWs: () => this.setState({ wsMenu: false, screen: 'onboarding', ob: 'welcome', cx: 'choose' }),
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: '22px', height: '22px', borderRadius: '2px', background: sys.ramp[500], boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.25)', flex: '0 0 auto' },
setSysMeta: 'tailwind.config.ts · ' + sys.font + ' · 12 components',
connIcon: this.icon('code', 16),
connAccess: 'write',
connAccessStyle: { fontFamily: this.MONO, fontSize: '9px', letterSpacing: '.04em', padding: '1px 7px', borderRadius: '2px', color: 'var(--live)', background: 'rgba(91,127,94,.14)' },
connMeta: 'GitHub · main · fetched 4m ago',
reauthStub: () => this.toast('Token still valid — re-authorized.'),
envSettingsRows: [
{ key: 'DATABASE_URL', masked: 'postgres://••••••••', src: 'turbo/env', chip: 'set', ok: true },
{ key: 'STRIPE_SECRET_KEY', masked: '— not set —', src: '.env.example', chip: 'needed', ok: false },
{ key: 'NEXT_PUBLIC_APP_URL', masked: 'https://••••••', src: 'next.config', chip: 'set', ok: true }
].map(k => ({ key: k.key, masked: k.masked, src: k.src, chip: k.chip, chipStyle: { fontFamily: this.MONO, fontSize: '9.5px', padding: '2px 8px', borderRadius: '2px', flex: '0 0 auto', color: k.ok ? 'var(--live)' : 'var(--warn)', background: k.ok ? 'rgba(91,127,94,.14)' : 'rgba(224,160,58,.12)' } })),
doctorRows: [
{ name: 'git', st: 'ok', detail: 'git 2.44.0 · found on PATH' },
{ name: 'disk', st: 'ok', detail: '1.2 GB used by this workspace · 84 GB free' },
{ name: 'preview adapter', st: 'ok', detail: 'vite dev server healthy · :5173' },
{ name: 'AI key', st: 'warn', detail: 'no key set — add one to compose · Settings → AI' }
].map(d => ({ name: d.name, detail: d.detail, dot: d.st === 'ok' ? '✓' : d.st === 'warn' ? '!' : '✕', dotStyle: { fontFamily: this.MONO, fontSize: '11px', width: '14px', flex: '0 0 auto', textAlign: 'center', color: d.st === 'ok' ? 'var(--live)' : d.st === 'warn' ? 'var(--warn)' : 'var(--err)' } })),
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: '2px', 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.connectVals(), this.onboardingVals(), this.editorVals(), this.screensVals(), this.overlayVals());
}
}
</script>
</body>
</html>