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
This commit is contained in:
2026-07-12 14:09:30 +02:00
parent c80e09e732
commit 1483a45406
97 changed files with 15685 additions and 1 deletions

View File

@@ -9,7 +9,8 @@
"**/__snapshots__/**", "**/__snapshots__/**",
"**/turbo/generators/templates/**", "**/turbo/generators/templates/**",
"**/*.generated.ts", "**/*.generated.ts",
"**/*.d.ts" "**/*.d.ts",
"docs/product/reference/**"
], ],
"dynamicallyLoaded": [ "dynamicallyLoaded": [
"packages/**/__factories__/**", "packages/**/__factories__/**",

View File

@@ -1,2 +1,5 @@
# Generated compliance artifacts — do not reformat # Generated compliance artifacts — do not reformat
compliance/*.yml compliance/*.yml
# Design reference material (ADR-029) — committed byte-for-byte, never reformat
docs/product/reference/

View File

@@ -0,0 +1,22 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read `app-prototype-development/project/Veect.dc.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `app-prototype-development/README.md` — this file
- `app-prototype-development/project/` — the `App prototype development` project files (HTML prototypes, assets, components)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,251 @@
# Veect — Product & Design Specification
_v1.0 · July 2026 · covers `Veect.dc.html` (interactive prototype), `Veect Landing.dc.html`, and `veect-codebase/` (React 18 · TypeScript strict · Tailwind · shadcn-style)_
---
## 1. Product
**Veect is a design-system-native canvas.** A designer brings their own tokens and
components; Veect composes screens from *only* that system and exports production React
that imports their real library. The three invariants everything hangs on:
1. **One runtime** — canvas, preview, isolation and export render the same tree through
the same renderer. No "roughly how it'll look."
2. **0 unregistered elements** — AI output is whitelist-sanitized against the component
registry; anything outside it is refused, never faked.
3. **Deterministic code** — same tree → same code, every time. Export imports the
customer's library (`@solstice/ui` in the demo), never inlined clones.
Demo fiction: the customer is **Solstice** (warm team-scheduling brand), with designer
Maya (owner) and design engineer Devon (reviewer, owns the merge veto).
---
## 2. Brand & design language
### 2.1 Logo
Typographic wordmark: lowercase **`veect`**, Instrument Sans 600, letter-spacing 0.035em,
line-height 1, closed by a **square ink terminal** (the "vector point") sized ≈ 0.28 × font
size, baseline-aligned, `currentColor`. No pictorial mark. Codebase atom: `Logo size={n}`.
### 2.2 Color — chrome tokens
Color belongs to the customer's canvas; the instrument is monochrome. The accent IS the ink.
| Token | Light (default file value) | Dark |
|---|---|---|
| `--bg` / `--panel` | `#FCFCFB` | `#0E0E0D` |
| `--raised` / `--chip` | `#FFFFFF` / `#F4F3F0` | `#161615` |
| `--line` / `--line2` | `#E7E6E2` / `#CFCEC8` | `#262624` / `#3A3A37` |
| `--t1` / `--t2` / `--t3` | `#161513` / `#63615B` / `#9A978F` | `#F4F4F2` / `#A6A6A1` / `#73736E` |
| `--iris` (accent = ink) | `#161513` | `#F4F4F2` |
| `--iris-dim` | `rgba(22,21,19,.06)` | `rgba(244,244,242,.09)` |
| `--btn-ink` (text on accent) | `#FFFFFF` | `#0E0E0D` |
| `--warn` / `--err` / `--live` | `#9A7B3F` / `#B05E5E` / `#5B7F5E` | `#C2A36B` / `#C97070` / `#8BA88E` |
Amber (`--warn`) is reserved for AI-scope affordances (pick outline, scope chips, draw
strokes) and the unmapped-component warning. The prototype ships **dark by default**
(user preference); the codebase mirrors this (`index.html data-theme="dark"`).
### 2.3 Surfaces & elevation
Flat. Radius **2px everywhere** in chrome (customer canvas keeps its own `radius-md`
token). No floating shadows — separation is a hairline: popovers/menus use
`box-shadow: 0 0 0 1px var(--line2)`. Canvas frames sit on the board with a 1px
`rgba(0,0,0,.14)` ring.
### 2.4 Typography
- **UI**: Instrument Sans 400/500/600 — 13px base, 1112.5px controls.
- **Labels**: Fragment Mono, 911px, uppercase, letter-spacing .06.14em — all section
headers, metadata, and "engraved" captions.
- **Customer content**: the system's own font (demo: Bricolage Grotesque) — never used
for chrome.
- Scale discipline: chrome text never below 8px (activity-bar labels), canvas display
type responsive per view (§6).
### 2.5 Iconography
Hand-drawn 16-grid SVG, 1.5px stroke, round caps, `currentColor`. Set: chat (spark),
layers, library, tokens, history, inspect (sliders), polish (spark-4), code (chevrons),
pick (crosshair), draw (pen), eye / eye-off, mic. No icon fonts, no emoji.
### 2.6 Motion
120450ms, `cubic-bezier(.2,.7,.2,1)`. Keyframes: `riseIn`, `popIn`, `fadeIn`,
`lineFlash` (code sync), `nodePulse` (token ripple). Everything respects
`prefers-reduced-motion`.
### 2.7 Voice
Plainspoken, no hype, no emoji. Refusals teach ("You don't have a component for this
yet"), fixes explain the *why* in the user's own tokens.
---
## 3. Screens
| Screen | Purpose | Key elements |
|---|---|---|
| **Sign in** | design-partner entry | email → straight through; SSO stubs; provisioning note |
| **Home** | projects + systems | project grid (1 live + placeholders), search, Solstice system card (ramp, open tokens, re-import), "bring a design system" |
| **Onboarding** | the re-theme reveal | two paths: paste tokens (parse → contrast checks) or 5-token quick path (brand, neutrals, radius, type, spacing) with live mini-kit; ends "Bring it to life →" |
| **Editor** | the canvas (§45) | |
| **Settings** | system + governance | token source card, component mappings ("merge bar"), craft standard picker, export defaults, plan, delete |
| **Profile** | account | identity card, theme preference, workspace members (Maya/Devon), keyboard map, sign out |
| **System update** | US-6 re-import | paste → diff (CHANGED/ADDED/REMOVED + swatches) → impact line → apply-with-undo → ripple |
| **Landing** | marketing (§10) | |
Navigation: project dropdown (top-left) is the hub — all projects, new project, export,
copy TSX, import tokens, upload component, settings, profile, onboarding demo. Escape and
outside-click dismiss. Avatar → Profile everywhere.
---
## 4. Editor anatomy
### 4.1 Top bar (36px)
Wordmark · project dropdown · `✓ saved` · **view switcher** (centered: Desktop / Tablet /
Mobile / ⧉ All) · Preview · **Export** (primary) · theme toggle ◐ · avatar.
### 4.2 Activity bar (44px, fixed left)
Toggleable icon+label buttons, grouped by dividers:
`✦ Chat``Layers · Library · Tokens · History``Inspect · Polish``Code`.
Click = open that panel/tab; click again = collapse. Active = `--iris-dim` chip. At
overlay widths (<1000px) panels are mutually exclusive and float over the board.
### 4.3 Panels (resizable via edge-drag; « » collapse chevrons)
- **Chat** (284px) vertical AI thread 7): bubbles, proposal Accept/Discard inline,
refusal actions, model picker (haiku/sonnet), image attachments, scope chip.
- **Board rail** (216px) LAYERS: collapsible tree (caret-expand, double-click reveal),
per-frame **variant chips** 6); LIBRARY: 10-component base kit with mapping badges,
isolation ◉, add +, drag-to-canvas, patterns, custom `.tsx/.jsx` upload; TOKENS: brand
ramp + curated swatches + hex, radius stepper, type picker, neutrals, spacing base
every change ripples live (`nodePulse`); HISTORY: labelled steps, restore cursor 8).
- **Inspector** (244px) selection card, Edit-with-AI (scoped), content/variant/weight/
tone/size controls, gap/pad steppers (system-scale steps), Tailwind utilities
(collapsible; w/h/max-w/shadow/opacity selects + custom string), token bindings,
FRAME settings (name, width ±80, **active view** line). Polish tab: §7.3.
- **Code** (380px, dark `#161311` always) file segs (`{Frame}.tsx` / `tokens.css`),
line-numbered TSX with click-lineselect-node sync and `lineFlash` on change; **edit
mode** (toggle syntax-highlighted textarea apply parses back via the generated
grammar, line-referenced errors); footer `prettier ✓ tsc ✓`.
- **Isolation** (over board) variant × state grid per component on a paper stage, real
runtime, Esc exits.
### 4.4 Board
Infinite surface: scroll pans, ⌘/ctrl-scroll zooms at cursor, two-finger pinch, drag
empty board pans, background click deselects. Zoom cluster ( % + · fit · + frame ·
board backdrop toggle). Frames render as **view clusters** 6) with a draggable name
tab (`Home · 34 nodes · one codebase`). Selection = ink ring; hover = neutral ring;
AI-target = amber ring. Tool pill (bottom-center): **Pick (V) · Draw (D) · Overlays (O)
· Voice (stub)** icon buttons with key hints.
### 4.5 Preview
Full-screen overlay, real hover/focus states, active view's width fills the screen
(mobile/tablet render as a centered device sheet).
### 4.6 Keyboard
`⌘K` commands · `✦` focus composer · `⌘Z/⇧⌘Z` undo/redo · `⌘0` fit · `⌘1` 100% ·
`⌘±` zoom · `V/D/O` tools · `⌫` delete selection · `Esc` dismiss cascade (menu modes
refusal proposal scope selection).
---
## 5. Canvas node model
`board → frame[] → (stack | card | heading | text | button | badge | input | avatar |
image | divider | custom)*`. Layout via `dir/gap/pad/padX/padY/align/justify/maxW/bg`
(4px scale). Content props per type; `tw` bag for curated utility classes. Frames carry
`x/y/w/views/view`. IDs are stable; comment anchors and code-line mapping key off them.
---
## 6. Responsive variants — one tree, three widths
- Views: **desktop** (frame's own `w`, default 1280) · **tablet 768** · **mobile 390**.
- A frame renders every enabled view side by side (120px gap) same children, so an
edit anywhere updates every view. Captions (`tablet · 768`) label each block; the
**active view** (drives preview, code, export width) is ink-highlighted.
- Renderer responds to view width: row-stacks wrap 900, cards get flex-basis 240,
display type steps down (xl: 54 44 34px) 900/≤480.
- Switcher semantics: Desktop/Tablet/Mobile = show only that view + make it active;
** All** = all three. Layer chips: label click = select frame + make active (opens
frame settings); eye icon = show/hide that view (min 1). Cluster growth shifts
right-neighbors by the width delta clusters never collide.
---
## 7. AI, constrained
### 7.1 Compose
Prompt system prompt embeds the registry JSON (types + allowed props) + brand voice
rules (one primary CTA per group, 4px spacing, no lorem). Response is sanitized: unknown
type **refusal**, numeric props clamped to scale, strings capped. Result stages as a
**dashed ghost** on the active frame Accept commits (history label `AI compose —
accepted`), Discard drops.
### 7.2 Scoped edits
Pick tool (V) or Inspector "✦ Edit with AI": amber target ring + composer scope chip
(`h1 · Scheduling…`), inline bubble at cursor, type-aware quick actions (punchier /
shorter / demote / colorize ×n…). Edit proposals ghost beside the original and swap in
on Accept.
### 7.3 Polish (the Impeccable pass)
Ranked issues (HIGH/MED/LOW): weak hierarchy (display heading at 400), competing primary
CTAs, off-scale gaps (`gap-[18px]` drift), uneven sibling padding, AA contrast failures
(with measured ratios). Each: title *why it matters* one-click fix **in the user's
tokens**. Fix-all; empty state "Impeccable. Nothing to fix." Craft standard selectable
in Settings (Veect default / Impeccable / custom DESIGN.md).
### 7.4 Refusal protocol
Off-registry ask (3D globe, video, charts…) "You don't have a component for this yet"
+ reason + **Compose from primitives** / Not now. Never generic markup. Models: Haiku 4.5
default, Sonnet 4.5 option; offline heuristics keep demos deterministic.
---
## 8. History
Every mutation is a labelled step (`Add Button`, `Polish — Two primary actions`,
`View → tablet`, `AI edit — accepted`…). History tab lists steps newest-first with age.
Clicking restores that state and sets a **cursor**: newer steps dim (35%); the next
meaningful edit truncates the dimmed trail. `⌘Z` walks steps; redo restores.
---
## 9. Code generation & round-trip
| Canvas | Export | Notes |
|---|---|---|
| heading/text | `<h1h4>/<p>` + Tailwind | size/weight/tone classes, neutral ramp |
| button | `<Button kind size>` | `variant→kind` rename shown in mappings |
| card | `<Card>` | pad/gap classes |
| input | `<TextField label placeholder />` | |
| avatar | `<Avatar name />` | |
| badge | `@veect/base-kit` fallback + TODO | until mapped "map now" chip |
| stack | `<div>/<nav>` flex classes | |
| image/divider | semantic HTML | |
| custom upload | real `import X from "./components/…"` | props sniffed from source |
`tokens.css` generates from the live system. The **edit-mode parser** accepts exactly
this grammar back (line-referenced errors otherwise) the grammar runs both ways.
Export dialog: honest file tree, `npm i && npm run dev`, copy `{Frame}.tsx`, fidelity
badge. Guarantee line: *deterministic — same tree, same code*.
---
## 10. Landing page
Carbon-mono flat (same language). Sections: fixed nav (scroll-spy underline, blur on
scroll) · Hero ("Your system in. Real React out.", masked line reveal, magnetic CTAs,
live 5-token kit demo with 3D tilt) · proof strip (0 unregistered / <10% LOC / <20 min)
· three-pillar wedge · graceful-refusal chat (typewriter) · code panel (staggered lines)
· Polish demo (run/undo on a Solstice card) · CTA. Left ruler tracks scroll. GSAP +
ScrollTrigger, all reduced-motion-safe.
---
## 11. Codebase map & seams
See `veect-codebase/README.md` for the atomic-design map (config / engine / store /
hooks / ui / atoms / molecules / organisms / templates / pages), product invariants,
and **Handoff notes** the deliberate seams: model bridge (`engine/ai.ts#complete`),
export zip, placeholder projects, auth, persistence. Board substrate: React Flow
(`@xyflow/react`, MIT) frames as custom nodes, zustand as single source of truth.
Pre-flight: `npm run typecheck && npm run lint`.

View File

@@ -0,0 +1,437 @@
<!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,600;12..96,700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<style>
html,body{margin:0;padding:0;background:#0E0E0D;}
html{scroll-behavior:smooth;}
body{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;}
a{color:#F4F4F2;text-decoration:none} a:hover{color:#F4F4F2}
*{box-sizing:border-box}
button{font:inherit;color:inherit;background:none;border:none;padding:0;cursor:pointer;text-align:left}
::selection{background:rgba(127,127,120,.3)}
*:focus-visible{outline:2px solid #F4F4F2;outline-offset:2px;border-radius:2px}
@keyframes cursorBlink{0%,100%{opacity:1}50%{opacity:0}}
@media (prefers-reduced-motion: reduce){*{animation-duration:.01ms !important;transition-duration:.01ms !important}html{scroll-behavior:auto}}
</style>
</helmet>
<div style="background:#0E0E0D;color:#F4F4F2;font-family:'Instrument Sans',sans-serif;font-size:15px;line-height:1.5;overflow-x:hidden;">
<div data-ruler style="position:fixed;left:0;top:0;bottom:0;width:34px;z-index:40;border-right:1px solid #161615;background:repeating-linear-gradient(to bottom,transparent 0 11px,#262624 11px 12px);pointer-events:none;">
<div data-ruler-dot style="position:absolute;left:0;top:0;width:34px;height:2px;background:#F4F4F2;"></div>
</div>
<nav data-nav style="position:fixed;top:0;left:0;right:0;z-index:50;display:flex;align-items:center;gap:clamp(12px,2.5vw,26px);padding:14px clamp(16px,4vw,40px) 14px clamp(50px,7vw,66px);transition:background .25s,border-color .25s;border-bottom:1px solid transparent;">
<a href="#top" style="display:flex;align-items:center;gap:9px;color:#F4F4F2;">
<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>
</a>
<span style="flex:1"></span>
<span data-navlinks style="display:flex;align-items:center;gap:clamp(12px,2.5vw,26px);">
<a href="#system" style="font-size:13px;color:#A6A6A1;">System</a>
<a href="#refusal" style="font-size:13px;color:#A6A6A1;">Constraint</a>
<a href="#code" style="font-size:13px;color:#A6A6A1;">Code</a>
<a href="#craft" style="font-size:13px;color:#A6A6A1;">Craft</a>
</span>
<a href="Veect.dc.html" style="background:#F4F4F2;color:#0E0E0D;padding:8px 16px;border-radius:2px;font-size:13px;font-weight:600;">Open the canvas</a>
</nav>
<section id="top" data-screen-label="Hero" style="min-height:100vh;display:flex;align-items:center;padding:110px clamp(18px,5vw,40px) 70px clamp(50px,7vw,66px);position:relative;overflow:hidden;">
<div style="width:100%;max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:64px;align-items:center;position:relative;">
<div style="flex:1 1 420px;display:flex;flex-direction:column;align-items:flex-start;">
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:11px;letter-spacing:.14em;color:#82827C;">DESIGN-SYSTEM-NATIVE CANVAS</span>
<h1 style="font-size:clamp(40px,6.2vw,76px);line-height:1.05;letter-spacing:-2.2px;font-weight:600;margin:22px 0 0;">
<span style="display:block;overflow:hidden;"><span data-hline style="display:block;">Your system in.</span></span>
<span style="display:block;overflow:hidden;"><span data-hline style="display:block;">Real React out.</span></span>
</h1>
<p data-reveal style="font-size:17px;line-height:1.6;color:#A6A6A1;max-width:460px;margin:22px 0 0;text-wrap:pretty;">Veect composes screens from your own tokens and components — and the code it exports imports your real library. Nothing generic, nothing thrown away.</p>
<div data-reveal style="display:flex;flex-wrap:wrap;gap:12px;margin-top:32px;align-items:center;">
<a data-magnetic href="Veect.dc.html" style="display:inline-block;background:#F4F4F2;color:#0E0E0D;padding:13px 22px;border-radius:2px;font-size:14.5px;font-weight:600;">Open the canvas →</a>
<a data-magnetic href="#code" style="display:inline-block;border:1px solid #3A3A37;color:#F4F4F2;padding:13px 22px;border-radius:2px;font-size:14.5px;font-weight:500;">Read the code</a>
</div>
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#5A5A56;margin-top:26px;">try it — the panel on the right is live</span>
</div>
<div data-reveal style="flex:1 1 360px;max-width:430px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;flex-direction:column;gap:12px;background:#161615;border:1px solid #262624;border-radius:2px;padding:16px;">
<span style="font-family:'Fragment Mono',monospace;font-size:10px;letter-spacing:.1em;color:#82827C;">FIVE TOKENS — WATCH THE KIT FOLLOW</span>
<div style="display:flex;gap:10px;align-items:center;">
<sc-for list="{{ brandSwatches }}" as="item" hint-placeholder-count="4">
<button onClick="{{ item.onPick }}" title="{{ item.hex }}" style="{{ item.style }}"></button>
</sc-for>
<span style="flex:1"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#82827C;">{{ brandHex }}</span>
</div>
<div style="display:flex;gap:12px;align-items:center;">
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#82827C;width:52px;">radius</span>
<input type="range" min="0" max="20" step="2" value="{{ radiusVal }}" onChange="{{ onRadius }}" style="flex:1;accent-color:#F4F4F2;">
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#A6A6A1;width:34px;text-align:right;">{{ radiusLabel }}</span>
</div>
<div style="display:flex;gap:6px;">
<sc-for list="{{ fontSegs }}" as="item" hint-placeholder-count="3">
<button onClick="{{ item.onClick }}" style="{{ item.style }}">{{ item.label }}</button>
</sc-for>
</div>
</div>
<div data-kit style="{{ kitVars }}">
<div style="display:flex;align-items:center;gap:10px;">
<span style="background:var(--ka);color:#fff;border-radius:var(--krb);padding:10px 18px;font-family:var(--kf);font-weight:600;font-size:14px;transition:all .45s cubic-bezier(.2,.7,.2,1);">Get started</span>
<span style="border:1px solid var(--kl);color:var(--ki);border-radius:var(--krb);padding:10px 18px;font-family:var(--kf);font-weight:600;font-size:14px;transition:all .45s cubic-bezier(.2,.7,.2,1) .05s;">Docs</span>
<span style="flex:1"></span>
<span style="background:var(--kd);color:var(--kas);border-radius:2px;padding:4px 12px;font-family:var(--kf);font-weight:600;font-size:11px;transition:all .45s cubic-bezier(.2,.7,.2,1) .1s;">NEW</span>
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<span style="font-size:12.5px;font-weight:600;color:var(--ki);font-family:var(--kf);transition:all .45s cubic-bezier(.2,.7,.2,1) .12s;">Email</span>
<div style="border:1px solid var(--kl);border-radius:var(--krb);padding:10px 13px;color:var(--km);font-size:13.5px;font-family:var(--kf);transition:all .45s cubic-bezier(.2,.7,.2,1) .15s;">you@company.com</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span style="width:34px;height:20px;border-radius:2px;background:var(--ka);position:relative;transition:all .45s cubic-bezier(.2,.7,.2,1) .2s;"><span style="position:absolute;top:2.5px;right:2.5px;width:15px;height:15px;border-radius:2px;background:#fff;"></span></span>
<span style="font-size:12.5px;color:var(--ki);font-family:var(--kf);font-weight:500;transition:all .45s cubic-bezier(.2,.7,.2,1) .2s;">Notifications</span>
<span style="flex:1"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:#8A8279;">your tokens · live</span>
</div>
</div>
</div>
</div>
</section>
<section data-screen-label="Proof" style="padding:0 clamp(18px,5vw,40px) 0 clamp(50px,7vw,66px);">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:clamp(24px,5vw,64px);padding:30px 0;border-top:1px solid #262624;">
<div data-reveal style="display:flex;flex-direction:column;gap:7px;min-width:170px;flex:1;">
<span style="font-family:'Fragment Mono',monospace;font-size:30px;color:#F4F4F2;letter-spacing:-1px;">0</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#82827C;letter-spacing:.08em;line-height:1.6;">UNREGISTERED ELEMENTS<br>ACROSS A 100-PROMPT EVAL</span>
</div>
<div data-reveal style="display:flex;flex-direction:column;gap:7px;min-width:170px;flex:1;">
<span style="font-family:'Fragment Mono',monospace;font-size:30px;color:#F4F4F2;letter-spacing:-1px;">&lt;10%</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#82827C;letter-spacing:.08em;line-height:1.6;">LOC CHANGED PRE-MERGE —<br>THE ENGINEER IS THE JUDGE</span>
</div>
<div data-reveal style="display:flex;flex-direction:column;gap:7px;min-width:170px;flex:1;">
<span style="font-family:'Fragment Mono',monospace;font-size:30px;color:#F4F4F2;letter-spacing:-1px;">&lt;20 min</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#82827C;letter-spacing:.08em;line-height:1.6;">SIGN-IN TO YOUR FIRST<br>EXPORTED, ON-BRAND SCREEN</span>
</div>
</div>
</section>
<section id="system" data-screen-label="Wedge" style="padding:clamp(64px,10vw,110px) clamp(18px,5vw,40px) clamp(64px,10vw,110px) clamp(50px,7vw,66px);border-top:1px solid #262624;">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-direction:column;">
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:11px;letter-spacing:.14em;color:#82827C;">WHY IT HOLDS</span>
<h2 data-reveal style="font-size:clamp(28px,3.6vw,44px);letter-spacing:-1.2px;font-weight:600;margin:18px 0 0;max-width:640px;text-wrap:balance;">Other tools re-invent your UI. Veect is constrained to it.</h2>
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-top:48px;">
<div data-reveal style="flex:1 1 280px;background:#161615;border:1px solid #262624;border-radius:2px;padding:26px;display:flex;flex-direction:column;gap:10px;">
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;letter-spacing:.1em;color:#F4F4F2;">01 — YOUR COMPONENTS</span>
<span style="font-size:16px;font-weight:600;">The canvas renders the real thing</span>
<span style="font-size:13.5px;line-height:1.6;color:#A6A6A1;">One runtime backs canvas, preview and export. "Roughly how it'll look" is the lie that makes handoff lossy — Veect never tells it.</span>
</div>
<div data-reveal style="flex:1 1 280px;background:#161615;border:1px solid #262624;border-radius:2px;padding:26px;display:flex;flex-direction:column;gap:10px;">
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;letter-spacing:.1em;color:#F4F4F2;">02 — YOUR TOKENS</span>
<span style="font-size:16px;font-weight:600;">Change once, everywhere</span>
<span style="font-size:13.5px;line-height:1.6;color:#A6A6A1;">Edit brand-500 and watch every bound component ripple on canvas and in code — the system stays the single source of truth.</span>
</div>
<div data-reveal style="flex:1 1 280px;background:#161615;border:1px solid #262624;border-radius:2px;padding:26px;display:flex;flex-direction:column;gap:10px;">
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;letter-spacing:.1em;color:#F4F4F2;">03 — REAL IMPORTS</span>
<span style="font-size:16px;font-weight:600;">Code an engineer would merge</span>
<span style="font-size:13.5px;line-height:1.6;color:#A6A6A1;">Export imports your mapped library — import paths, prop names and all. Not inlined clones. That is the whole trust bar.</span>
</div>
</div>
</div>
</section>
<section id="refusal" data-screen-label="Refusal" style="padding:clamp(64px,10vw,110px) clamp(18px,5vw,40px) clamp(64px,10vw,110px) clamp(50px,7vw,66px);border-top:1px solid #262624;">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:56px;align-items:center;">
<div style="flex:1 1 380px;display:flex;flex-direction:column;align-items:flex-start;">
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:11px;letter-spacing:.14em;color:#82827C;">THE GRACEFUL REFUSAL</span>
<h2 data-reveal style="font-size:clamp(28px,3.6vw,44px);letter-spacing:-1.2px;font-weight:600;margin:18px 0 0;text-wrap:balance;">When AI can't stay on-system, it says so.</h2>
<p data-reveal style="font-size:15px;line-height:1.65;color:#A6A6A1;max-width:440px;margin:18px 0 0;">Ask for something your registry can't express and Veect refuses — then offers to compose it from your primitives. It never fails open to generic markup. Across a 100-prompt eval: zero unregistered elements.</p>
</div>
<div data-reveal style="flex:1 1 360px;max-width:470px;background:#161615;border:1px solid #262624;border-radius:2px;padding:18px;display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:flex-end;">
<span style="background:rgba(244,244,242,.10);border:1px solid rgba(244,244,242,.22);border-radius:2px 12px 4px 12px;padding:9px 13px;font-size:13px;">A hero with a spinning 3D globe</span>
</div>
<div style="display:flex;">
<div style="background:#161615;border:1px solid #262624;border-radius:2px 12px 12px 4px;padding:11px 14px;font-size:13px;line-height:1.6;max-width:88%;display:flex;flex-direction:column;gap:9px;">
<span data-typewriter style="color:#F4F4F2;min-height:42px;">You don't have a component for this yet.</span>
<span data-refactions style="display:flex;gap:8px;flex-wrap:wrap;">
<span style="border:1px solid #3A3A37;color:#F4F4F2;padding:6px 11px;border-radius:2px;font-size:12px;font-weight:500;">Compose from primitives</span>
<span style="color:#82827C;padding:6px 4px;font-size:12px;">Not now</span>
</span>
</div>
</div>
<span style="font-family:'Fragment Mono',monospace;font-size:9.5px;color:#5A5A56;">an error state, elevated to a signature moment</span>
</div>
</div>
</section>
<section id="code" data-screen-label="Code" style="padding:clamp(64px,10vw,110px) clamp(18px,5vw,40px) clamp(64px,10vw,110px) clamp(50px,7vw,66px);border-top:1px solid #262624;">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:56px;align-items:center;">
<div data-reveal style="flex:1 1 400px;max-width:540px;background:#161311;border:1px solid #262624;border-radius:2px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid #262624;">
<span style="font-family:'Fragment Mono',monospace;font-size:10.5px;color:#F4F4F2;background:#1B1B1A;border:1px solid #262624;border-radius:2px;padding:3px 8px;">Home.tsx</span>
<span style="flex:1"></span>
<span style="font-size:11px;color:#A6A6A1;">in sync</span>
</div>
<div style="padding:14px 0;font-family:'Fragment Mono',monospace;font-size:12px;line-height:1.8;">
<div data-codeline style="padding:0 16px;white-space:pre;color:#F4F4F2;">import <span style="color:#A6A6A1;">{ Button, Card, TextField }</span> from <span style="color:#D9A961;">"@solstice/ui"</span>;</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#5A5A56;">&#160;</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"><span style="color:#F4F4F2;">export default function</span> <span style="color:#F4F4F2;font-weight:600;">Home</span>() {</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"> <span style="color:#F4F4F2;">return</span> (</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"> &lt;main className=<span style="color:#D9A961;">"flex flex-col bg-white"</span>&gt;</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"> &lt;<span style="color:#F4F4F2;font-weight:600;">Button</span> kind=<span style="color:#D9A961;">"primary"</span>&gt;Start free&lt;/<span style="color:#F4F4F2;font-weight:600;">Button</span>&gt;</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"> &lt;/main&gt;</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;"> );</div>
<div data-codeline style="padding:0 16px;white-space:pre;color:#A6A6A1;">}</div>
</div>
<div style="display:flex;gap:12px;padding:9px 14px;border-top:1px solid #262624;font-family:'Fragment Mono',monospace;font-size:9.5px;color:#73736E;">
<span>prettier ✓</span><span>tsc --noEmit ✓</span><span style="flex:1"></span><span>imports your library</span>
</div>
</div>
<div style="flex:1 1 380px;display:flex;flex-direction:column;align-items:flex-start;">
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:11px;letter-spacing:.14em;color:#82827C;">TRUST IS EARNED IN THE OPEN</span>
<h2 data-reveal style="font-size:clamp(28px,3.6vw,44px);letter-spacing:-1.2px;font-weight:600;margin:18px 0 0;text-wrap:balance;">The code is a keystroke away — and it's yours.</h2>
<p data-reveal style="font-size:15px;line-height:1.65;color:#A6A6A1;max-width:440px;margin:18px 0 0;">Nudge a component and the code updates in lockstep. Deterministic output — same tree, same code — that passes Prettier, tsc and your reviewer's patience.</p>
</div>
</div>
</section>
<section id="craft" data-screen-label="Craft" style="padding:clamp(64px,10vw,110px) clamp(18px,5vw,40px) clamp(64px,10vw,110px) clamp(50px,7vw,66px);border-top:1px solid #262624;">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:56px;align-items:center;">
<div style="flex:1 1 380px;display:flex;flex-direction:column;align-items:flex-start;">
<span data-reveal style="font-family:'Fragment Mono',monospace;font-size:11px;letter-spacing:.14em;color:#82827C;">POLISH — THE IMPECCABLE PASS</span>
<h2 data-reveal style="font-size:clamp(28px,3.6vw,44px);letter-spacing:-1.2px;font-weight:600;margin:18px 0 0;text-wrap:balance;">On-brand and genuinely well-designed.</h2>
<p data-reveal style="font-size:15px;line-height:1.65;color:#A6A6A1;max-width:440px;margin:18px 0 0;">One pass critiques spacing, hierarchy and contrast against a craft standard — and fixes them in your tokens, with the why. Try it.</p>
<div data-reveal style="display:flex;flex-direction:column;gap:8px;margin-top:22px;">
<sc-for list="{{ polishChips }}" as="item" hint-placeholder-count="3">
<span style="{{ item.style }}">{{ item.label }}</span>
</sc-for>
</div>
</div>
<div data-reveal style="flex:1 1 360px;max-width:470px;display:flex;flex-direction:column;gap:12px;">
<div style="background:#FFFFFF;border:1px solid #ECE8E2;border-radius:2px;padding:34px 30px;display:flex;flex-direction:column;align-items:center;text-align:center;overflow:hidden;">
<span style="{{ demoBadgeStyle }}">New — shared team calendars</span>
<span style="{{ demoHeadStyle }}">Scheduling that feels like sunlight</span>
<span style="{{ demoSubStyle }}">Every timezone, handled kindly.</span>
<div style="{{ demoCtaRowStyle }}">
<span style="{{ demoCta1Style }}">Start free</span>
<span style="{{ demoCta2Style }}">Book a demo</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<button onClick="{{ togglePolish }}" style="{{ polishBtnStyle }}">{{ polishBtnLabel }}</button>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#5A5A56;">{{ polishNote }}</span>
</div>
</div>
</div>
</section>
<section data-screen-label="CTA" style="padding:clamp(80px,12vw,130px) clamp(18px,5vw,40px) 60px clamp(50px,7vw,66px);border-top:1px solid #262624;">
<div style="max-width:1120px;margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:28px;">
<h2 data-reveal style="font-size:clamp(34px,5vw,64px);letter-spacing:-1.8px;font-weight:600;line-height:1.05;max-width:760px;text-wrap:balance;">What you design is what ships.</h2>
<a data-reveal data-magnetic href="Veect.dc.html" style="display:inline-block;background:#F4F4F2;color:#0E0E0D;padding:15px 26px;border-radius:2px;font-size:15px;font-weight:600;">Open the canvas →</a>
<div style="display:flex;flex-wrap:wrap;gap:24px;align-items:center;width:100%;margin-top:60px;padding-top:24px;border-top:1px solid #262624;">
<span style="display:flex;align-items:center;gap:8px;">
<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>
</span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#5A5A56;">working name · design-partner MVP</span>
<span style="flex:1"></span>
<span style="font-family:'Fragment Mono',monospace;font-size:10px;color:#5A5A56;">tokens in · react out · impeccable by default</span>
</div>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { brand: '#E4572E', radius: 12, font: 'Bricolage Grotesque', polished: false };
BRANDS = ['#E4572E', '#2A6FDB', '#1F8A5B', '#111827'];
FONTS = ['Bricolage Grotesque', 'Space Grotesk', 'Instrument Sans'];
MONO = "'Fragment Mono',monospace";
hexRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
mix(a, b, t) {
const x = this.hexRgb(a), y = this.hexRgb(b);
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return '#' + c(x[0] + (y[0] - x[0]) * t) + c(x[1] + (y[1] - x[1]) * t) + c(x[2] + (y[2] - x[2]) * t);
}
componentDidMount() {
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const g = window.gsap;
const tw = document.querySelector('[data-typewriter]');
if (tw) {
const full = "You don't have a component for this yet. Want me to compose one from your primitives?";
let started = false;
const acts = document.querySelector('[data-refactions]');
if (acts && !reduced) { acts.style.opacity = '0'; acts.style.transform = 'translateY(6px)'; }
const showActs = () => { if (acts && g) g.to(acts, { opacity: 1, y: 0, duration: 0.5, ease: 'power2.out' }); else if (acts) acts.style.opacity = '1'; };
const start = () => {
if (started) return; started = true;
if (reduced || !tw) { tw.textContent = full; showActs(); return; }
tw.textContent = '';
let i = 0;
this.tick = setInterval(() => {
i += 1;
tw.textContent = full.slice(0, i);
if (i >= full.length) { clearInterval(this.tick); showActs(); }
}, 22);
};
const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) start(); }), { threshold: 0.4 });
io.observe(tw); this.io = io;
}
if (g && window.ScrollTrigger && !reduced) {
g.registerPlugin(window.ScrollTrigger);
document.querySelectorAll('[data-reveal]').forEach((el) => {
g.fromTo(el, { y: 26, opacity: 0 }, { y: 0, opacity: 1, duration: 0.7, ease: 'power3.out', scrollTrigger: { trigger: el, start: 'top 86%', once: true } });
});
document.querySelectorAll('[data-codeline]').forEach((el, i) => {
g.fromTo(el, { x: 14, opacity: 0 }, { x: 0, opacity: 1, duration: 0.45, ease: 'power2.out', delay: i * 0.07, scrollTrigger: { trigger: el.parentElement, start: 'top 80%', once: true } });
});
}
// headline mask reveal
if (g && !reduced) {
g.fromTo('[data-hline]', { yPercent: 112 }, { yPercent: 0, duration: 0.9, ease: 'power4.out', stagger: 0.12, delay: 0.15 });
}
// 3D tilt on the live kit card
const kit = document.querySelector('[data-kit]');
if (kit && !reduced && window.matchMedia('(pointer:fine)').matches) {
const par = kit.parentElement;
if (par) par.style.perspective = '1100px';
kit.style.willChange = 'transform';
this.onKitMove = (ev) => {
const r = kit.getBoundingClientRect();
const rx = ((ev.clientY - r.top) / r.height - 0.5) * -8;
const ry = ((ev.clientX - r.left) / r.width - 0.5) * 10;
if (g) g.to(kit, { rotateX: rx, rotateY: ry, duration: 0.5, ease: 'power2.out', transformPerspective: 1100 });
};
this.onKitLeave = () => { if (g) g.to(kit, { rotateX: 0, rotateY: 0, duration: 0.7, ease: 'power3.out' }); };
kit.addEventListener('pointermove', this.onKitMove);
kit.addEventListener('pointerleave', this.onKitLeave);
}
// magnetic CTAs
if (g && !reduced && window.matchMedia('(pointer:fine)').matches) {
document.querySelectorAll('[data-magnetic]').forEach((el) => {
el.addEventListener('pointermove', (ev) => {
const r = el.getBoundingClientRect();
g.to(el, { x: (ev.clientX - r.left - r.width / 2) * 0.18, y: (ev.clientY - r.top - r.height / 2) * 0.25, duration: 0.4, ease: 'power2.out' });
});
el.addEventListener('pointerleave', () => g.to(el, { x: 0, y: 0, duration: 0.55, ease: 'elastic.out(1,0.55)' }));
});
}
// responsive chrome: hide ruler + nav links on narrow viewports
this.onResize = () => {
const narrow = window.innerWidth < 760;
const ruler = document.querySelector('[data-ruler]');
const links = document.querySelector('[data-navlinks]');
if (ruler) ruler.style.display = narrow ? 'none' : 'block';
if (links) links.style.display = narrow ? 'none' : 'flex';
};
window.addEventListener('resize', this.onResize);
this.onResize();
// ambient glow drift
const glow = document.querySelector('[data-glow]');
if (glow && g && !reduced) g.to(glow, { y: 34, x: -20, duration: 7, yoyo: true, repeat: -1, ease: 'sine.inOut' });
this.spy = ['system', 'refusal', 'code', 'craft'].map((id) => ({ id, el: document.getElementById(id) }));
this.links = Array.from(document.querySelectorAll('[data-navlinks] a'));
this.onScroll = () => {
const doc = document.documentElement;
const p = doc.scrollTop / Math.max(1, doc.scrollHeight - doc.clientHeight);
const dot = document.querySelector('[data-ruler-dot]');
if (dot) dot.style.top = (p * (window.innerHeight - 2)) + 'px';
const nav = document.querySelector('[data-nav]');
if (nav) {
nav.style.background = doc.scrollTop > 24 ? 'rgba(14,14,13,.88)' : 'transparent';
nav.style.backdropFilter = doc.scrollTop > 24 ? 'blur(10px)' : 'none';
nav.style.borderBottomColor = doc.scrollTop > 24 ? '#262624' : 'transparent';
}
// scroll-spy: quiet accent on the active section link
if (this.spy && this.links) {
const y = doc.scrollTop + window.innerHeight * 0.38;
let active = -1;
this.spy.forEach((sct, i) => { if (sct.el && sct.el.offsetTop <= y) active = i; });
this.links.forEach((a, i) => {
a.style.color = i === active ? '#F4F4F2' : '#A6A6A1';
a.style.borderBottom = i === active ? '1px solid #F4F4F2' : '1px solid transparent';
a.style.paddingBottom = '3px';
});
}
};
window.addEventListener('scroll', this.onScroll, { passive: true });
this.onScroll();
}
componentWillUnmount() {
if (this.tick) clearInterval(this.tick);
if (this.io) this.io.disconnect();
window.removeEventListener('scroll', this.onScroll);
if (this.onResize) window.removeEventListener('resize', this.onResize);
if (window.ScrollTrigger) window.ScrollTrigger.getAll().forEach((t) => t.kill());
}
renderVals() {
const s = this.state;
const ka = s.brand;
const kas = this.mix(ka, '#000000', 0.28);
const kd = this.mix(ka, '#FFFFFF', 0.86);
const krb = Math.max(4, Math.round(s.radius * 0.66)) + 'px';
const seg = (label, active, onClick) => ({
label, onClick,
style: { flex: 1, textAlign: 'center', padding: '6px 8px', borderRadius: '2px', fontSize: '11px', fontFamily: this.MONO, background: active ? '#1B1B1A' : 'transparent', color: active ? '#F4F4F2' : '#82827C', border: active ? '1px solid #3A3A37' : '1px solid #262624' }
});
const p = s.polished;
const trans = 'all .45s cubic-bezier(.2,.7,.2,1)';
const chip = (label, done) => ({
label: (done ? '✓ ' : '· ') + label,
style: { alignSelf: 'flex-start', fontFamily: this.MONO, fontSize: '11px', padding: '5px 11px', borderRadius: '2px', transition: trans, color: done ? '#F4F4F2' : '#A6A6A1', background: done ? 'rgba(244,244,242,.08)' : '#161615', border: done ? '1px solid rgba(244,244,242,.22)' : '1px solid #262624', textDecoration: done ? 'line-through' : 'none' }
});
return {
brandHex: s.brand.toUpperCase(),
brandSwatches: this.BRANDS.map((hex) => ({
hex,
onPick: () => this.setState({ brand: hex }),
style: { width: '28px', height: '28px', borderRadius: '2px', background: hex, flex: '0 0 auto', boxShadow: s.brand === hex ? '0 0 0 2px #161615, 0 0 0 4px #F4F4F2' : 'inset 0 0 0 1px rgba(255,255,255,.14)' }
})),
radiusVal: s.radius,
radiusLabel: s.radius + 'px',
onRadius: (e) => this.setState({ radius: +e.target.value }),
fontSegs: this.FONTS.map((f) => seg(f.split(' ')[0], s.font === f, () => this.setState({ font: f }))),
kitVars: {
display: 'flex', flexDirection: 'column', gap: '16px', background: '#FFFFFF', border: '1px solid #ECE8E2', borderRadius: '2px', padding: '20px',
'--ka': ka, '--kas': kas, '--kd': kd, '--ki': '#1C1917', '--km': '#78716C', '--kl': '#E7E5E4', '--krb': krb, '--kf': "'" + s.font + "',sans-serif"
},
polishChips: [
chip('gap 18px → 16px (space-4)', p),
chip('display heading → font-bold', p),
chip('subcopy → neutral-600 · AA 7.2:1', p)
],
demoBadgeStyle: { alignSelf: 'center', background: kd, color: kas, borderRadius: '2px', padding: '4px 12px', fontSize: '11.5px', fontWeight: 600, fontFamily: "'Bricolage Grotesque',sans-serif", transition: trans },
demoHeadStyle: { fontFamily: "'Bricolage Grotesque',sans-serif", fontSize: '30px', lineHeight: 1.12, letterSpacing: '-.6px', color: '#1C1917', maxWidth: '340px', marginTop: p ? '16px' : '18px', fontWeight: p ? 700 : 400, transition: trans, textWrap: 'balance' },
demoSubStyle: { fontSize: '14.5px', lineHeight: 1.55, color: p ? '#57534E' : '#A8A29E', marginTop: p ? '12px' : '18px', transition: trans },
demoCtaRowStyle: { display: 'flex', gap: '12px', marginTop: p ? '20px' : '18px', transition: trans },
demoCta1Style: { background: ka, color: '#fff', borderRadius: krb, padding: '10px 18px', fontFamily: "'Bricolage Grotesque',sans-serif", fontWeight: 600, fontSize: '14px', transition: trans },
demoCta2Style: { background: p ? '#FFFFFF' : ka, color: p ? '#1C1917' : '#fff', border: p ? '1px solid #D6D3D1' : '1px solid transparent', borderRadius: krb, padding: '10px 18px', fontFamily: "'Bricolage Grotesque',sans-serif", fontWeight: 600, fontSize: '14px', transition: trans },
togglePolish: () => this.setState({ polished: !p }),
polishBtnLabel: p ? 'Undo' : '✧ Run Polish',
polishBtnStyle: p
? { color: '#82827C', fontSize: '13px', fontWeight: 500, padding: '11px 6px' }
: { background: '#F4F4F2', color: '#0E0E0D', padding: '11px 20px', borderRadius: '2px', fontSize: '13.5px', fontWeight: 600 },
polishNote: p ? '3 fixes — all in Solstice tokens' : 'weight 400 display · gap off-scale · AA fail'
};
}
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
# Company Context — "Weave" (working name)
_Living strategy doc · Founder OS · last updated 2026-07-09_
_Status: Phases 15 **compressed** at the founder's request to jump to Phase 6. Confidence tags: 🟢 verified · 🟡 reasoned · 🔴 assumed / needs validation._
> **Working name only.** "Weave" is a placeholder so the docs read cleanly — not a naming decision. Real naming/trademark is a separate exercise. 🔴
---
## One-liner
A **design-system-native canvas** that turns your own tokens and components into **real, production-ready React**, held to a **craft standard (Impeccable)** so output is on-brand *and* genuinely well-designed — so what a designer composes is exactly what ships.
## Problem
Product designers who already have a design system still can't ship on-brand UI without waiting on engineering. Today's AI design/code tools (v0, Lovable, Bolt, Figma Make) re-invent **generic** UI instead of using the team's own components and tokens — so the output is off-brand and thrown away, and the design→code handoff stays lossy.
- 🟢 92% of designers and 91% of developers say the handoff process needs improvement (Figma, *State of the Designer*).
- 🟢 Figma Make users report it "won't use my library components" / "created new ones that just looked like mine"; v0 "treats the Figma file as a screenshot" (Figma forum; user teardowns).
- 🟢 The market has already conceded the pain — Bolt, Lovable and Figma all bolted on "use your design system" features in 2025 because generic output was the #1 designer complaint.
## Beachhead ICP
Product/UI designers and design-system / design-engineer leads at startups & scale-ups (~10200 people) that **already have a design system** (tokens + component library) and ship a **React** web product. 🟡
- **User (feels pain):** product designer · design-system designer · design engineer.
- **Champion:** Head of Design / Design Systems lead. 🟡
- **Economic buyer:** design leadership budget; escalates to VP/Eng at team tier. 🟡
- **Veto:** Engineering ("will the emitted code meet our repo standards?") and Security at enterprise. 🟢
## Top Jobs-to-be-Done (from discovery research)
1. When I finish a design, I want the built UI to match it exactly without policing every pixel, so I can stop redlining.
2. When our design system changes, I want Figma and code components to stay in lockstep, so "the library" means one thing everywhere.
3. When I need a screen shipped, I want to produce it myself without an eng sprint slot, so I can move at the speed of the idea.
4. When I use AI to generate UI, I want it built from **my** components/tokens (not a generic theme), so I get on-brand output I don't throw away.
## Wedge / Positioning (April Dunford frame)
- **For** product designers who have a design system but can't ship on-brand code without engineering,
- **Weave is** a design-system-native canvas that emits real, production React from your own tokens + components,
- **unlike** v0 / Lovable / Bolt / Figma Make, which are developer/prompt-first and generate generic, throwaway UI,
- **so that** what you design is what gets built — on-brand, high-craft, with zero translation loss.
- **Category:** "design-system-native design-to-code" — a wedge inside the crowded AI-builder space. 🟡
**Two things deepen the wedge (added after review):**
- **Real-component mapping** — export emits the team's *real* coded components (their import paths + prop names), not Weave-internal clones. Without this the output never clears the engineer's merge bar and the wedge collapses to "v0 with theming." 🟢 (this was the single biggest gap the red-team found)
- **Craft layer (Impeccable)** — beyond "constrained to your system," a Polish pass holds output to a craft ruleset (impeccable.style — an open-source anti-"AI-slop" design language for AI harnesses). On-brand **and** high-craft is a sharper position than either alone. 🟡
## Why now
AI codegen is finally good enough for *constrained* composition; design-token standardization (W3C DTCG) is maturing; "design engineering" is rising as a role; Figma Dev Mode's per-seat pricing backlash has opened a door. 🟡
## Competitive frame (from existing knowledge — deep teardown deferred)
| Cluster | Players | Why they don't own the wedge |
|---|---|---|
| Prompt/dev-first app builders | v0, Lovable, Bolt | Fast but generic output; prompt-first not canvas-first; not BYO-design-system-first |
| Visual design-to-code | **Subframe** (closest), Builder.io Fusion, Anima, Locofy | Subframe is strong but leans "design in browser → code"; others are dev-first imports or Figma-plugin exporters with fidelity issues |
| Site builders | Framer, Webflow | Own proprietary runtime; you don't get *your* React components/code |
| Incumbent | Figma (Dev Mode, Make) | Enormous stickiness, but generic AI output and a handoff "tax"; not designer-owned code from your system |
**White space (the bet):** a **designer-first** canvas where a **bring-your-own design system is the source of truth** and AI is **constrained to it**. 🟡 *(Subframe is the nearest competitor; a real teardown is still owed to harden the "unowned" claim — see 🔴 A4.)*
## North Star Metric
**Weekly shipped screens** — screens whose code is exported and actually used, per active designer. No repo integration yet, so **proxy** it via an explicit "mark as shipped" + design-partner check-ins. 🟡
- **Activation:** first screen composed from the user's *own* tokens + components and exported. 🟡
- **Trust bar (primary, engineer-judged):** real PRs at design partners — merge rate + % LOC changed pre-merge. The right judge is the engineer, not the designer's self-report. 🟢
- **Wedge-usage guardrail:** % of exported screens composed *majority* from the user's mapped/real components (not stock base kit) — so we can't "pass" by validating a re-themed generic kit. 🟡
## Business model (compressed — full Phase 5 deferred)
Per-seat SaaS for designers with a usage component on AI compose; land bottom-up with individual designers, expand to design teams. Not yet modeled. 🔴
---
## Validation gate status (Phase 1) — **NOT formally cleared**
The pain is real and well-sourced 🟢, but the core belief rests on unvalidated assumptions. Per Founder OS discipline, the MVP in the PRD is scoped as **the cheapest way to test the core bet**, not a green-lit build. Run the validation experiment (see PRD §Risks/Validation) in parallel with any build.
### Riskiest assumptions to validate
- 🔴 **A1 — Designers want to compose/own real code.** Many want the *outcome* (their design shipped faithfully, fast), not to own a codebase. Evidence is genuinely mixed; some designers report AI codegen is *slower* than just designing.
- 🔴 **A2 — Designers will adopt a new canvas.** Figma stickiness is structural (libraries, network effects, org process); real-world switching is usually *additive / per-phase*, not a replacement.
- 🔴 **A3 — The bottleneck is tooling, not governance.** Practitioners repeatedly say design-system drift/sync is a people-and-process problem a tool alone won't fix.
- 🔴 **A4 — "BYO design system → real code" is genuinely unowned.** Competitive teardown was deferred this pass; Subframe is close. Harden before fundraising claims.
### Validation gate (sequenced, with kill criteria — run before/at the start of build)
1. **Concierge real-PRs (primary):** hand-build 5 partners' screens from their own systems and open **real PRs** against their repos. **Pass** ≥3/5 merged with <10% LOC changed; **kill/rethink** if <2/5 or reviews are structural. Tests A1 *and* the trust bar with the correct judge, with zero product code.
2. **Clickable-mock task test:** 5 designers complete the core task on a Figma prototype of the editor. **Pass** 4/5 unaided. Tests A2 (will they adopt a new canvas) before the canvas is built.
3. **Fake-door demo** vs. a v0-style generic control: **pass** waitlist conversion 2× control.
4. **Competitor teardown** (Subframe et al. owed from Phase 2) to harden A4. No code needed.
5. **10 problem interviews** with design-system leads probing A1 and A3.
> Founder OS discipline: only the token-engine + shared-runtime spike should run concurrently with these. "Build in parallel with validation" is how the gate gets quietly bypassed — the red-team flagged exactly this.

View File

@@ -0,0 +1,177 @@
# PRD — "Weave" MVP (working name) · v2
_Founder OS · Phase 6 (MVP Definition) · 2026-07-09_
_Companion docs: `company-context.md` (strategy) · `design-brief-weave.md` (UI/UX)_
_v2 changes (from adversarial product review + Impeccable integration): **real-component mapping pulled into MVP** (the wedge depended on it); **Impeccable craft/Polish layer added** (F9); validation gate **sequenced with kill criteria**; trust metric moved to **engineer-observed merge behavior**; scope trims (share link, frame-linking, full authoring → deferred)._
_Tags: 🟢 verified · 🟡 reasoned · 🔴 assumed / needs validation_
---
## 1. Problem & why now
Product designers with an existing design system can't ship on-brand UI without engineering, and AI tools (v0, Lovable, Bolt, Figma Make) generate **generic, throwaway** code that ignores their tokens and components. Weave makes the design system the source of truth — a canvas where everything you compose is **real React built from your own components** — and holds output to a **craft standard (Impeccable)** so it's on-brand *and* genuinely good, not slop. (Evidence in `company-context.md`.)
## 2. Goals & non-goals
**Goals (MVP)**
- Prove the core bet: **your design system in → on-brand, high-craft, production React out, from a visual canvas.**
- Get a designer from "bring my system" to "export a screen's real code" in **one sitting (<20 min)** 🟡.
- Clear the trust bar: an engineer would **merge** the output (import their real components, pass review), not rewrite it 🟢. *This is the bar the whole wedge rests on.*
**Non-goals (MVP)**
- Not a full-app/backend builder (Lovable/Bolt's lane) — Weave is **UI + prototypes**.
- Not a Figma replacement or vector-illustration tool.
- Not multi-framework, not real-time multiplayer, not repo round-trip, not a component-authoring studio (all deferred; §8).
## 3. Success metrics
| Metric | Definition | MVP target |
|---|---|---|
| **North Star** | Weekly shipped screens (code exported & used), proxied by an explicit **"mark as shipped"** + design-partner check-ins (no repo integration yet) | trend up 🟡 |
| **Trust bar (primary, engineer-judged)** | Real PRs opened at design partners: **merge rate**, **% LOC changed pre-merge**, structural-vs-nit review comments | ≥60% merged with <10% LOC changed 🔴 |
| **Wedge-usage** | % of exported screens composed *majority* from the user's **mapped/real** components (not stock base kit) | 50% floor 🔴 |
| **Activation** | First screen composed from own system **and exported** | 40% of signups 🔴 |
| **Time-to-first-value** | Signup first exported on-brand screen | <20 min median 🔴 |
| **Craft** | Open Polish issues per exported screen | trend 0 🟡 |
| **Guardrail** | Canvas interaction latency | <16 ms/frame on a 50-node screen (node = one rendered element instance) 🟡 |
## 4. Target users
- **P1 "Maya," product designer** (primary): has a design system, ships React, blocked on eng, burned by generic AI output. Often does **not** own a clean token file (it lives in Figma/repo).
- **P2 "Devon," design-systems / design-engineer lead** (champion + gatekeeper): owns the **real coded component library**; cares that output imports *their* components and passes review. Holds the merge veto.
## 5. Core value & the "aha"
*"I typed a screen, it came out in MY components and MY tokens, it's genuinely well-designed, and the code imports our real library."* Four trust beats: my system re-themes the tool the canvas renders the **real** component **Polish** makes it craft-grade export imports our **real** components.
## 6. MVP — the core loop
```
1. Bring your system → 2. Map to your real components → 3. Compose on canvas
tokens·fonts·assets (Devon; optional if no coded lib) real components
(file OR 5-token path) ↓
↑ 4. AI compose (constrained)
└──────────── 7. Export (imports your real code) ← 6. Polish (craft) ← 5. Preview
```
## 7. In scope (MVP) — each traced to the wedge
| # | Capability | MVP boundary | Traces to |
|---|---|---|---|
| F1 | **Design-system import** | Tailwind config **or** paste CSS variables **or** Figma Variables (if available); **no-file path: set 5 core tokens** over the base kit. Upload fonts (woff2) + assets. One source of truth. | Whole wedge; Maya's real input path |
| F2 | **Component library + mapping** | Tokenized **base kit** (~15 components, **semantic HTML + ARIA**). **Map base-kit components → the team's real components** (import path + prop names) so export references their library. Save composed **patterns**. *(Full variant/prop authoring deferred.)* | Wedge-critical: clears the merge bar; JTBD #2 |
| F3 | **Canvas / composer** | Frames, drag/drop, **auto-layout** stacks (dir/gap/padding = tokens), props, token binding, undo/redo, keyboard-first. **Desktop breakpoint**; layout model responsive-ready. | Figma-like half; flow |
| F4 | **AI compose (constrained)** | Prompt assembles **only** from mapped/base components + tokens; **propose→accept** (staged ghost layer); shows provenance; **graceful refusal** (offers to compose from primitives w/ token binding + save) never emits non-system markup. | The differentiator; JTBD #4 |
| F5 | **Real code output** | Clean **React + Tailwind**; **imports the user's mapped components** (not inlined clones); tokens as CSS vars/Tailwind theme; Prettier; deterministic (same tree same code). | The trust bar; JTBD #1 |
| F6 | **Live preview** | Same runtime as canvas/export renders the **real** components; breakpoint switch; **real hover/focus/active states**. *(No frame-to-frame linking — see §8.)* | Kills "dead document"; JTBD #1 |
| F7 | **Export / handoff** | Copy component code; **download runnable project (.zip)** wired to tokens + real component imports. *(Share link deferred.)* | Activation; North Star |
| F8 | **Auth / projects** | Projects, autosave, single-player. **Manual provisioning acceptable** for a ~10-team design-partner MVP. | Table stakes |
| **F9** | **Craft / Polish layer (Impeccable)** | Generated & composed output adheres to a **craft ruleset**; a **Polish** action critiques the screen (spacing, hierarchy, type, contrast, alignment, states), proposes **one-click fixes in your tokens/components**, and **explains why** (teach). | On-brand **and** high-craft = anti-slop; new user ask |
| F10 | *(fast-follow)* **Pluggable standard** | Choose craft standard: Weave default / **Impeccable** / custom. | Extends F9; deepens moat |
## 8. Explicitly OUT of scope (the load-bearing section) — deferred
> Rule: *"Does the core value — on-brand, high-craft real code from a visual canvas — survive without it?"* If yes → cut.
- **Full component-authoring studio** (variant/prop/slot editor). MVP = base kit + **mapping** + pattern-save; authoring recreates Subframe and risks a third source of truth. fast-follow
- **Real-time multiplayer.** Single-player + (later) share link. v2
- **Repo round-trip / two-way Git sync.** The dream; not needed to prove value. Export-only. v2
- **Share/read-only preview link.** Copy+zip is the activation event; link is virality, not the bet. fast-follow
- **Frame-to-frame prototyping / interaction linking.** Figma-parity creep; conflicts with "not a Figma replacement." Real component states come free. v2
- **Figma file (layout) import.** Fidelity rabbit hole (v0/Anima's failure). Tokens/Variables yes; layouts no. v2
- **Multiple frameworks** (Vue/Svelte/SwiftUI/HTML). React+Tailwind only. later
- **Full vector/illustration tools.** Composer, not illustrator. out of lane
- **Backend / data / auth-in-app / business logic.** Out of Lovable/Bolt's lane. out of lane
- **Second breakpoint (mobile).** Desktop MVP; model responsive-ready. near-term follow
- **Design-token governance/versioning, multi-brand; plugins/marketplace; comments/redline.** v2
## 9. User stories & acceptance criteria (Given/When/Then)
**US-1 — Bring my system (incl. no-file path)**
- **Given** a Tailwind config / pasted CSS vars / Figma Variables, **when** I import, **then** tokens parse and the base kit re-themes within ~3s.
- **Given** I have no token file, **when** I choose the 5-token path, **then** I set brand/neutral/radius/font/spacing and the kit re-themes live **no dead end**.
- **Given** an invalid file, **when** I import, **then** I get a line-referenced error with manual mapping.
**US-2 — Map to my real components**
- **Given** a coded component library, **when** I map "Weave Button" `@/components/ui/Button` and its props, **then** the code preview shows the exact real import, and canvas instances render as that mapped component.
- **Given** unmapped components on a screen, **when** I export, **then** I'm warned which will fall back to Weave defaults, and offered to map them.
**US-3 — AI compose, constrained**
- **Given** an imported system, **when** I prompt "pricing, three tiers," **then** the result uses **only** my mapped/base components + tokens, staged as a ghost layer to **accept/discard**, listing components used.
- **Given** the AI can't map a need, **when** it hits it, **then** it **says so** and offers to compose from primitives (with token binding + save) **it does not inject generic markup**. **AC:** across a 100-prompt eval, **0 unregistered elements** in output. 🟢 (differentiator integrity)
**US-4 — Trust the code (engineer-judged)**
- **Given** a composed screen, **when** Devon opens the code, **then** it **imports our real components** (not re-declared inline), references token vars, is Prettier-clean, `tsc`+`eslint` clean.
- **Given** an exported zip, **when** run (`npm i && npm run dev`), **then** it renders identically to preview (shared runtime).
**US-5 — Prototype**
- **Given** a screen, **when** I preview, **then** real hover/focus/active states work at the desktop breakpoint. *(No click-through linking in MVP.)*
**US-6 — Keep system in lockstep** *(new)*
- **Given** updated tokens, **when** I re-import, **then** I see a **diff + impact** ("affects 14 components / 3 screens") and can **apply with undo**; bindings are preserved; nothing silently breaks.
**US-7 — Polish to craft standard** *(new — Impeccable)*
- **Given** a composed screen, **when** I run **Polish**, **then** I get a ranked list of craft issues (off-scale spacing, weak hierarchy, contrast fail, misalignment, missing state), each with a **why** and a **one-click fix expressed in my tokens/components**.
- **Given** a clean screen, **when** I Polish, **then** it says "Impeccable. Nothing to fix." (earned, not flattery).
## 10. Low-fi wireframes
_See `design-brief-weave.md` §9 for full screens + states. Editor spine:_
```
Top: Project ▾ · [Desktop] · ⟳Preview · ✧Polish · </>Code · ⇪Export
Left: Pages/Layers · Library(map/patterns) · Tokens
Center: Canvas + ✦ AI bar Right: Inspector (props·tokens·layout) / Polish issues
Companion: Code panel — imports { Button, Card } from "@/components/ui"
```
## 11. Functional requirements
- FR-1 Parse Tailwind + CSS-vars + Figma Variables tokens; 5-token quick path; font/asset upload+storage; contrast check at import.
- FR-2 Base kit (~15, semantic + ARIA) bound to tokens; **component-mapping registry** (Weave component real import path + prop map); pattern save.
- FR-3 Canvas engine: frames, drag/drop, auto-layout, token-bound props, desktop breakpoint (responsive-ready model), undo/redo, shortcuts, autosave+offline/conflict handling.
- FR-4 AI compose constrained to the project registry; proposeaccept staging; provenance; graceful-refusal path; 0-unregistered-elements guarantee.
- FR-5 Codegen: tree React+Tailwind, **imports mapped components**, tokens as vars, Prettier, deterministic; export = runnable zip.
- FR-6 Single shared component runtime for canvas/preview/export; breakpoint switch; real interaction states.
- FR-7 **Polish engine (F9):** evaluate a screen against the craft ruleset; produce ranked, explained, auto-fixable issues expressed in tokens/components.
- FR-8 Token re-import: diff, impact analysis, safe apply+undo.
- FR-9 Auth/projects/autosave (manual provisioning acceptable for partner MVP).
## 12. Non-functional requirements
- **Performance:** <16ms/frame canvas on a 50-node screen; AI first paint <2s; Polish scan <2s on a typical screen. 🟡
- **Fidelity (corrected):** state the **shared-runtime invariant** (preview == export by construction) as the guarantee; **golden-file visual tests on real user-composed screens** with **pinned browser + fonts** (not base-kit-only). Target 98% match. 🟡
- **Code quality:** exported project passes `tsc`+`eslint`; imports resolve against the mapped library; **semantic HTML/ARIA** in base kit.
- **Security/privacy:** per-project isolation; assets/fonts scoped to project; **no training on user systems (absolute for MVP)**; SOC2 path noted (not MVP). 🟢 (eng/security veto)
- **Accessibility (tool):** WCAG 2.2 AA for Weave's UI; keyboard-operable canvas.
- **Browsers:** latest Chrome/Edge/Safari/Firefox; desktop-first.
- **Reliability:** autosave 5s; no lost work on refresh; offline read-only + reconnect.
## 13. Validation plan & gate (sequenced — review fix)
> The MVP is a **bet to validate**, not a green light. Run these **before/at the start of build**, with pre-registered kill criteria. Only the token-engine + shared-runtime spike may run concurrently.
| # | Experiment | Tests | Pass / Kill threshold |
|---|---|---|---|
| V1 | **Concierge real-PRs:** hand-build 5 partners' screens from their systems, open **real PRs** against their repos | A1 + the trust bar (right judge) | **Pass** 3/5 merged with <10% LOC changed; **Kill/rethink** if <2/5 or reviews are structural |
| V2 | **Clickable-mock task test** (Figma prototype of the editor), 5 designers | A2 (will they work in a new canvas) | **Pass** 4/5 complete the core task unaided |
| V3 | **Fake-door demo** ("your system on-brand screen real code") vs. a v0-style generic control | Desirability / positioning | **Pass** waitlist conversion 2× control |
| V4 | **Subframe/competitor teardown** (owed from Phase 2) | A4 (is the wedge unowned) | Decision input; no code needed |
| V5 | **10 problem interviews** with design-system leads | A1 + A3 (tooling vs governance) | Qualitative gate |
## 14. Key risks & mitigations
| Risk | L/I | Mitigation |
|---|---|---|
| 🔴 Designers want the *outcome*, not to own code (A1) | M/High | V1 + V3 before heavy build; position as "export & hand to dev," code as proof |
| Output doesn't clear the merge bar (imports/clean) | M/High | Mapping in MVP; shared runtime; golden-file tests; V1 is the direct test |
| AI "fails open" to generic markup | M/High | Registry-only codegen; explicit refusal path; 0-unregistered eval |
| Won't displace Figma (A2) | H/Med | Position per-phase (coexist); V2 before canvas build |
| Subframe/incumbents own it first (A4) | M/Med | V4 teardown; sharpen system+craft wedge; Impeccable moat |
| Scope creep to "app builder" | H/Med | §8 is load-bearing; guard it |
| Impeccable rules not literally matched | L/Med | Reconcile with real ruleset / embed the Impeccable skill (F10) |
## 15. Open questions
- Import a **coded** library at MVP (mapping assumes one exists) vs. base-kit-only for library-less teams? (Support both; mapping is first-class.) 🟡
- Token source of truth first: Tailwind vs Figma Variables vs CSS vars? (Recommend Tailwind + CSS-vars, Figma Variables if API allows.) 🟡
- **Embed the Impeccable skill directly, or ship a compatible ruleset?** (F10 decision.) 🔴
- Promise "shippable" or "starting-point" code? (Fidelity labeling.) 🔴
## 16. Build sequence (trust path first)
0. **Validation gate (§13)** V1/V4 need no product code; start immediately.
1. **Component runtime + token engine** (base kit re-themes from tokens) the spine.
2. **Component mapping registry** (Weave real imports) the merge bar.
3. **Canvas compose + inspector** on that runtime.
4. **Codegen + export** (make the trust bar real early).
5. **AI compose, constrained** (proposeaccept, refusal).
6. **Polish engine (Impeccable)** + **token re-import**.
7. **Preview + auth/projects** hardening.
## Exit gate (Phase 6)
Scope is defensibly minimal every §7 item traces to the wedge/North Star, §8 removes the rest, and the wedge-critical **mapping** and **craft** capabilities are now in. **Phase 1's gate remains uncleared (🔴 A1A4):** treat this as the spec for a bet, and run §13 with its kill criteria before committing the full build.

View File

@@ -0,0 +1,274 @@
# Design Brief — "Weave" (working name) · v2
_A hand-off for a design agent / designer. Founder OS · Phase 6._
_Companion docs: `company-context.md` (strategy) · `design-app-prd.md` (MVP scope)._
_v2 changes: folded in an adversarial design review (fixed art-direction contradictions, added missing screens/states, spec'd the AI interaction) **and integrated Impeccable** (impeccable.style) as Weave's craft standard._
_Confidence: 🟢 verified · 🟡 reasoned · 🔴 assumption to validate._
---
## 0. On Impeccable (how it shows up in this brief)
**Impeccable** (impeccable.style, by Paul Bakaus — open-source) is a *design language / "skill" for AI harnesses* (Claude Code, Cursor) whose entire purpose is to **stop AI-generated UIs from looking like AI** — to replace generic "slop" with real craft. 🟢 It's structured as a design language (rules) plus sub-skills for **building, "polishing" (critiquing + fixing), and teaching** good design. 🟡
It matters to Weave **twice**, and both are load-bearing:
1. **As Weave's own craft standard** — the UI you design from this brief must itself be Impeccable-grade (see §11.5 Craft Standard + the checklist in §20). Weave dogfoods the rules it sells.
2. **As a product capability** — Weave doesn't just constrain output to the user's *system*; it holds output to a *craft standard* (an Impeccable-style ruleset) via a **Polish** pass, so results are **on-brand AND high-craft**. See PRD F9/F10. This is the deepening of the wedge: not just "your components," but "your components, arranged well."
> ⚠️ **Fidelity note:** I could not fetch Impeccable's verbatim `DESIGN.md` this session (fetch was rate-limited). The craft rules in §11.5/§20 reflect Impeccable's documented anti-slop philosophy plus established craft; treat exact numeric values as 🟡/🔴 and reconcile them against the real Impeccable ruleset (or install the Impeccable skill) before locking. Sources listed at the end.
## 1. Header / how-to
- **Product:** Weave (working name — not final 🔴).
- **One-liner:** A design-system-native canvas that turns your own tokens and components into real, production-ready React — held to a craft standard so it never looks generic.
- **Surfaces:** Desktop web app (primary). Read-only shared preview link (any device) — *fast-follow, not MVP* (see PRD). No native mobile app.
- **Who it's for:** Product/UI designers and design-engineer / design-system leads at startups & scale-ups that already have a design system and ship a React web product.
- **How to use this brief:** §3§7 = point of view + experience; §8§9 = build map (IA + screens + states); §10§14 + §20 = the craft bar. Recommendations are decisive defaults you may improve on, not pixel law. Everything 🔴 must be validated (see §19).
## 2. Product snapshot
- **What it is:** A visual canvas where a designer composes screens from **their own** components and tokens, gets **real React + Tailwind** out, and where a **craft layer (Impeccable)** keeps the result high-quality.
- **The wedge:** v0 / Lovable / Bolt / Figma Make generate *generic* UI and throw your design system away. Weave makes your design system the **source of truth** and constrains everything — including AI — to it, **then polishes to a craft standard**.
- **Emotional promise:** *"My design system is alive, what I make is what ships — and it looks like we hired a great designer, not an AI."* Relief, trust, quiet pride.
## 3. Design north star
> **Every screen must make the designer trust that what they see is exactly what will ship — built from their own system, arranged with real craft, with nothing lost in translation.**
If a screen introduces doubt ("is this the real component? will the code match? is this on-brand? does this look generic?"), it has failed. **Trust is the product; craft is how trust is earned.**
## 4. Design principles (opinionated, product-specific)
1. **Your system is the hero; our chrome is stagehand.** Weave's UI is deliberately quiet, near-monochrome, recessive — a matte camera body around a bright photograph — so the user's brand dominates. *Why: the #1 complaint about competitors is off-brand output; our UI must never visually compete with the user's brand.*
2. **Real, never represented.** The canvas renders the actual coded component at actual fidelity; preview = the same runtime as export. *Why: "roughly how it'll look" is the lie that makes handoff lossy.*
3. **Show the code — trust is earned in the open.** Real code is a keystroke away, not a buried export; clean code that imports *their* components converts skeptics. *Why: designers have been burned by screenshot-to-code; visible honest code is the proof.*
4. **AI proposes in your language; the system disposes.** AI output is always in the user's components/tokens, always shows provenance, always reversible; when it can't map, it *says so* — it never falls open to generic markup. *Why: constrained-to-your-system is the entire differentiator.*
5. **Craft is not optional — Impeccable-grade or it doesn't ship.** Every surface (ours and the user's output) meets the craft checklist in §20: real type hierarchy, strict spacing, purposeful color, optical alignment, designed empty/error states. Generic is a bug. *Why: "on-brand but sloppy" still loses; the whole promise is that the output is genuinely good.*
6. **Flow is a latency budget, not a slogan.** The core loop is keyboard-first and sub-frame (<16ms). *Match Figma's canvas responsiveness or don't ship the canvas.* *Why: designers forgive missing features, never lag in the core loop.*
7. **Honest about fidelity.** Where something is prototype vs. production-ready, the UI says so plainly; never over-promise "shippable." *Why: over-claiming code quality loses the engineer — and the engineer is the veto.*
## 5. Users & context (personas condensed *for design*)
| | **Maya — Product Designer (primary)** | **Devon — Design-Systems / Design-Engineer Lead (champion + gatekeeper)** |
|---|---|---|
| Device/env | 2732" display, macOS, Figma muscle memory, headphones-in flow | Dual monitor, VS Code + Figma, **reads the generated code** |
| Emotional state | Impatient with handoff, burned by generic AI output, protective of brand | Skeptical of design-to-code claims, allergic to messy code |
| Proficiency | Expert in design tools; reads code, writes little | Fluent React/Tailwind; owns the component library + the repo veto |
| Accessibility | Long sessions dark mode, low glare; keyboard-heavy | Scrutinizes output a11y/contrast |
| Artifact they hold | Brand/visual decisions; often **no clean token file** (it's in Figma or the repo) | The **real coded components + tokens** (Tailwind config / repo) |
| What wins them | On-brand, high-craft result in minutes, no relearning | Code that imports *their* components and passes review |
> Design implication (from review): **Maya frequently does not own a DTCG/Tailwind file** — it's Devon's. Onboarding must not dead-end on "upload your tokens." See §9.2 no-file path.
## 6. JTBD → top tasks
| Job | Concrete tasks the UI must make effortless |
|---|---|
| Design finished build matches without policing pixels stop redlining | Compose from real components; see live code; export exact |
| System changes design & code stay in lockstep "the library" means one thing | Re-import changed tokens; see impact/diff; apply safely |
| Need a screen ship it myself move at idea-speed | Prompt or drag; export runnable code |
| Using AI build from *my* system on-brand, not throwaway | Constrained AI compose with provenance; graceful "can't map" |
| Result looks generic make it genuinely good look designed, not AI | One-click **Polish** to the craft standard |
## 7. Core user journeys (name the "aha")
**A. Onboarding → first value (target <20 min).**
Sign in **Bring your system**: (a) connect a Tailwind config / paste CSS variables, or (b) **no file? set 5 core tokens over the base kit** (brand, neutral, radius, font, spacing) *base kit re-themes to their brand* drop into a starter frame prompt or drag a first section **Polish** open code **export / copy**. **The aha: "these are *my* components now, arranged well, and the code is real."**
**B. The core loop (daily).**
Open project compose/adjust on canvas (drag, props, tokens) glance at live code AI compose a section **Polish** export. Tight, keyboard-first, low-latency *sketching that happens to produce production code*.
**C. Habit / expansion.**
Map/save components reuse across screens/projects **re-import updated tokens** (System update, §9.8) (fast-follow) share read-only prototypes; (v2) sync to repo. The user's mapped library + saved patterns is the retention flywheel.
## 8. Information architecture
**Surfaces:** (1) Home/Projects · (2) Editor · (3) Preview (full-screen + fast-follow shared link) · (4) **Settings** (system, mappings, account).
**Editor layout (persistent 3-pane + dual companion):**
```
Top bar: Project ▾ · breakpoint [Desktop] · ⟳ Preview · ✧ Polish · </> Code · ⇪ Export · avatar
Left rail: Pages/Layers · Component Library · Tokens (switchable)
Center: Canvas (frames, direct manipulation) + ✦ AI compose bar (docked bottom)
Right rail: Inspector (props · tokens · layout for selection)
Companion: Code panel (right split / bottom drawer, toggle </>) · Polish panel (✧, right)
```
**Two command surfaces, reconciled (review fix):** **K = commands/navigation** (find, jump, run action); ** AI bar = generative** (compose/modify from your system). Typing `✦` inside K routes to the AI bar. Never two overlapping "type here to make UI" fields.
**Screen map:** Home Editor {Component editor (focused), Token manager, **System update / re-import**, Polish, Preview, Code/Export, Settings}.
## 9. Key screens (purpose · primary user · key elements · **all states**)
### 9.1 Home / Projects
- **Purpose:** get into work fast; hold projects + systems. **Elements:** project grid (live real-render thumbnails), New project, Bring a design system, recent, search.
- **States:** **Empty** warm single-CTA "Bring your design system to life" + 20-sec inline demo loop. **Loading** skeletons matching grid rhythm. **Error** inline retry. **Populated** real-render thumbnails.
### 9.2 Onboarding — Bring your system
- **Purpose:** turn tokens/fonts/assets into a live, themed, craft-checked kit. **Elements:** connect Tailwind config / paste CSS vars / **Figma Variables** (if available) **or the no-file path: set 5 core tokens** (brand, neutral ramp, radius, font, base spacing); font upload; asset upload; live re-theme preview; **token contrast check**.
- **States:** **Initial** inviting, two clear paths (file vs. no-file), example link. **Parsing** per-token progress; kit tinting in. **Partial/invalid** line-referenced human error ("We couldn't read `colors.brand` map it?") + manual mapping; **never a hard stop**. **No-file** 5-token quick form with live preview. **Success** the **re-theme reveal** (Signature Moment #1) + "Your system is live."
### 9.3 Editor / Canvas (the product)
- **Purpose:** compose from real components; the home of flow. **Elements:** frames, drag-drop, auto-layout stacks (direction/gap/padding = tokens), multi-select, snap/align guides, undo/redo, K, AI bar, Polish.
- **States:** **Empty frame** ghost placeholder + hint ("Drag a component, or describe this screen"). **Selection** inspector + guides. **Dragging** snap lines, live auto-layout reflow. **AI proposing** staged **ghost layer** of *real* components (see §10.6), provenance chips streaming in. **Polish running** non-blocking scan with issue markers. **AI can't map** inline first-class prompt (Signature Moment #6), not a dead error. **Save states (review fix):** autosave tick · **saving** · **offline (read-only banner)** · **reconnecting** · **conflict (two tabs)** resolution. **Latency guard:** all direct manipulation <16ms/frame.
### 9.4 Component Library (manage · map · define) — *fully specified (was the weakest section)*
- **Purpose:** the user's system as objects; **map** base-kit their real coded components, and define reusable patterns. This is the retention flywheel and the wedge-critical mapping surface.
- **Elements:**
- **Library list:** base kit · **mapped** components (linked to the team's real import path + prop names) · user-saved patterns; each shows usage count + a **mapping status** (mapped / unmapped / drifted).
- **Component detail:** a **variant matrix** (variant × state) with a **live state grid** (default / hover / focus / active / disabled / loading) rendered from the real runtime; a **token-binding panel** (which token drives which property); **prop editor**.
- **Mapping panel (wedge-critical):** map "Weave Button" `@/components/ui/Button`, map props (`variant→kind`, etc.); preview the exact import the export will emit.
- **New component from selection** (save a composed pattern).
- **States:** **Empty (user)** "You've got the base kit map it to your real components, or save your first pattern." **Mapping** guided prop-matching with live code preview. **Editing variant** focused mode, live state grid. **Drift flag** quiet non-blocking marker; clicking opens **Impact review** (what changed, where used, apply/ignore). **Success** component usable on canvas; export references the real import.
### 9.5 Tokens / Design-system panel
- **Purpose:** colors, type, spacing, radius, shadow as named tokens = single source of truth. **Elements:** grouped token lists, edit-in-place, **live contrast check** on color pairs, "where used" per token.
- **States:** **Empty** import or seed defaults. **Editing** everything bound updates live (canvas + code) the **token ripple** (Signature Moment #5). **Contrast fail** inline AA warning with ratio + fix (icon+text, not color alone). **Success** change ripples visibly.
### 9.6 Prototype / Preview
- **Purpose:** prove "what you see is what ships." **Elements:** full-screen live render, breakpoint switch, **real hover/focus/active states** (free from real components), device frame optional. *(Frame-to-frame click-through linking is cut from MVP — Figma-parity creep — see PRD §8.)*
- **States:** **Loading** fast real-render (no fake spinner). **Interactive** cursor + keyboard, visible focus rings. **Error** graceful "couldn't render open editor." **(Fast-follow) Shared view** clean, no editor chrome; link lifecycle: active / revoked / expired / 404.
### 9.7 Code / Export
- **Purpose:** earn trust; hand off. **Elements:** file tree, syntax-highlighted **read-only** React+Tailwind, token file, "Copy component," "Download project (.zip)." Code visibly **imports the user's mapped components** and references token vars; **fidelity badge** ("Production-grade" / "Prototype-grade").
- **States:** **Generating** streamed formatting. **Ready** copy/download + honest badge. **Export success** "Runs with `npm i && npm run dev`." **Error** specific, retryable. **Unmapped-components warning** "3 components aren't mapped to your repo yet export will include Weave defaults for these. Map them?"
### 9.8 System update (re-import changed tokens) — *new screen (review fix; closes JTBD #2)*
- **Purpose:** keep design & code in lockstep when the system changes. **Elements:** re-import tokens **diff** (added / changed / removed), **impact preview** ("affects 14 components across 3 screens"), one-click **apply with undo**, per-change accept/skip.
- **States:** **No changes** "Your system is up to date." **Diff** grouped changes with before/after swatches. **Breaking change** flags components that would break; safe-apply guidance. **Applied** ripple + "System updated · undo."
### 9.9 Settings — *new (was in IA, unspecified)*
- **Purpose:** manage system source, component **mappings**, craft standard, account. **Elements:** design-system source, mapping table, **craft standard selector** (Weave default / Impeccable / custom), export defaults, team/billing. **States:** standard load/error; mapping health summary.
### 9.10 Polish panel (Impeccable-driven) — *new product surface*
- **Purpose:** critique the current screen against the craft standard and fix, in your tokens/components. **Elements:** ranked issue list (spacing off-scale, weak hierarchy, contrast fail, misalignment, orphaned state), each with a **why** (teach) and a **one-click fix**; "Polish all"; before/after.
- **States:** **Clean** "Impeccable. Nothing to fix." (earned, not flattering). **Issues** grouped by severity, each fix previewable. **Applying** animated corrections. **Can't auto-fix** explains the manual change needed.
## 10. Signature moments (the award bar — craft these first)
1. **"The system comes alive."** During onboarding, as tokens resolve, the neutral base kit **re-themes to the user's brand in one crafted ~700ms cascade** (colors, type, radius sweeping across components). The *"those are mine"* moment. The single most important animation in the product.
2. **"What you see is what ships."** Live synced split: nudge a component on canvas and the **real code updates in lockstep**, with the one reserved **live-green** indicator affirming preview = code = export.
3. **"On-brand in one prompt."** Type *"a pricing section, three tiers"* it assembles from **their** Card/Button/Text in **their** tokens, with provenance chips visibly *not* the generic slate/Inter output expected from v0.
4. **"The clean handoff."** Export becomes a readable file tree that **imports their real components** a confident, almost anticlimactic *"of course it's real."* Delight through trustworthiness.
5. **"Change once, everywhere."** *(new)* Edit `brand-500` in the token panel and watch a **~400ms ripple** re-theme every affected component on canvas *and* highlight the changed code lines. The best possible demo of "system as source of truth"; the daily-use aha.
6. **"The graceful refusal."** *(new — the differentiator, as a moment not an error)* AI hits something your system can't express a crafted, calm beat: *"You don't have a component for this yet. Compose one from your primitives?"* with its own motion and copy. An error state elevated to a signature interaction is an award-grade move and it's the exact behavior that separates Weave from tools that fake it with generic markup.
## 11. Visual & art direction
- **Personality:** a precise, calm, professional **instrument** studio equipment, not a toy. Confident restraint. "Matte body around a bright photo."
- **Palette (chrome recedes genuinely warm neutrals so the user's brand pops):**
| Role | Dark (default) | Light |
|---|---|---|
| App bg | `#121110` | `#FAF9F7` |
| Panel | `#191715` | `#FFFFFF` |
| Raised/float | `#221F1C` | `#FFFFFF` + border |
| Hairline border | `#2B2724` | `#ECE8E2` |
| Text primary | `#F1EEEA` | `#1A1714` |
| Text secondary | `#B8B0A6` | `#55504A` |
| Text tertiary *(large/non-essential only)* | `#8C847A` | `#8A8279` |
| **Interactive accent (iris)** fills/selection | `#6E56F7` | `#5B44E0` |
| Iris **as text/link** (AA-safe variant) | `#A99BFF` | `#5B44E0` |
| **Reserved "live/real" green** indicator only | `#3FB984` | `#1E9E6A` |
| Warning / Error | `#E0A03A` / `#E5484D` | same, AA-checked |
**Color rules (review fixes — resolve the contradictions):**
- **Green is reserved** exclusively for the live/synced indicator (Moment #2). **Success is NOT green** success = neutral surface + iris check icon + label. This keeps green meaningful.
- **Iris as text must use `#A99BFF` on dark** (the `#6E56F7` fill fails AA as text). Iris fills are for selection/primary actions.
- **Tertiary text is large/non-essential only** (it doesn't clear AA 4.5:1 for body). Body/secondary uses `#B8B0A6`+.
- Every status pairs **icon + label** with color (never color alone).
- Dark is the **default and hero**; the canvas art-board can be set light/dark **independently** of the app theme so a light brand isn't judged against black.
- **Typography:** chrome uses a **precise neutral grotesk that is NOT AI-branded** *avoid Inter (the generic default), and avoid Geist (Vercel) / Söhne (OpenAI), which re-install the exact "AI look" we're fighting* (review catch: v0's output literally ships in Geist). Recommend **Untitled Sans, Suisse Int'l, or Neue Haas Grotesk/Unica** (or commission a cut). Mono for code/tokens: **Berkeley Mono** or **Commit Mono** (avoid the default JetBrains-everywhere look). **Type scale (UI):** 11 / 12 / 13 / 14 / 16 / 20 / 24, weights 400·500·600, line-height 1.4 (UI) / 1.6 (code), slight negative tracking on 20 only. **Critical:** the canvas renders the *user's* fonts chrome type must stay neutral and never clash.
- **Spacing & radius:** 4px base scale (4·8·12·16·24·32·48). Radius **6 / 10 / 14** deliberately *not* "8px on everything."
- **Iconography:** 1.5px stroke, geometric, consistent radius; quiet, learnable glyphs for component/token/frame/mapping/polish.
- **Depth:** flat + hairline borders + one soft shadow tier for raised/floating; no skeuomorphism.
- **Data-viz (light use usage counts, contrast meters, polish scores):** monochrome + single accent; meters and small bars, not chart junk (follow standard restrained categorical palette if a dashboard grows).
### 11.5 Craft standard — Impeccable alignment
Weave's UI (and the output it generates) must pass the craft checklist in **§20**, derived from Impeccable's anti-slop philosophy: intentional type hierarchy (real size *and* weight contrast, not just size), strict spacing scale, purposeful and restrained color, true visual hierarchy and focal point, optical alignment/correction, depth used sparingly, motion with purpose, and **every state designed** (empty/loading/error/success are where craft shows). 🟡 *Reconcile exact rules with the real Impeccable ruleset before locking (§0 note).*
## 12. Motion & micro-interactions
- **Character:** precise, quick, spring-based for direct manipulation; motion clarifies causality, never decorates.
- **Params (review fix buildable, not vibes):** drag/direct-manipulation spring **stiffness 210 / damping 24**; micro-feedback **90160ms** ease-out; panels/drawers **180240ms**; **re-theme reveal 600800ms** (the one indulgent moment); **token ripple ~400ms** propagation with synced code-line highlight; **AI placement stagger ~90ms** per element.
- **`prefers-reduced-motion`:** honor fully re-theme cascade crossfade; ripple instant; disable springs/parallax. No motion is load-bearing for meaning.
## 13. Voice & microcopy
- **Tone:** precise, calm, designer-native; speak in **components, tokens, frames, variants** never "divs/markup." Confident, never hypey; honest about fidelity; **explains *why*** when it fixes something (the Impeccable "teach" instinct).
- **Do words:** compose, system, tokens, components, live, real, ships, export, on-brand, polish, craft.
- **Don't words:** magic -as-crutch, "just," "simply," "AI-powered," "pixel-perfect," "website builder," "slop."
- **Example strings:** onboarding success *"Your system is live."* · export *"Ready to ship. Runs with `npm i && npm run dev`."* · AI can't map *"You don't have a component for this yet. Want me to compose one from your primitives?"* · polish clean *"Impeccable. Nothing to fix."* · polish fix *"Bumped this to your 24px step and matched your heading weight — here's why."*
## 14. Accessibility & responsive
- **Target:** WCAG 2.2 AA for Weave's own UI (the palette in §11 is tuned to pass a tool that *sells* contrast checking cannot fail its own). Full keyboard operability of the canvas; visible focus rings; ARIA for panels/trees; respects reduced-motion + prefers-color-scheme.
- **Color independence:** every status (live/warn/error, contrast/polish pass/fail) pairs icon/label with color.
- **Output a11y:** surface **contrast checks on the user's token pairs** at import and in the token panel; the base kit ships **semantic HTML + ARIA** so exported code passes Devon's review (not just contrast).
- **Chrome-vs-brand collision policy (review fix):** the iris selection outline and live-green dot must stay legible when the user's brand *is* violet or green use a **dual-tone selection outline** (light/dark stroke pair) and always pair the live indicator with an icon+label; test chrome over violet/green/black/white canvases.
- **Responsive (the app):** desktop-first, optimized ~12802560px; graceful 1024px; below "Weave is best on desktop." Generated **output** is responsive-ready (auto-layout), with the second breakpoint a near-term follow (PRD).
## 15. Technical & platform constraints (for design awareness)
- **Stack:** desktop web app; output is **React + Tailwind**, tokens as CSS vars / Tailwind theme. Canvas, preview, and export share **one component runtime** visual parity is by construction; design assuming no divergent mock rendering.
- **Wedge-critical:** export emits the user's **real, mapped components** (import paths + prop names), not Weave-internal clones design the **mapping** surface 9.4) as first-class.
- **Constraint that shapes UX:** AI is hard-limited to the project's component/token registry design the **"can't map / compose from primitives"** path (Moment #6) and the **Polish** loop as first-class flows.
- **Budgets:** <16ms/frame canvas on a 50-*node* screen (define node = one rendered element instance, incl. nested primitives); AI first paint <2s.
## 16. Design success metrics
- **Activation:** % of new users who compose a screen from their own system **and export** (target 40% 🔴).
- **Time-to-first-value:** signup first exported on-brand screen (<20 min median 🔴; guarded by the no-file onboarding path).
- **Craft lift:** Polish-panel issues per screen trend down; % screens exported "clean" (no open craft issues).
- **Task success:** compose-a-known-section success 90% in usability tests 🟡.
- **Trust/delight:** SUS 80 🟡. *(The primary trust metric — would an engineer merge it — is measured on the eng side; see PRD §3.)*
## 17. Deliverables expected (from the design agent)
1. **User flows** for the 3 journeys 7), incl. the **no-file onboarding** and the **system-update** loop.
2. **Low→mid-fi wireframes** for all screens in §9 (9.19.10), each with empty/loading/error/success (+ save/offline/conflict for the editor).
3. **Hi-fi designs:** onboarding (incl. re-theme frames), Editor, Component Library (variant matrix + mapping + drift), Tokens, System update, Polish panel, Preview, Code/Export, Settings.
4. **Weave's own design system** (tokens, recessive-chrome components, dark+light) dogfood the concept; ship it passing §20.
5. **Prototypes + motion specs** for the 6 signature moments 10).
6. **Redlines** for the two hardest interactions: canvascode sync, and the constrained-AI **propose→accept / graceful-refusal** flow 10.6).
7. **Microcopy deck** (the voice in §13 across states) and **a11y-annotated** redlines.
## 18. Inspiration / benchmarks (emulate the *quality*, do **not** clone)
- **Linear** precision, keyboard-first speed, restrained dark UI, K discipline.
- **Figma** canvas calm, direct-manipulation fluency, inspector clarity (the muscle memory to respect).
- **Vercel/Geist & Raycast** code-as-first-class-object; velocity *but note (review):* leaning on these too hard reproduces the 2026 default uniform; borrow the discipline, not the skin.
- **Subframe** nearest competitor; study its visual-builderReact flow, then out-design it on "your own system + craft is the source of truth."
- **Impeccable (impeccable.style)** the craft bar itself; the anti-slop standard Weave must meet and enforce.
- **One off-cluster reference (review fix):** **Teenage Engineering / broadcast-console & camera UI** for one ownable, tactile detail that escapes the sameness. Cash the "instrument" metaphor into form: e.g., **engraved-label token chips** and **dial-like prop steppers** for numeric tokens.
> Do not clone any of these. The synthesis — *a recessive, precise instrument that makes the user's brand and real, well-crafted code the hero* — is Weave's own.
## 19. Open design questions (validate before locking — carries strategy 🔴s)
- 🔴 **A1 (owns-code):** How much *code surface* do designers want? Design two framings "export & hand to dev" (code as proof) vs. "you own/edit the code" (code as workspace) and test which converts. Changes how prominent the code panel is.
- 🔴 **A2 (Figma coexistence):** replacement canvas or per-phase tool alongside Figma? Recommend designing for **coexistence** first; validate with a clickable-mock task test before building the canvas.
- 🔴 **A4 (vs Subframe):** make the "system + craft as source of truth" differentiation *visible* in the UI before hi-fi.
- 🟡 **Mapping vs authoring:** MVP leans on **mapping to real components**; how much in-app authoring does Devon expect? Design mapping as first-class now, authoring as a path.
- 🔴 **Impeccable rules (fidelity):** reconcile §11.520 with the verbatim Impeccable `DESIGN.md`; decide whether Weave *embeds the Impeccable skill* or ships a compatible ruleset.
- 🟡 **Fidelity labeling:** validate "Production-grade / Prototype-grade" language with real engineers.
## 20. Appendix — Craft checklist (the "Impeccable-grade" bar)
Every screen (Weave's UI *and* generated output) should pass:
- **Type:** a real hierarchy via size **and** weight (not size alone); 2 families; consistent scale; no default-Inter look.
- **Spacing:** everything on the 4px scale; intentional whitespace; related things grouped, unrelated things separated; no arbitrary gaps.
- **Color:** restrained; one purposeful accent; neutrals do the work; color never the only signal; AA contrast.
- **Hierarchy:** one clear focal point per screen; secondary/tertiary genuinely de-emphasized; alignment optical, not just mathematical.
- **Depth:** used sparingly and consistently (one shadow tier); borders hairline; radius on-scale.
- **Motion:** purposeful, fast, causal; reduced-motion honored.
- **States:** empty/loading/error/success all designed; empty and error states are craft showcases, not afterthoughts.
- **Detail:** optical corrections, consistent icon weight, no orphaned or truncated content, real content not lorem.
> 🟡 Align exact thresholds with the real Impeccable ruleset (§0).
---
### Sources (Impeccable)
- Impeccable impeccable.style (site, docs, "Designing with Impeccable"): https://impeccable.style/ · https://impeccable.style/docs/impeccable/ · https://impeccable.style/designing/
- GitHub pbakaus/impeccable (incl. `DESIGN.md`, `SKILL.md`): https://github.com/pbakaus/impeccable
- Overviews: https://abduzeedo.com/impeccable-open-source-ai-design-skill-better-ui · https://emelia.io/hub/impeccable-ai-design-skill
_End of brief v2. Pair with `design-app-prd.md` for scope and acceptance criteria. Update as 🔴 items validate._

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,17 @@
module.exports = {
root: true,
env: { browser: true, es2022: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.local
.DS_Store
.vscode/
.idea/

View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}

View File

@@ -0,0 +1,87 @@
# Veect — design-system-native canvas
> Full product & design specification: [`SPEC.md`](./SPEC.md)
A React 18 + TypeScript (strict) + Tailwind + shadcn-style codebase for the Veect MVP:
a canvas where a designer composes screens from **their own** tokens and components and
gets **real React** out — constrained AI, a Polish (Impeccable) craft pass, and honest code.
## Run
```bash
npm i
npm run dev # vite
npm run typecheck # tsc --noEmit
npm run lint # eslint
```
## Architecture — atomic design
```
src/
types/ Domain types (VeectNode, DesignSystem, Proposal, PolishIssue…)
config/ Library manifest + node glyphs + demo fiction (customer identity, imports, team)
engine/ Pure logic, no React: color math, tree ops, views (responsive variants),
codegen, parser (code→tree), tw utilities, polish rules, isolation specs,
upload intake, constrained-AI prompt + sanitizer
store/ zustand store (navigation, document + labelled restorable history, panels + tabs, chat)
hooks/ usePanelResize, useAiCompose (AI orchestration)
lib/ cn(), clamp(), truncate()
components/
ui/ shadcn-style primitives (Button, Input, Badge, Select, Panel) — cva + tailwind-merge
atoms/ Logo, MonoLabel, Swatch
molecules/ SegmentedControl, Stepper, ChatBubble, ChatComposer, LayerTree, LibraryList,
TokenEditor, PolishIssueCard, CollapseButton, ZoomControls
organisms/ ActivityBar, NodeRenderer, BoardCanvas, FrameNode, ChatPanel, BoardSidebar,
Inspector, PolishTab, IsolationView, CodePanel, ExportDialog, TopBar
templates/ EditorLayout (activity bar + resizable columns), PageShell
pages/ SignIn, Home, Onboarding, Editor, Settings, Profile, SystemUpdate
```
**Rules of the split**
- *Atoms* render one thing, take no store access.
- *Molecules* compose atoms, still store-free (props in, events out).
- *Organisms* own a slice of store state and one product concern.
- *Templates* own layout only. *Pages* wire organisms to a screen.
- `engine/` is framework-free and unit-testable; nothing in it imports React.
## Product invariants encoded here
- **One runtime** — `NodeRenderer` backs canvas *and* preview; codegen walks the same tree
(`engine/codegen.ts` is deterministic: same tree → same code).
- **The board is React Flow** (`@xyflow/react`, MIT) — frames are custom nodes with
`.frame-drag-handle` name tabs, so the DOM inside stays fully interactive. Scroll pans,
⌘/pinch zooms at the cursor, pane click deselects; the zustand tree remains the single
source of truth (React Flow carries only positions + viewport).
- **0 unregistered elements** — `engine/ai.ts` whitelist-sanitizes model output and refuses
anything outside the registry rather than failing open to generic markup.
- **Green is reserved** — the `LiveDot` atom is the only green in the chrome; success states
use neutral surfaces + accent check.
- **Craft is a pass, not a vibe** — `engine/polish.ts` holds the Impeccable-derived rules;
every issue carries a *why* and a one-click fix expressed in the user's tokens.
## Wiring the model
`engine/ai.ts#complete` expects a host-provided client (the prototype used one); point it
at your `/api/compose` endpoint in production. Everything else runs fully offline.
## Handoff notes — deliberate seams
- **Model bridge** — `engine/ai.ts#complete` throws without a host client; swap in your API.
- **Export zip** — `ExportDialog`'s "Download .zip" is presentational; wire it to a bundler
endpoint (the code it shows is real — `engine/codegen.ts`).
- **Placeholder projects** — `HomePage` lists two static placeholder cards; back with real data.
- **Auth** — `SignInPage` continues straight through; SSO buttons are visual.
- **Persistence** — the zustand store is in-memory; add storage middleware for real sessions.
- Theme default is dark (`index.html` `data-theme` + store); light is one toggle away.
## Prototype parity roadmap
Ported for parity with the prototype: **activity bar** (`ActivityBar` — toggleable icon
groups drive every panel; tabs live in the store), **responsive variants**
(`engine/views.ts` — one tree rendered at 13 widths per frame, view chips in Layers with
eye toggles, Desktop/Tablet/Mobile/All switcher in the top bar, cluster-shift on resize),
and the **ultra-minimal flat restyle** (paper/ink light default + carbon dark, 2px radii,
hairline borders instead of shadows — `index.css` + `tailwind.config.ts`).

View File

@@ -0,0 +1,251 @@
# Veect — Product & Design Specification
_v1.0 · July 2026 · covers `Veect.dc.html` (interactive prototype), `Veect Landing.dc.html`, and `veect-codebase/` (React 18 · TypeScript strict · Tailwind · shadcn-style)_
---
## 1. Product
**Veect is a design-system-native canvas.** A designer brings their own tokens and
components; Veect composes screens from *only* that system and exports production React
that imports their real library. The three invariants everything hangs on:
1. **One runtime** — canvas, preview, isolation and export render the same tree through
the same renderer. No "roughly how it'll look."
2. **0 unregistered elements** — AI output is whitelist-sanitized against the component
registry; anything outside it is refused, never faked.
3. **Deterministic code** — same tree → same code, every time. Export imports the
customer's library (`@solstice/ui` in the demo), never inlined clones.
Demo fiction: the customer is **Solstice** (warm team-scheduling brand), with designer
Maya (owner) and design engineer Devon (reviewer, owns the merge veto).
---
## 2. Brand & design language
### 2.1 Logo
Typographic wordmark: lowercase **`veect`**, Instrument Sans 600, letter-spacing 0.035em,
line-height 1, closed by a **square ink terminal** (the "vector point") sized ≈ 0.28 × font
size, baseline-aligned, `currentColor`. No pictorial mark. Codebase atom: `Logo size={n}`.
### 2.2 Color — chrome tokens
Color belongs to the customer's canvas; the instrument is monochrome. The accent IS the ink.
| Token | Light (default file value) | Dark |
|---|---|---|
| `--bg` / `--panel` | `#FCFCFB` | `#0E0E0D` |
| `--raised` / `--chip` | `#FFFFFF` / `#F4F3F0` | `#161615` |
| `--line` / `--line2` | `#E7E6E2` / `#CFCEC8` | `#262624` / `#3A3A37` |
| `--t1` / `--t2` / `--t3` | `#161513` / `#63615B` / `#9A978F` | `#F4F4F2` / `#A6A6A1` / `#73736E` |
| `--iris` (accent = ink) | `#161513` | `#F4F4F2` |
| `--iris-dim` | `rgba(22,21,19,.06)` | `rgba(244,244,242,.09)` |
| `--btn-ink` (text on accent) | `#FFFFFF` | `#0E0E0D` |
| `--warn` / `--err` / `--live` | `#9A7B3F` / `#B05E5E` / `#5B7F5E` | `#C2A36B` / `#C97070` / `#8BA88E` |
Amber (`--warn`) is reserved for AI-scope affordances (pick outline, scope chips, draw
strokes) and the unmapped-component warning. The prototype ships **dark by default**
(user preference); the codebase mirrors this (`index.html data-theme="dark"`).
### 2.3 Surfaces & elevation
Flat. Radius **2px everywhere** in chrome (customer canvas keeps its own `radius-md`
token). No floating shadows — separation is a hairline: popovers/menus use
`box-shadow: 0 0 0 1px var(--line2)`. Canvas frames sit on the board with a 1px
`rgba(0,0,0,.14)` ring.
### 2.4 Typography
- **UI**: Instrument Sans 400/500/600 — 13px base, 1112.5px controls.
- **Labels**: Fragment Mono, 911px, uppercase, letter-spacing .06.14em — all section
headers, metadata, and "engraved" captions.
- **Customer content**: the system's own font (demo: Bricolage Grotesque) — never used
for chrome.
- Scale discipline: chrome text never below 8px (activity-bar labels), canvas display
type responsive per view (§6).
### 2.5 Iconography
Hand-drawn 16-grid SVG, 1.5px stroke, round caps, `currentColor`. Set: chat (spark),
layers, library, tokens, history, inspect (sliders), polish (spark-4), code (chevrons),
pick (crosshair), draw (pen), eye / eye-off, mic. No icon fonts, no emoji.
### 2.6 Motion
120450ms, `cubic-bezier(.2,.7,.2,1)`. Keyframes: `riseIn`, `popIn`, `fadeIn`,
`lineFlash` (code sync), `nodePulse` (token ripple). Everything respects
`prefers-reduced-motion`.
### 2.7 Voice
Plainspoken, no hype, no emoji. Refusals teach ("You don't have a component for this
yet"), fixes explain the *why* in the user's own tokens.
---
## 3. Screens
| Screen | Purpose | Key elements |
|---|---|---|
| **Sign in** | design-partner entry | email → straight through; SSO stubs; provisioning note |
| **Home** | projects + systems | project grid (1 live + placeholders), search, Solstice system card (ramp, open tokens, re-import), "bring a design system" |
| **Onboarding** | the re-theme reveal | two paths: paste tokens (parse → contrast checks) or 5-token quick path (brand, neutrals, radius, type, spacing) with live mini-kit; ends "Bring it to life →" |
| **Editor** | the canvas (§45) | |
| **Settings** | system + governance | token source card, component mappings ("merge bar"), craft standard picker, export defaults, plan, delete |
| **Profile** | account | identity card, theme preference, workspace members (Maya/Devon), keyboard map, sign out |
| **System update** | US-6 re-import | paste → diff (CHANGED/ADDED/REMOVED + swatches) → impact line → apply-with-undo → ripple |
| **Landing** | marketing (§10) | |
Navigation: project dropdown (top-left) is the hub — all projects, new project, export,
copy TSX, import tokens, upload component, settings, profile, onboarding demo. Escape and
outside-click dismiss. Avatar → Profile everywhere.
---
## 4. Editor anatomy
### 4.1 Top bar (36px)
Wordmark · project dropdown · `✓ saved` · **view switcher** (centered: Desktop / Tablet /
Mobile / ⧉ All) · Preview · **Export** (primary) · theme toggle ◐ · avatar.
### 4.2 Activity bar (44px, fixed left)
Toggleable icon+label buttons, grouped by dividers:
`✦ Chat``Layers · Library · Tokens · History``Inspect · Polish``Code`.
Click = open that panel/tab; click again = collapse. Active = `--iris-dim` chip. At
overlay widths (<1000px) panels are mutually exclusive and float over the board.
### 4.3 Panels (resizable via edge-drag; « » collapse chevrons)
- **Chat** (284px) vertical AI thread 7): bubbles, proposal Accept/Discard inline,
refusal actions, model picker (haiku/sonnet), image attachments, scope chip.
- **Board rail** (216px) LAYERS: collapsible tree (caret-expand, double-click reveal),
per-frame **variant chips** 6); LIBRARY: 10-component base kit with mapping badges,
isolation ◉, add +, drag-to-canvas, patterns, custom `.tsx/.jsx` upload; TOKENS: brand
ramp + curated swatches + hex, radius stepper, type picker, neutrals, spacing base
every change ripples live (`nodePulse`); HISTORY: labelled steps, restore cursor 8).
- **Inspector** (244px) selection card, Edit-with-AI (scoped), content/variant/weight/
tone/size controls, gap/pad steppers (system-scale steps), Tailwind utilities
(collapsible; w/h/max-w/shadow/opacity selects + custom string), token bindings,
FRAME settings (name, width ±80, **active view** line). Polish tab: §7.3.
- **Code** (380px, dark `#161311` always) file segs (`{Frame}.tsx` / `tokens.css`),
line-numbered TSX with click-lineselect-node sync and `lineFlash` on change; **edit
mode** (toggle syntax-highlighted textarea apply parses back via the generated
grammar, line-referenced errors); footer `prettier ✓ tsc ✓`.
- **Isolation** (over board) variant × state grid per component on a paper stage, real
runtime, Esc exits.
### 4.4 Board
Infinite surface: scroll pans, ⌘/ctrl-scroll zooms at cursor, two-finger pinch, drag
empty board pans, background click deselects. Zoom cluster ( % + · fit · + frame ·
board backdrop toggle). Frames render as **view clusters** 6) with a draggable name
tab (`Home · 34 nodes · one codebase`). Selection = ink ring; hover = neutral ring;
AI-target = amber ring. Tool pill (bottom-center): **Pick (V) · Draw (D) · Overlays (O)
· Voice (stub)** icon buttons with key hints.
### 4.5 Preview
Full-screen overlay, real hover/focus states, active view's width fills the screen
(mobile/tablet render as a centered device sheet).
### 4.6 Keyboard
`⌘K` commands · `✦` focus composer · `⌘Z/⇧⌘Z` undo/redo · `⌘0` fit · `⌘1` 100% ·
`⌘±` zoom · `V/D/O` tools · `⌫` delete selection · `Esc` dismiss cascade (menu modes
refusal proposal scope selection).
---
## 5. Canvas node model
`board → frame[] → (stack | card | heading | text | button | badge | input | avatar |
image | divider | custom)*`. Layout via `dir/gap/pad/padX/padY/align/justify/maxW/bg`
(4px scale). Content props per type; `tw` bag for curated utility classes. Frames carry
`x/y/w/views/view`. IDs are stable; comment anchors and code-line mapping key off them.
---
## 6. Responsive variants — one tree, three widths
- Views: **desktop** (frame's own `w`, default 1280) · **tablet 768** · **mobile 390**.
- A frame renders every enabled view side by side (120px gap) same children, so an
edit anywhere updates every view. Captions (`tablet · 768`) label each block; the
**active view** (drives preview, code, export width) is ink-highlighted.
- Renderer responds to view width: row-stacks wrap 900, cards get flex-basis 240,
display type steps down (xl: 54 44 34px) 900/≤480.
- Switcher semantics: Desktop/Tablet/Mobile = show only that view + make it active;
** All** = all three. Layer chips: label click = select frame + make active (opens
frame settings); eye icon = show/hide that view (min 1). Cluster growth shifts
right-neighbors by the width delta clusters never collide.
---
## 7. AI, constrained
### 7.1 Compose
Prompt system prompt embeds the registry JSON (types + allowed props) + brand voice
rules (one primary CTA per group, 4px spacing, no lorem). Response is sanitized: unknown
type **refusal**, numeric props clamped to scale, strings capped. Result stages as a
**dashed ghost** on the active frame Accept commits (history label `AI compose —
accepted`), Discard drops.
### 7.2 Scoped edits
Pick tool (V) or Inspector "✦ Edit with AI": amber target ring + composer scope chip
(`h1 · Scheduling…`), inline bubble at cursor, type-aware quick actions (punchier /
shorter / demote / colorize ×n…). Edit proposals ghost beside the original and swap in
on Accept.
### 7.3 Polish (the Impeccable pass)
Ranked issues (HIGH/MED/LOW): weak hierarchy (display heading at 400), competing primary
CTAs, off-scale gaps (`gap-[18px]` drift), uneven sibling padding, AA contrast failures
(with measured ratios). Each: title *why it matters* one-click fix **in the user's
tokens**. Fix-all; empty state "Impeccable. Nothing to fix." Craft standard selectable
in Settings (Veect default / Impeccable / custom DESIGN.md).
### 7.4 Refusal protocol
Off-registry ask (3D globe, video, charts…) "You don't have a component for this yet"
+ reason + **Compose from primitives** / Not now. Never generic markup. Models: Haiku 4.5
default, Sonnet 4.5 option; offline heuristics keep demos deterministic.
---
## 8. History
Every mutation is a labelled step (`Add Button`, `Polish — Two primary actions`,
`View → tablet`, `AI edit — accepted`…). History tab lists steps newest-first with age.
Clicking restores that state and sets a **cursor**: newer steps dim (35%); the next
meaningful edit truncates the dimmed trail. `⌘Z` walks steps; redo restores.
---
## 9. Code generation & round-trip
| Canvas | Export | Notes |
|---|---|---|
| heading/text | `<h1h4>/<p>` + Tailwind | size/weight/tone classes, neutral ramp |
| button | `<Button kind size>` | `variant→kind` rename shown in mappings |
| card | `<Card>` | pad/gap classes |
| input | `<TextField label placeholder />` | |
| avatar | `<Avatar name />` | |
| badge | `@veect/base-kit` fallback + TODO | until mapped "map now" chip |
| stack | `<div>/<nav>` flex classes | |
| image/divider | semantic HTML | |
| custom upload | real `import X from "./components/…"` | props sniffed from source |
`tokens.css` generates from the live system. The **edit-mode parser** accepts exactly
this grammar back (line-referenced errors otherwise) the grammar runs both ways.
Export dialog: honest file tree, `npm i && npm run dev`, copy `{Frame}.tsx`, fidelity
badge. Guarantee line: *deterministic — same tree, same code*.
---
## 10. Landing page
Carbon-mono flat (same language). Sections: fixed nav (scroll-spy underline, blur on
scroll) · Hero ("Your system in. Real React out.", masked line reveal, magnetic CTAs,
live 5-token kit demo with 3D tilt) · proof strip (0 unregistered / <10% LOC / <20 min)
· three-pillar wedge · graceful-refusal chat (typewriter) · code panel (staggered lines)
· Polish demo (run/undo on a Solstice card) · CTA. Left ruler tracks scroll. GSAP +
ScrollTrigger, all reduced-motion-safe.
---
## 11. Codebase map & seams
See `veect-codebase/README.md` for the atomic-design map (config / engine / store /
hooks / ui / atoms / molecules / organisms / templates / pages), product invariants,
and **Handoff notes** the deliberate seams: model bridge (`engine/ai.ts#complete`),
export zip, placeholder projects, auth, persistence. Board substrate: React Flow
(`@xyflow/react`, MIT) frames as custom nodes, zustand as single source of truth.
Pre-flight: `npm run typecheck && npm run lint`.

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Veect — design-system-native canvas</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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,600;12..96,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
{
"name": "veect",
"private": true,
"version": "0.3.0",
"description": "Veect — design-system-native canvas. Your tokens and components in, production React out.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@xyflow/react": "^12.3.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.428.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9",
"postcss": "^8.4.41",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { EditorPage } from '@/pages/EditorPage';
import { HomePage } from '@/pages/HomePage';
import { OnboardingPage } from '@/pages/OnboardingPage';
import { ProfilePage } from '@/pages/ProfilePage';
import { SettingsPage } from '@/pages/SettingsPage';
import { SignInPage } from '@/pages/SignInPage';
import { SystemUpdatePage } from '@/pages/SystemUpdatePage';
import { useVeect } from '@/store/veect';
import type { Screen } from '@/types';
const PAGES: Record<Screen, () => JSX.Element> = {
signin: SignInPage,
home: HomePage,
onboarding: OnboardingPage,
editor: EditorPage,
settings: SettingsPage,
profile: ProfilePage,
sysupdate: SystemUpdatePage,
};
export default function App() {
const screen = useVeect((s) => s.screen);
const undo = useVeect((s) => s.undo);
const redo = useVeect((s) => s.redo);
const select = useVeect((s) => s.select);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const typing = ['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName);
if (e.key === 'Escape') return select(null);
if (typing) return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
(e.shiftKey ? redo : undo)();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [redo, select, undo]);
const Page = PAGES[screen];
return (
<div className="fixed inset-0 flex flex-col overflow-hidden bg-bg text-ink">
<Page />
</div>
);
}

View File

@@ -0,0 +1,14 @@
/**
* Atom — the Veect logo. Typographic: a tight semibold lowercase
* wordmark closed by a square ink terminal (the vector point).
* Inherits currentColor, so it holds in both themes and on the landing.
*/
export function Logo({ size = 14 }: { size?: number }) {
const sq = Math.max(3, Math.round(size * 0.28));
return (
<span className="flex shrink-0 items-baseline" style={{ gap: 3 }} aria-label="Veect">
<span style={{ fontSize: size, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1 }}>veect</span>
<span style={{ width: sq, height: sq, background: 'currentColor' }} />
</span>
);
}

View File

@@ -0,0 +1,28 @@
import type { PropsWithChildren } from 'react';
import { cn } from '@/lib/utils';
/** Atom — uppercase mono section label (the engraved-instrument voice). */
export function MonoLabel({ children, className }: PropsWithChildren<{ className?: string }>) {
return (
<span className={cn('font-mono text-[10px] tracking-[0.08em] text-ink-3', className)}>
{children}
</span>
);
}
/** Atom — color swatch with an inset hairline so light colors hold shape. */
export function Swatch({ hex, size = 16, radius = 2 }: { hex: string; size?: number; radius?: number }) {
return (
<span
className="inline-block shrink-0"
style={{
width: size,
height: size,
borderRadius: radius,
background: hex,
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.25)',
}}
aria-label={hex}
/>
);
}

View File

@@ -0,0 +1,36 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ChatMessage } from '@/types';
/** Molecule — one chat turn. User turns sit right in accent-dim; assistant turns left in chip. */
export function ChatBubble({
message,
actions,
}: {
message: ChatMessage;
actions?: React.ReactNode;
}) {
const user = message.role === 'user';
return (
<div className={cn('flex', user ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'flex max-w-[86%] flex-col gap-1.5 px-3 py-2 text-[12.5px] leading-relaxed text-ink',
user
? 'rounded-[2px] border border-accent/30 bg-accent-dim'
: 'rounded-[2px] border border-line bg-chip',
)}
>
{message.atts && message.atts.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{message.atts.map((a) => (
<Badge key={a.name}> {a.name}</Badge>
))}
</div>
)}
<span className="whitespace-pre-wrap">{message.text}</span>
{actions}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { MonoLabel } from '@/components/atoms/primitives';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { truncate } from '@/lib/utils';
import type { VeectNode } from '@/types';
/**
* Molecule — the chat composer: scope chip, input pill, send, model picker.
* Store-free: state in, events out.
*/
export function ChatComposer({
targetNode,
onClearTarget,
onSubmit,
thinking,
model,
onModel,
}: {
targetNode: VeectNode | null;
onClearTarget: () => void;
onSubmit: (text: string) => void;
thinking: boolean;
model: string;
onModel: (m: string) => void;
}) {
const [draft, setDraft] = useState('');
const send = () => {
const text = draft.trim();
if (!text || thinking) return;
setDraft('');
onSubmit(text);
};
return (
<div className="flex flex-col gap-2 border-t border-line p-3">
{targetNode && (
<div className="flex items-center gap-2">
<Badge tone="warn">
{targetNode.type} · {truncate(targetNode.name ?? targetNode.props.text ?? targetNode.type, 16)}
</Badge>
<button onClick={onClearTarget} className="text-[10px] text-ink-3 hover:text-ink" title="Clear scope">
clear scope
</button>
</div>
)}
<div className="flex min-h-[56px] items-start gap-2 rounded-[2px] border border-line bg-chip px-2.5 py-2">
<span className="text-[14px] text-accent-text"></span>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder={targetNode ? `refine further — scoped to this ${targetNode.type}` : 'Describe a section — composed only from your system…'}
className="border-none bg-transparent px-0"
/>
<button
onClick={send}
className="flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-[2px] bg-accent text-[13px] font-bold text-white disabled:bg-chip disabled:text-ink-3"
disabled={!draft.trim() || thinking}
title="Send"
>
</button>
</div>
<div className="flex items-center gap-2">
<Select value={model} onChange={(e) => onModel(e.target.value)}>
<option value="claude-haiku-4-5">haiku 4.5 fast</option>
<option value="claude-sonnet-4-5">sonnet 4.5 sharper</option>
</Select>
<span className="flex-1" />
<MonoLabel>constrained to your 10 components</MonoLabel>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
/**
* Molecule — the collapse chevron shown inside an open panel. Panels
* reopen from the ActivityBar, so this is the only dismiss affordance
* a panel needs.
*/
export function CollapseButton({
direction,
onCollapse,
className,
}: {
direction: 'left' | 'right';
onCollapse: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onCollapse}
title="Collapse panel"
className={cn(
'flex h-5 w-5 items-center justify-center rounded-[2px] text-[14px] text-ink-3 hover:bg-white/5 hover:text-ink',
className,
)}
>
{direction === 'left' ? '«' : '»'}
</button>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react';
import { NODE_GLYPHS } from '@/config/library';
import { activeView, VIEW_ORDER, viewsOf, viewW, type ViewKind } from '@/engine/views';
import { cn } from '@/lib/utils';
import type { VeectNode } from '@/types';
const EyeIcon = ({ off }: { off?: boolean }) => (
<svg width={10} height={10} viewBox="0 0 16 16" className="block">
<path
d="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"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
opacity={off ? 0.55 : 1}
/>
{off ? (
<path d="M2.6 13.4l10.8-10.8" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
) : (
<circle cx={8} cy={8} r={1.8} fill="none" stroke="currentColor" strokeWidth={1.5} />
)}
</svg>
);
/**
* Molecule — collapsible layer tree. Under every frame row sits a
* clearly separated variant-chips row (desktop/tablet/mobile): the dot
* toggles a view's visibility, the label makes it the active view.
*/
export function LayerTree({
frames,
selectedId,
onSelect,
onSetViews,
}: {
frames: VeectNode[];
selectedId: string | null;
onSelect: (id: string) => void;
onSetViews: (frameId: string, views: ViewKind[], view: ViewKind) => void;
}) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const rows: Array<{ node: VeectNode; depth: number; branch: boolean }> = [];
const push = (node: VeectNode, depth: number) => {
const branch = node.children.length > 0;
rows.push({ node, depth, branch });
if (expanded[node.id]) node.children.forEach((c) => push(c, depth + 1));
};
frames.forEach((f) => push(f, 0));
return (
<div className="flex flex-col">
{rows.map(({ node, depth, branch }) => (
<div key={node.id} className="flex flex-col">
<button
onClick={() => onSelect(node.id)}
className="flex w-full items-center gap-1.5 rounded-[2px] py-1 pr-2 text-[12px] hover:bg-white/5"
style={{
paddingLeft: 6 + depth * 12,
background: selectedId === node.id ? 'var(--accent-dim)' : undefined,
color: selectedId === node.id ? 'var(--t1)' : 'var(--t2)',
}}
>
<span
className="w-3 text-center text-[8px] text-ink-3"
onClick={(e) => {
e.stopPropagation();
if (branch) setExpanded({ ...expanded, [node.id]: !expanded[node.id] });
}}
>
{branch ? (expanded[node.id] ? '▾' : '▸') : ''}
</span>
<span className="w-3.5 font-mono text-[10px] opacity-70">{NODE_GLYPHS[node.type]}</span>
<span className="truncate">{node.name ?? node.props.text ?? node.type}</span>
</button>
{node.type === 'frame' && (
<div className="flex flex-wrap items-center gap-1 pb-1.5 pt-0.5" style={{ paddingLeft: 19 + depth * 12 }}>
{VIEW_ORDER.map((k) => {
const views = viewsOf(node);
const av = activeView(node);
const on = views.includes(k);
const isAv = av === k && on;
return (
<button
key={k}
title={`Set ${k} (${viewW(node, k)}px) as the active view`}
onClick={() => onSetViews(node.id, on ? views : [...views, k], k)}
className={cn(
'flex shrink-0 items-center gap-1 rounded-[2px] py-0.5 pl-1 pr-1.5 font-mono text-[9px] tracking-[0.02em]',
isAv
? 'border border-accent bg-accent font-semibold text-btn-ink'
: on
? 'border border-line-2 text-ink-2'
: 'border border-dashed border-line text-ink-3 opacity-65',
)}
>
<span
className="cursor-pointer p-px leading-none"
title={on ? 'Hide this variant' : 'Show this variant'}
onClick={(e) => {
e.stopPropagation();
let next = on ? views.filter((v) => v !== k) : [...views, k];
if (!next.length) next = [k];
onSetViews(node.id, next, next.includes(av) ? av : next[0]);
}}
>
<EyeIcon off={!on} />
</span>
{k}
</button>
);
})}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { LIBRARY, NODE_GLYPHS } from '@/config/library';
import { CUSTOMER_LIB } from '@/config/demo';
import { MonoLabel } from '@/components/atoms/primitives';
import { Badge } from '@/components/ui/badge';
import type { CustomComponent } from '@/types';
/**
* Molecule — the base kit + custom components list with mapping status,
* isolation triggers and the .tsx upload intake. Store-free.
*/
export function LibraryList({
customComps,
onIsolate,
onUpload,
}: {
customComps: CustomComponent[];
onIsolate: (type: string) => void;
onUpload: (file: File) => void;
}) {
return (
<div className="flex flex-col gap-1.5 p-1">
<MonoLabel>BASE KIT MAPPED TO YOUR CODE</MonoLabel>
{LIBRARY.map((item) => (
<div key={item.type} className="flex items-center gap-2 rounded-[2px] border border-line bg-raised px-2 py-1.5">
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-[2px] bg-chip font-mono text-[10px] text-ink-3">
{NODE_GLYPHS[item.type]}
</span>
<span className="flex-1 truncate text-[12.5px] font-medium">{item.name}</span>
<button
onClick={() => onIsolate(item.type)}
title="View in isolation — variants and states"
className="rounded-[2px] px-0.5 text-[11px] text-ink-3 hover:text-ink"
>
</button>
<Badge tone={'unmapped' in item && item.unmapped ? 'warn' : 'neutral'}>
{'primitive' in item && item.primitive ? 'html' : 'unmapped' in item && item.unmapped ? 'unmapped' : '@solstice/ui'}
</Badge>
</div>
))}
<label className="mt-1 flex cursor-pointer flex-col gap-1 rounded-[2px] border border-dashed border-line-2 px-2.5 py-2 text-ink-3 hover:border-accent hover:text-ink">
<span className="text-[11.5px] font-medium">
Upload component <span className="font-mono text-[9.5px]">.tsx / .jsx</span>
</span>
<input
type="file"
accept=".jsx,.tsx,.js"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) onUpload(f);
}}
/>
</label>
{customComps.length > 0 && <MonoLabel className="mt-2">CUSTOM YOUR CODE</MonoLabel>}
{customComps.map((cc) => (
<div key={cc.name} className="flex items-center gap-2 rounded-[2px] border border-line bg-raised px-2 py-1.5">
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-[2px] bg-accent-dim font-mono text-[10px] text-accent-text"></span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[12.5px] font-medium">{cc.name}</span>
<span className="truncate font-mono text-[9px] text-ink-3">{cc.file}</span>
</div>
<button onClick={() => onIsolate(cc.name)} title="View in isolation" className="rounded-[2px] px-0.5 text-[11px] text-ink-3 hover:text-ink">
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { PolishIssue } from '@/types';
const sevTone = { HIGH: 'danger', MED: 'warn', LOW: 'neutral' } as const;
/** Molecule — one ranked craft issue with its why (teach) and one-click fix. */
export function PolishIssueCard({ issue, onFix }: { issue: PolishIssue; onFix: () => void }) {
return (
<div className="flex flex-col gap-1.5 rounded-[2px] border border-line bg-raised p-2.5">
<div className="flex items-center gap-1.5">
<Badge tone={issue.fixed ? 'neutral' : sevTone[issue.sev]}>{issue.sev}</Badge>
<span
className={cn(
'text-[12.5px] font-semibold leading-snug',
issue.fixed ? 'text-ink-3 line-through' : 'text-ink',
)}
>
{issue.title}
</span>
</div>
<p className="text-[11.5px] leading-relaxed text-ink-2">{issue.why}</p>
{issue.fixed ? (
<span className="font-mono text-[10.5px] text-accent-text"> Fixed in your tokens</span>
) : (
<button
type="button"
onClick={onFix}
className="self-start rounded-[2px] border border-line-2 px-2 py-1 text-[11.5px] font-medium text-ink hover:border-ink-3"
>
{issue.fixLabel}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { cn } from '@/lib/utils';
export interface SegmentedOption<T extends string> {
value: T;
label: string;
}
/** Molecule — engraved segmented control (chip well + raised active seg). */
export function SegmentedControl<T extends string>({
options,
value,
onChange,
className,
}: {
options: SegmentedOption<T>[];
value: T;
onChange: (v: T) => void;
className?: string;
}) {
return (
<div className={cn('flex gap-0.5 rounded-[2px] border border-line bg-chip p-0.5', className)}>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
'flex-1 rounded-[2px] px-2.5 py-1.5 text-center text-[12px] font-medium transition-colors',
active ? 'border border-line-2 bg-raised text-ink shadow-raise' : 'border border-transparent text-ink-3 hover:text-ink-2',
)}
>
{opt.label}
</button>
);
})}
</div>
);
}
/** Molecule — dial-like numeric stepper for token-bound values. */
export function Stepper({
value,
onStep,
format = (v) => `${v}px`,
}: {
value: number;
onStep: (delta: 1 | -1) => void;
format?: (v: number) => string;
}) {
const btn =
'flex h-5 w-5 items-center justify-center rounded-[2px] border border-line-2 bg-raised text-[12px] text-ink-2 hover:text-ink';
return (
<div className="flex items-center gap-1.5 rounded-[2px] border border-line bg-chip p-1">
<button type="button" className={btn} onClick={() => onStep(-1)} aria-label="Decrease">
</button>
<span className="flex-1 text-center font-mono text-[11px]">{format(value)}</span>
<button type="button" className={btn} onClick={() => onStep(1)} aria-label="Increase">
+
</button>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { MonoLabel, Swatch } from '@/components/atoms/primitives';
import type { DesignSystem } from '@/types';
const BRAND_OPTIONS = ['#E4572E', '#2A6FDB', '#1F8A5B', '#111827'];
/**
* Molecule — the token editor: brand ramp + curated swatches + usage.
* Store-free; the parent owns the system and the ripple.
*/
export function TokenEditor({
system,
usedCount,
onBrand,
}: {
system: DesignSystem;
usedCount: number;
onBrand: (hex: string) => void;
}) {
return (
<div className="flex flex-col gap-2 p-1">
<MonoLabel>COLOR BRAND</MonoLabel>
<div className="flex overflow-hidden rounded-[2px] border border-line">
{([100, 300, 500, 700, 900] as const).map((k) => (
<span key={k} className="h-5 flex-1" style={{ background: system.ramp[k] }} title={`brand-${k}`} />
))}
</div>
<div className="flex gap-2">
{BRAND_OPTIONS.map((hex) => (
<button key={hex} title={hex} onClick={() => onBrand(hex)} className="rounded-[2px]">
<Swatch hex={hex} size={24} />
</button>
))}
</div>
<MonoLabel className="mt-2">brand-500 · used by {usedCount} components</MonoLabel>
<MonoLabel className="mt-2">RADIUS {system.radius}px · TYPE {system.font}</MonoLabel>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useReactFlow, useViewport } from '@xyflow/react';
const btn =
'px-2 py-1 font-mono text-[10.5px] text-ink-2 hover:bg-white/5 hover:text-ink';
/**
* Molecule — board zoom cluster, wired to the React Flow viewport.
* Must render inside <ReactFlow> to reach its context.
*/
export function ZoomControls() {
const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow();
const { zoom } = useViewport();
return (
<div className="absolute bottom-3 left-3 z-10 flex items-center gap-1.5">
<div className="flex items-center overflow-hidden rounded-[2px] border border-line-2 bg-raised">
<button className={btn} title="Zoom out" onClick={() => void zoomOut({ duration: 150 })}>
</button>
<button
className={`${btn} min-w-[44px] text-center`}
title="Zoom to 100%"
onClick={() => void zoomTo(1, { duration: 200 })}
>
{Math.round(zoom * 100)}%
</button>
<button className={btn} title="Zoom in" onClick={() => void zoomIn({ duration: 150 })}>
+
</button>
</div>
<button
className={`${btn} rounded-[2px] border border-line-2 bg-raised`}
title="Zoom to fit"
onClick={() => void fitView({ padding: 0.18, duration: 250 })}
>
fit
</button>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useVeect } from '@/store/veect';
const Icon = ({ d, extra }: { d: string[]; extra?: ReactNode }) => (
<svg width={14} height={14} viewBox="0 0 16 16" className="block">
{d.map((p, i) => (
<path key={i} d={p} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
))}
{extra}
</svg>
);
const ICONS: Record<string, ReactNode> = {
chat: <Icon d={['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']} />,
layers: <Icon d={['M8 2.2L14 5.4 8 8.6 2 5.4 8 2.2z', 'M2.4 8.4L8 11.4l5.6-3', 'M2.4 11.2L8 14.2l5.6-3']} />,
library: (
<Icon
d={['M2.5 2.5h4.4v4.4H2.5z', 'M9.1 2.5h4.4v4.4H9.1z', 'M2.5 9.1h4.4v4.4H2.5z']}
extra={<circle cx={11.3} cy={11.3} r={2.3} fill="none" stroke="currentColor" strokeWidth={1.5} />}
/>
),
tokens: (
<Icon
d={[]}
extra={
<>
<circle cx={6.2} cy={6.2} r={3.7} fill="none" stroke="currentColor" strokeWidth={1.5} />
<circle cx={10.2} cy={10.2} r={3.7} fill="none" stroke="currentColor" strokeWidth={1.5} />
</>
}
/>
),
inspect: (
<Icon
d={['M2.5 4.4h7', 'M12.3 4.4h1.2', 'M2.5 11.6h1.2', 'M6.3 11.6h7.2']}
extra={
<>
<circle cx={10.9} cy={4.4} r={1.5} fill="none" stroke="currentColor" strokeWidth={1.5} />
<circle cx={5} cy={11.6} r={1.5} fill="none" stroke="currentColor" strokeWidth={1.5} />
</>
}
/>
),
polish: <Icon d={['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']} />,
code: <Icon d={['M6 4.6L2.8 8 6 11.4', 'M10 4.6L13.2 8 10 11.4']} />,
};
interface Item {
key: string;
label: string;
icon: ReactNode;
active: boolean;
onClick: () => void;
}
/**
* Organism — the leftmost activity bar: every panel is a toggleable
* icon, grouped by dividers (chat · board panels · detail panels · code).
*/
export function ActivityBar() {
const { chatOpen, boardRailCollapsed, inspectorCollapsed, codeOpen, setPanel } = useVeect();
const boardTab = useVeect((s) => s.boardTab);
const setBoardTab = useVeect((s) => s.setBoardTab);
const inspTab = useVeect((s) => s.inspTab);
const setInspTab = useVeect((s) => s.setInspTab);
const boardShown = !boardRailCollapsed;
const inspShown = !inspectorCollapsed;
const openBoard = (tab: typeof boardTab) => () => {
if (boardShown && boardTab === tab) setPanel('board', false);
else {
setBoardTab(tab);
setPanel('board', true);
}
};
const openInsp = (tab: typeof inspTab) => () => {
if (inspShown && inspTab === tab) setPanel('inspector', false);
else {
setInspTab(tab);
setPanel('inspector', true);
}
};
const groups: Item[][] = [
[{ key: 'chat', label: 'Chat', icon: ICONS.chat, active: chatOpen, onClick: () => setPanel('chat', !chatOpen) }],
[
{ key: 'layers', label: 'Layers', icon: ICONS.layers, active: boardShown && boardTab === 'layers', onClick: openBoard('layers') },
{ key: 'library', label: 'Library', icon: ICONS.library, active: boardShown && boardTab === 'library', onClick: openBoard('library') },
{ key: 'tokens', label: 'Tokens', icon: ICONS.tokens, active: boardShown && boardTab === 'tokens', onClick: openBoard('tokens') },
],
[
{ key: 'inspect', label: 'Inspect', icon: ICONS.inspect, active: inspShown && inspTab === 'inspector', onClick: openInsp('inspector') },
{ key: 'polish', label: 'Polish', icon: ICONS.polish, active: inspShown && inspTab === 'polish', onClick: openInsp('polish') },
],
[{ key: 'code', label: 'Code', icon: ICONS.code, active: codeOpen, onClick: () => setPanel('code', !codeOpen) }],
];
return (
<nav className="flex w-[44px] shrink-0 flex-col items-center gap-0.5 overflow-y-auto border-r border-line bg-panel py-2">
{groups.map((group, gi) => (
<div key={gi} className="flex w-full flex-col items-center gap-0.5">
{gi > 0 && <div className="my-1 h-px w-[22px] bg-line" />}
{group.map((item) => (
<button
key={item.key}
title={item.label}
onClick={item.onClick}
className={cn(
'flex w-[38px] flex-col items-center gap-0.5 rounded-[2px] px-0 pb-1 pt-1.5',
item.active ? 'bg-accent-dim text-ink' : 'text-ink-3 hover:text-ink-2',
)}
>
{item.icon}
<span className="text-[8px] font-medium tracking-[0.03em]">{item.label}</span>
</button>
))}
</div>
))}
</nav>
);
}

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react';
import {
applyNodeChanges,
Background,
BackgroundVariant,
ReactFlow,
type Node,
type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { FrameNode } from '@/components/organisms/FrameNode';
import { ZoomControls } from '@/components/molecules/ZoomControls';
import { findNode, frames } from '@/engine/tree';
import { useVeect } from '@/store/veect';
import type { VeectNode } from '@/types';
const nodeTypes = { frame: FrameNode };
const toFlowNodes = (board: VeectNode): Node[] =>
frames(board).map((frame) => ({
id: frame.id,
type: 'frame',
position: { x: frame.props.x ?? 0, y: frame.props.y ?? 0 },
data: { frame },
dragHandle: '.frame-drag-handle',
}));
/**
* Organism — the infinite board on React Flow: scroll pans, ⌘/pinch
* zooms at the cursor, empty-pane drag pans, pane click deselects.
* The zustand tree stays the single source of truth — React Flow only
* carries frame positions and the viewport.
*/
export function BoardCanvas() {
const board = useVeect((s) => s.board);
const select = useVeect((s) => s.select);
const mutate = useVeect((s) => s.mutate);
const [nodes, setNodes] = useState<Node[]>(() => toFlowNodes(board));
// resync from the document store (add/delete/breakpoint/undo)
useEffect(() => setNodes(toFlowNodes(board)), [board]);
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);
const onNodeDragStop = useCallback(
(_e: unknown, node: Node) => {
mutate((draft) => {
const frame = findNode(draft, node.id);
if (!frame) return;
frame.props.x = Math.round(node.position.x);
frame.props.y = Math.round(node.position.y);
}, 'Move frame');
},
[mutate],
);
return (
<div className="relative h-full min-w-0 flex-1">
<ReactFlow
nodes={nodes}
edges={[]}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={onNodeDragStop}
onNodeClick={(_e, node) => select(node.id)}
onPaneClick={() => select(null)}
fitView
fitViewOptions={{ padding: 0.18, maxZoom: 1 }}
minZoom={0.08}
maxZoom={2.5}
panOnScroll
zoomOnScroll={false}
zoomOnPinch
panOnDrag={[0, 1]}
nodesFocusable={false}
deleteKeyCode={null}
className="!bg-bg"
>
<Background variant={BackgroundVariant.Dots} gap={26} size={1} color="var(--line)" />
<ZoomControls />
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { LayerTree } from '@/components/molecules/LayerTree';
import { LibraryList } from '@/components/molecules/LibraryList';
import { TokenEditor } from '@/components/molecules/TokenEditor';
import { CollapseButton } from '@/components/molecules/CollapseButton';
import { MonoLabel } from '@/components/atoms/primitives';
import { ramp } from '@/engine/color';
import { parseComponentSource } from '@/engine/upload';
import { frames, walk } from '@/engine/tree';
import { withFrameViews } from '@/engine/views';
import { useVeect } from '@/store/veect';
const TITLES = { layers: 'LAYERS', library: 'LIBRARY — YOUR SYSTEM', tokens: 'TOKENS' } as const;
/**
* Organism — the board rail. The activity bar picks the tab; this is
* a thin shell around store-free molecules.
*/
export function BoardSidebar() {
const tab = useVeect((s) => s.boardTab);
const board = useVeect((s) => s.board);
const system = useVeect((s) => s.system);
const setSystem = useVeect((s) => s.setSystem);
const select = useVeect((s) => s.select);
const selectedId = useVeect((s) => s.selectedId);
const setPanel = useVeect((s) => s.setPanel);
const setIsolate = useVeect((s) => s.setIsolate);
const setInspTab = useVeect((s) => s.setInspTab);
const customComps = useVeect((s) => s.customComps);
const addCustomComp = useVeect((s) => s.addCustomComp);
const mutate = useVeect((s) => s.mutate);
let brandUse = 0;
walk(board, (n) => {
if (['button', 'badge', 'avatar'].includes(n.type)) brandUse += 1;
});
return (
<aside className="relative flex h-full w-full flex-col border-r border-line bg-panel">
<CollapseButton direction="left" onCollapse={() => setPanel('board', false)} className="absolute right-1.5 top-2 z-10" />
<div className="mb-1.5 ml-3 mr-8 mt-3">
<MonoLabel>{TITLES[tab]}</MonoLabel>
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === 'layers' && (
<LayerTree
frames={frames(board)}
selectedId={selectedId}
onSelect={select}
onSetViews={(frameId, views, view) => {
select(frameId);
setInspTab('inspector');
setPanel('inspector', true);
mutate(
(draft) => Object.assign(draft, withFrameViews(draft, frameId, views, view)),
views.length > 1 ? `Variants → ${views.join(' + ')}` : `View → ${views[0]}`,
);
}}
/>
)}
{tab === 'library' && (
<LibraryList
customComps={customComps}
onIsolate={setIsolate}
onUpload={(file) => {
void file.text().then((src) => {
const comp = parseComponentSource(src, file.name);
if (!customComps.some((c) => c.name === comp.name)) addCustomComp(comp);
});
}}
/>
)}
{tab === 'tokens' && (
<TokenEditor
system={system}
usedCount={brandUse}
onBrand={(hex) => setSystem({ ...system, brand: hex, ramp: ramp(hex) })}
/>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,66 @@
import { MonoLabel } from '@/components/atoms/primitives';
import { ChatBubble } from '@/components/molecules/ChatBubble';
import { ChatComposer } from '@/components/molecules/ChatComposer';
import { CollapseButton } from '@/components/molecules/CollapseButton';
import { Button } from '@/components/ui/button';
import { useAiCompose } from '@/hooks/useAiCompose';
import { useVeect } from '@/store/veect';
/**
* Organism — the vertical chat column. Layout only: the constrained-AI
* orchestration lives in useAiCompose, the composer is a molecule.
*/
export function ChatPanel() {
const chat = useVeect((s) => s.chat);
const model = useVeect((s) => s.model);
const setModel = useVeect((s) => s.setModel);
const setPanel = useVeect((s) => s.setPanel);
const { submit, accept, discard, thinking, proposal, targetNode, clearTarget } = useAiCompose();
return (
<aside className="flex h-full w-full flex-col overflow-hidden border-r border-line bg-panel">
<div className="flex items-center gap-2 border-b border-line px-3 py-2">
<span className="text-[12px] text-accent-text"></span>
<MonoLabel>COMPOSE CONSTRAINED TO YOUR SYSTEM</MonoLabel>
<span className="flex-1" />
<CollapseButton direction="left" onCollapse={() => setPanel('chat', false)} />
</div>
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto overflow-x-hidden p-3">
{chat.length === 0 && (
<p className="text-[12.5px] leading-relaxed text-ink-2">
Describe a section Veect composes it from your mapped components and tokens only.
</p>
)}
{chat.map((m) => (
<ChatBubble
key={m.id}
message={m}
actions={
m.kind === 'proposal' && proposal ? (
<div className="mt-1 flex items-center gap-2">
<Button variant="primary" size="sm" onClick={accept}>
Accept
</Button>
<Button variant="ghost" size="sm" onClick={discard}>
Discard
</Button>
</div>
) : undefined
}
/>
))}
{thinking && <span className="text-[12px] text-ink-2">composing from your system</span>}
</div>
<ChatComposer
targetNode={targetNode}
onClearTarget={clearTarget}
onSubmit={(text) => void submit(text)}
thinking={thinking}
model={model}
onModel={setModel}
/>
</aside>
);
}

View File

@@ -0,0 +1,118 @@
import { useMemo, useState } from 'react';
import { CollapseButton } from '@/components/molecules/CollapseButton';
import { buildCode } from '@/engine/codegen';
import { parseGeneratedTsx } from '@/engine/parser';
import { CUSTOMER_LIB } from '@/config/demo';
import { useActiveFrame, useVeect, findNode } from '@/store/veect';
/**
* Organism — the live code companion. Deterministic tree → TSX with the
* customer's real imports; click a line to select its node. Edit mode
* round-trips: the same grammar parses back onto the canvas.
*/
export function CodePanel() {
const system = useVeect((s) => s.system);
const select = useVeect((s) => s.select);
const selectedId = useVeect((s) => s.selectedId);
const setPanel = useVeect((s) => s.setPanel);
const mutate = useVeect((s) => s.mutate);
const customComps = useVeect((s) => s.customComps);
const frame = useActiveFrame();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [error, setError] = useState<string | null>(null);
const { lines } = useMemo(
() => (frame ? buildCode(frame, system, false, customComps) : { lines: [] }),
[frame, system, customComps],
);
const text = useMemo(() => lines.map((l) => l.t).join('\n'), [lines]);
const startEdit = () => {
setDraft(text);
setError(null);
setEditing(true);
};
const apply = () => {
if (!frame) return;
const result = parseGeneratedTsx(draft, customComps);
if ('error' in result) {
setError(`Line ${result.line}: couldn't read “${result.error}” — the editor speaks the generated grammar.`);
return;
}
mutate((board) => {
const f = findNode(board, frame.id);
if (!f) return;
f.children = result.children;
if (result.name) f.name = result.name;
}, 'Edited code by hand');
setEditing(false);
setError(null);
};
return (
<aside className="relative flex h-full w-full flex-col border-l border-line bg-[#161311]">
<div className="flex h-[42px] shrink-0 items-center gap-1.5 border-b border-[#262019] px-2.5">
<CollapseButton direction="right" onCollapse={() => setPanel('code', false)} />
<span className="rounded-[2px] border border-[#2b2724] bg-[#221d18] px-2 py-1 font-mono text-[10.5px] text-[#f1eeea]">
{(frame?.name ?? 'Screen').replace(/[^A-Za-z0-9]/g, '')}.tsx
</span>
<span className="flex-1" />
<button
onClick={() => void navigator.clipboard?.writeText(text)}
className="rounded-[2px] border border-[#2b2724] px-2 py-1 font-mono text-[10.5px] text-[#8c847a] hover:text-[#f1eeea]"
>
copy
</button>
<button
onClick={() => (editing ? setEditing(false) : startEdit())}
className="rounded-[2px] border border-[#2b2724] px-2 py-1 font-mono text-[10.5px] text-[#8c847a] hover:text-[#f1eeea]"
>
{editing ? 'cancel' : 'edit'}
</button>
{editing && (
<button onClick={apply} className="rounded-[2px] bg-accent px-2.5 py-1 font-mono text-[10.5px] font-semibold text-white">
apply
</button>
)}
</div>
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
wrap="off"
className="min-h-0 flex-1 resize-none whitespace-pre bg-[#161311] p-3.5 font-mono text-[11px] leading-[1.8] text-[#cfc9c1] outline-none"
/>
) : (
<pre className="flex-1 overflow-auto py-2 font-mono text-[11.5px] leading-[1.75]">
{lines.map((line, i) => (
<div
key={i}
onClick={() => line.id && !line.id.startsWith('imp') && select(line.id)}
className="flex cursor-pointer whitespace-pre pr-3"
style={{ background: line.id && line.id === selectedId ? 'rgba(61,123,250,.09)' : undefined }}
>
<span className="w-9 shrink-0 select-none pr-3 text-right text-[10px] text-[#57504a]">{i + 1}</span>
<code className="text-[#b8b0a6]">{line.t}</code>
</div>
))}
</pre>
)}
<div className="flex h-[30px] shrink-0 items-center gap-3 border-t border-[#262019] px-3 font-mono text-[9.5px] text-[#7a7268]">
{error ? <span className="truncate text-warn">{error}</span> : (
<>
<span>prettier </span>
<span>tsc --noEmit </span>
<span className="flex-1" />
<span>imports {CUSTOMER_LIB}</span>
</>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { MonoLabel } from '@/components/atoms/primitives';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { buildCode } from '@/engine/codegen';
import { CUSTOMER_LIB } from '@/config/demo';
import { useActiveFrame, useVeect } from '@/store/veect';
const TREE = [
{ d: 0, name: 'solstice-marketing/' },
{ d: 1, name: 'package.json' },
{ d: 1, name: 'tailwind.config.ts', note: 'your tokens' },
{ d: 1, name: 'src/' },
{ d: 2, name: 'tokens.css', note: 'generated' },
{ d: 2, name: 'lib/solstice-ui.ts', note: `${CUSTOMER_LIB}` },
{ d: 2, name: 'app/' },
];
/** Organism — export dialog: honest handoff, real imports, runnable zip story. */
export function ExportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const system = useVeect((s) => s.system);
const customComps = useVeect((s) => s.customComps);
const frame = useActiveFrame();
const name = (frame?.name ?? 'Screen').replace(/[^A-Za-z0-9]/g, '');
const text = useMemo(
() => (frame ? buildCode(frame, system, false, customComps).lines.map((l) => l.t).join('\n') : ''),
[frame, system, customComps],
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" onClick={onClose}>
<div
className="flex w-[720px] max-w-full flex-col gap-4 rounded-[2px] border border-line-2 bg-panel p-5 shadow-float"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2.5">
<span className="text-[16px] font-semibold tracking-tight">Export {frame?.name ?? 'Screen'}</span>
<Badge tone="accent">PRODUCTION-GRADE</Badge>
<span className="flex-1" />
<Button variant="ghost" size="icon" onClick={onClose}>
</Button>
</div>
<p className="-mt-2 text-[12.5px] text-ink-2">Real components, real tokens nothing inlined, nothing to translate.</p>
<div className="flex flex-wrap gap-4">
<div className="flex min-w-[220px] flex-1 flex-col gap-0.5 rounded-[2px] border border-line bg-chip p-3 font-mono text-[11.5px] text-ink-2">
{TREE.map((r) => (
<div key={r.name} className="flex items-center gap-2" style={{ paddingLeft: r.d * 14 }}>
<span>{r.name}</span>
<span className="flex-1" />
{r.note && <span className="text-[9.5px] text-ink-3">{r.note}</span>}
</div>
))}
<div className="flex items-center gap-2 rounded-[2px] bg-accent-dim px-1 text-accent-text" style={{ paddingLeft: 3 * 14 }}>
<span>{name}.tsx</span>
<span className="flex-1" />
<span className="text-[9.5px]">this screen</span>
</div>
</div>
<div className="flex min-w-[220px] flex-1 flex-col gap-3">
<span className="text-[13.5px] font-semibold">Ready to ship.</span>
<code className="rounded-[2px] border border-line bg-chip px-3 py-2 font-mono text-[12px]">npm i && npm run dev</code>
<MonoLabel>deterministic same tree, same code</MonoLabel>
<span className="flex-1" />
<div className="flex gap-2">
<Button onClick={() => void navigator.clipboard?.writeText(text)}>Copy {name}.tsx</Button>
<Button variant="primary" onClick={onClose}>
Download .zip
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { memo, useState } from 'react';
import type { Node, NodeProps } from '@xyflow/react';
import { NodeRenderer } from '@/components/organisms/NodeRenderer';
import { activeView, VIEW_GAP, viewsOf, viewW } from '@/engine/views';
import { countNodes, findNode } from '@/engine/tree';
import { useVeect } from '@/store/veect';
import type { VeectNode } from '@/types';
export type FrameFlowNode = Node<{ frame: VeectNode }, 'frame'>;
/**
* Organism — a Veect frame as a React Flow custom node, rendering its
* enabled responsive views side by side. One tree, N widths: every
* view renders the SAME children through the same runtime, so an edit
* anywhere updates everywhere. Only the name tab drags.
*/
export const FrameNode = memo(function FrameNode({ data, selected }: NodeProps<FrameFlowNode>) {
const system = useVeect((s) => s.system);
const select = useVeect((s) => s.select);
const selectedId = useVeect((s) => s.selectedId);
const setPanel = useVeect((s) => s.setPanel);
const mutate = useVeect((s) => s.mutate);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const frame = data.frame;
const views = viewsOf(frame);
const av = activeView(frame);
const multi = views.length > 1;
const count = countNodes([frame]);
return (
<div className="flex items-start" style={{ gap: VIEW_GAP }}>
<div
className="frame-drag-handle absolute cursor-grab select-none whitespace-nowrap font-mono text-[12px]"
style={{
top: multi ? -46 : -26,
left: 0,
color: selected || selectedId === frame.id ? 'var(--accent-text)' : 'var(--t3)',
}}
onClick={(e) => {
e.stopPropagation();
select(frame.id);
}}
title="Drag to move · click to select"
>
{frame.name ?? 'Frame'}{' '}
<span className="text-[10px] text-ink-3">
{multi ? `${count} nodes · one codebase · ${views.length} views` : `${viewW(frame, views[0])} × auto · ${count} nodes`}
</span>
</div>
{views.map((view) => (
<div key={view} className="relative" style={{ width: viewW(frame, view) }}>
{multi && (
<button
className="absolute -top-[22px] left-0 whitespace-nowrap font-mono text-[10.5px]"
style={{ color: av === view ? 'var(--accent-text)' : 'var(--t3)' }}
title={`Set ${view} as the active view`}
onClick={(e) => {
e.stopPropagation();
mutate((draft) => {
const f = findNode(draft, frame.id);
if (f) f.props.view = view;
}, `View → ${view}`);
}}
>
{view} · {viewW(frame, view)}
</button>
)}
<NodeRenderer
node={frame}
ctx={{
mode: 'editor',
system,
frameW: viewW(frame, view),
selectedId,
hoveredId,
onSelect: select,
onHover: setHoveredId,
onInspect: (id) => {
select(id);
setPanel('inspector', true);
},
}}
/>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,119 @@
import { MonoLabel } from '@/components/atoms/primitives';
import { CollapseButton } from '@/components/molecules/CollapseButton';
import { SegmentedControl, Stepper } from '@/components/molecules/SegmentedControl';
import { PolishTab } from '@/components/organisms/PolishTab';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { findNode } from '@/engine/tree';
import { useSelectedNode, useVeect } from '@/store/veect';
import type { ButtonVariant, TextTone, Weight } from '@/types';
/** Organism — right rail: selection inspector + the Polish pass. The activity bar picks the tab. */
export function Inspector() {
const tab = useVeect((s) => s.inspTab);
const selected = useSelectedNode();
const mutate = useVeect((s) => s.mutate);
const system = useVeect((s) => s.system);
const setPanel = useVeect((s) => s.setPanel);
const setAiTarget = useVeect((s) => s.setAiTarget);
const setProp = (key: string, val: unknown) => {
if (!selected) return;
mutate((draft) => {
const n = findNode(draft, selected.id);
if (n) (n.props as Record<string, unknown>)[key] = val;
}, `Set ${key}`);
};
return (
<aside className="relative flex h-full w-full flex-col border-l border-line bg-panel">
<CollapseButton direction="right" onCollapse={() => setPanel('inspector', false)} className="absolute left-1.5 top-2 z-10" />
<div className="mb-1.5 ml-8 mr-3 mt-3">
<MonoLabel>{tab === 'polish' ? '✧ POLISH — CRAFT PASS' : 'INSPECTOR'}</MonoLabel>
</div>
<div className="flex-1 overflow-y-auto p-3">
{tab === 'polish' && <PolishTab />}
{tab === 'inspector' &&
(!selected ? (
<p className="pt-4 text-[12.5px] leading-relaxed text-ink-3">Select a component on the canvas.</p>
) : (
<div className="flex flex-col gap-4">
<div>
<span className="text-[13px] font-semibold">{selected.name ?? selected.props.text ?? selected.type}</span>
<div className="font-mono text-[10px] text-ink-3">{selected.type}</div>
</div>
<Button
variant="secondary"
size="sm"
className="self-start"
onClick={() => {
setAiTarget(selected.id);
setPanel('chat', true);
}}
>
Edit with AI scoped
</Button>
{'text' in selected.props && (
<label className="flex flex-col gap-1.5">
<MonoLabel>CONTENT</MonoLabel>
<Input value={selected.props.text ?? ''} onChange={(e) => setProp('text', e.target.value)} />
</label>
)}
{selected.type === 'button' && (
<div className="flex flex-col gap-1.5">
<MonoLabel>VARIANT</MonoLabel>
<SegmentedControl<ButtonVariant>
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Second' },
{ value: 'ghost', label: 'Ghost' },
]}
value={selected.props.variant ?? 'primary'}
onChange={(v) => setProp('variant', v)}
/>
</div>
)}
{selected.type === 'heading' && (
<div className="flex flex-col gap-1.5">
<MonoLabel>WEIGHT</MonoLabel>
<SegmentedControl<Weight>
options={[
{ value: 'regular', label: '400' },
{ value: 'semibold', label: '600' },
{ value: 'bold', label: '700' },
]}
value={selected.props.weight ?? 'semibold'}
onChange={(v) => setProp('weight', v)}
/>
</div>
)}
{selected.type === 'text' && (
<div className="flex flex-col gap-1.5">
<MonoLabel>TONE</MonoLabel>
<SegmentedControl<TextTone>
options={[
{ value: 'default', label: 'Default' },
{ value: 'muted', label: 'Muted' },
{ value: 'faint', label: 'Faint' },
]}
value={selected.props.tone ?? 'default'}
onChange={(v) => setProp('tone', v)}
/>
</div>
)}
{(selected.type === 'stack' || selected.type === 'card' || selected.type === 'frame') && (
<div className="flex flex-col gap-1.5">
<MonoLabel>GAP</MonoLabel>
<Stepper
value={selected.props.gap ?? 0}
onStep={(d) => setProp('gap', Math.max(0, (selected.props.gap ?? 0) + d * system.space))}
/>
</div>
)}
</div>
))}
</div>
</aside>
);
}

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { MonoLabel } from '@/components/atoms/primitives';
import { NodeRenderer } from '@/components/organisms/NodeRenderer';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { isolationSpec } from '@/engine/isolation';
import { useVeect } from '@/store/veect';
/**
* Organism — isolation mode: one component, every variant and state,
* rendered by the real runtime on a paper stage. Esc exits.
*/
export function IsolationView() {
const isolate = useVeect((s) => s.isolate);
const setIsolate = useVeect((s) => s.setIsolate);
const system = useVeect((s) => s.system);
const customComps = useVeect((s) => s.customComps);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setIsolate(null);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [setIsolate]);
if (!isolate) return null;
const spec = isolationSpec(isolate, customComps);
return (
<div className="absolute inset-0 z-20 flex flex-col bg-bg">
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-4 py-2.5">
<MonoLabel>ISOLATION</MonoLabel>
<span className="text-[13px] font-semibold">{spec.title}</span>
<Badge>{spec.mapNote}</Badge>
<span className="flex-1" />
<MonoLabel>real runtime · variants × states</MonoLabel>
<Button size="sm" onClick={() => setIsolate(null)}>
Esc
</Button>
</div>
<div className="flex flex-1 items-start justify-center overflow-auto p-8">
<div className="flex w-full max-w-[860px] flex-wrap items-start gap-10 rounded-[2px] border border-[#ECE8E2] bg-white p-9 shadow-float">
{spec.cells.map((cell) => (
<div key={cell.node.id} className="flex flex-col items-start gap-2.5">
<span className="font-mono text-[10px] tracking-wide text-[#8A8279]">{cell.label}</span>
<NodeRenderer node={cell.node} ctx={{ mode: 'preview', system, frameW: 1280 }} />
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { memo } from 'react';
import { controlRadius } from '@/engine/system';
import { twStyles } from '@/engine/tw';
import type { DesignSystem, VeectNode } from '@/types';
export type RenderMode = 'editor' | 'preview' | 'ghost';
export interface RenderCtx {
mode: RenderMode;
system: DesignSystem;
frameW: number;
parentDir?: 'row' | 'col';
centered?: boolean;
selectedId?: string | null;
hoveredId?: string | null;
aiTargetId?: string | null;
onSelect?: (id: string) => void;
onHover?: (id: string | null) => void;
onInspect?: (id: string) => void;
}
/**
* Organism — renders a VeectNode subtree with the CUSTOMER's tokens.
* The same renderer backs canvas, preview and (conceptually) export:
* one runtime is the fidelity guarantee.
*/
export const NodeRenderer = memo(function NodeRenderer({ node, ctx }: { node: VeectNode; ctx: RenderCtx }) {
const { system, mode } = ctx;
const N = system.neutrals;
const R = system.ramp;
const editor = mode === 'editor';
const p = node.props;
const selected = editor && ctx.selectedId === node.id;
const hovered = editor && ctx.hoveredId === node.id && !selected;
const targeted = editor && ctx.aiTargetId === node.id;
const interactive = editor
? {
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
ctx.onSelect?.(node.id);
},
onDoubleClick: (e: React.MouseEvent) => {
e.stopPropagation();
ctx.onInspect?.(node.id);
},
onMouseOver: (e: React.MouseEvent) => {
e.stopPropagation();
ctx.onHover?.(node.id);
},
}
: {};
const outline: React.CSSProperties = targeted
? { boxShadow: '0 0 0 1.5px #fff, 0 0 0 3px var(--warn)' }
: selected
? { boxShadow: '0 0 0 1.5px #fff, 0 0 0 3.5px var(--accent)' }
: hovered
? { boxShadow: '0 0 0 1.5px rgba(61,123,250,.55)' }
: {};
const base: React.CSSProperties = {
position: 'relative',
fontFamily: `'${system.font}', sans-serif`,
transition: 'background-color .4s, color .4s, border-color .4s, border-radius .4s',
...twStyles(p.tw),
...(mode === 'ghost' ? { opacity: 0.62, outline: '1.5px dashed var(--accent)', outlineOffset: 2, pointerEvents: 'none' as const } : {}),
...outline,
};
const childCtx = (dir: 'row' | 'col', centered: boolean): RenderCtx => ({
...ctx,
parentDir: dir,
centered,
});
switch (node.type) {
case 'frame':
case 'stack':
case 'card': {
const dir = p.dir ?? 'col';
const style: React.CSSProperties = {
...base,
display: 'flex',
flexDirection: dir === 'row' ? 'row' : 'column',
gap: p.gap ?? 0,
...(dir === 'row' && ctx.frameW <= 900 ? { flexWrap: 'wrap' } : {}),
...(p.pad !== undefined ? { padding: p.pad } : {}),
...(p.padX !== undefined ? { paddingLeft: p.padX, paddingRight: p.padX } : {}),
...(p.padY !== undefined ? { paddingTop: p.padY, paddingBottom: p.padY } : {}),
...(p.padBottom !== undefined ? { paddingBottom: p.padBottom } : {}),
...(p.align ? { alignItems: p.align === 'center' ? 'center' : `flex-${p.align}` } : {}),
...(p.justify === 'between' ? { justifyContent: 'space-between' } : {}),
...(p.maxW ? { maxWidth: p.maxW, margin: '0 auto', width: '100%' } : {}),
...(p.bg === 'soft' ? { background: N[50] } : {}),
};
if (node.type === 'card') {
Object.assign(style, {
background: '#fff',
border: `1px solid ${N[200]}`,
borderRadius: system.radius,
boxShadow: '0 1px 2px rgba(28,25,23,.06)',
flex: ctx.parentDir === 'row' ? (ctx.frameW <= 900 ? '1 1 240px' : '1 1 0') : undefined,
minWidth: 0,
padding: p.pad ?? 16,
});
}
if (node.type === 'frame') {
Object.assign(style, { width: '100%', background: '#fff', color: N[800] });
if (mode !== 'preview') Object.assign(style, { borderRadius: 14, overflow: 'hidden' });
}
return (
<div style={style} {...interactive}>
{node.children.map((c) => (
<NodeRenderer key={c.id} node={c} ctx={childCtx(dir, dir === 'col' && p.align === 'center')} />
))}
</div>
);
}
case 'heading': {
const fw = ctx.frameW;
const sizes: Record<string, [number, number]> = {
xl: [fw <= 480 ? 34 : fw <= 900 ? 44 : 54, -1.5],
lg: [fw <= 480 ? 29 : 40, -0.8],
md: [fw <= 480 ? 24 : 28, -0.4],
sm: [19, -0.2],
};
const [fs, ls] = sizes[p.size ?? 'md'] ?? sizes.md;
const weight = { regular: 400, semibold: 600, bold: 700 }[p.weight ?? 'semibold'];
return (
<div
style={{ ...base, fontSize: fs, letterSpacing: ls, lineHeight: 1.1, fontWeight: weight, color: N[900], textAlign: ctx.centered ? 'center' : undefined, textWrap: 'balance' }}
{...interactive}
>
{p.text}
</div>
);
}
case 'text': {
const sizes: Record<string, number> = { sm: 14, md: 16.5, lg: 19 };
const tone = { default: N[800], muted: N[600], faint: N[400] }[p.tone ?? 'default'];
return (
<div
style={{ ...base, fontSize: sizes[p.size ?? 'md'], lineHeight: 1.6, color: tone, maxWidth: 640, textAlign: ctx.centered ? 'center' : undefined }}
{...interactive}
>
{p.text}
</div>
);
}
case 'button': {
const v = p.variant ?? 'primary';
const pad = { sm: '8px 14px', md: '11px 20px', lg: '14px 26px' }[p.size as string] ?? '11px 20px';
const colors =
v === 'primary'
? { background: R[500], color: '#fff' }
: v === 'secondary'
? { background: '#fff', color: N[800], border: `1px solid ${N[300]}` }
: { color: N[700] };
return (
<div
style={{ ...base, display: 'inline-flex', padding: pad, fontWeight: 600, borderRadius: controlRadius(system), whiteSpace: 'nowrap', ...colors }}
{...interactive}
>
{p.text}
</div>
);
}
case 'badge':
return (
<div style={{ ...base, display: 'inline-flex', background: R[100], color: R[700], borderRadius: 999, padding: '5px 13px', fontSize: 12.5, fontWeight: 600 }} {...interactive}>
{p.text}
</div>
);
case 'input':
return (
<div style={{ ...base, display: 'flex', flexDirection: 'column', gap: 6, minWidth: 220 }} {...interactive}>
<span style={{ fontSize: 13, fontWeight: 600, color: N[800] }}>{p.label}</span>
<div style={{ border: `1px solid ${N[300]}`, borderRadius: controlRadius(system), padding: '10px 12px', fontSize: 14, color: N[400], background: '#fff' }}>
{p.placeholder}
</div>
</div>
);
case 'avatar': {
const initials = (p.name ?? 'A B').split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{ ...base, width: 42, height: 42, borderRadius: 999, background: R[100], color: R[700], display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 600 }} {...interactive}>
{initials}
</div>
);
}
case 'image':
return (
<div
style={{ ...base, 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: system.radius, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
{...interactive}
>
<span style={{ fontFamily: 'Fragment Mono, monospace', fontSize: 11, color: N[500] }}>{p.label}</span>
</div>
);
case 'divider':
return <div style={{ ...base, alignSelf: 'stretch', height: 1, background: N[200], width: '100%' }} {...interactive} />;
case 'custom':
return (
<div
style={{ ...base, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', padding: 18, border: `1.5px dashed ${R[300]}`, borderRadius: system.radius, background: R[50] }}
{...interactive}
>
<span style={{ fontSize: 15, fontWeight: 600, color: N[900] }}>{`<${p.comp ?? 'Component'} />`}</span>
<span style={{ fontFamily: 'Fragment Mono, monospace', fontSize: 10, color: N[500] }}>your code · {p.file}</span>
</div>
);
default:
return null;
}
});

View File

@@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { MonoLabel } from '@/components/atoms/primitives';
import { PolishIssueCard } from '@/components/molecules/PolishIssueCard';
import { Button } from '@/components/ui/button';
import { applyIssue, detectIssues } from '@/engine/polish';
import { useActiveFrame, useVeect } from '@/store/veect';
/** Organism — the Polish (Impeccable) pass: ranked craft issues with one-click token fixes. */
export function PolishTab() {
const system = useVeect((s) => s.system);
const board = useVeect((s) => s.board);
const mutate = useVeect((s) => s.mutate);
const issues = useVeect((s) => s.issues);
const setIssues = useVeect((s) => s.setIssues);
const activeFrame = useActiveFrame();
const run = () => {
if (activeFrame) setIssues(detectIssues(activeFrame, system));
};
const openCount = useMemo(() => issues.filter((i) => !i.fixed).length, [issues]);
if (issues.length === 0) {
return (
<div className="flex flex-col gap-2.5">
<p className="text-[12.5px] leading-relaxed text-ink-2">
Critique this screen against your craft standard every fix lands in your tokens, with the why.
</p>
<Button variant="primary" size="sm" onClick={run} className="self-start">
Run Polish
</Button>
<MonoLabel>standard: Impeccable</MonoLabel>
</div>
);
}
if (openCount === 0) {
return (
<div className="flex flex-col items-center gap-2 pt-8 text-center">
<span className="text-[22px] text-accent-text"></span>
<span className="text-[14px] font-semibold">Impeccable. Nothing to fix.</span>
<Button variant="ghost" size="sm" onClick={run}>
run again
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-2">
<MonoLabel>
{openCount} of {issues.length} open · ranked
</MonoLabel>
<span className="flex-1" />
<Button
size="sm"
onClick={() => {
let next = board;
issues.filter((i) => !i.fixed).forEach((i) => (next = applyIssue(next, i)));
mutate((draft) => Object.assign(draft, next), 'Polish — fix all');
setIssues(issues.map((i) => ({ ...i, fixed: true })));
}}
>
Fix all
</Button>
</div>
{issues.map((issue) => (
<PolishIssueCard
key={issue.id}
issue={issue}
onFix={() => {
mutate((draft) => Object.assign(draft, applyIssue(draft, issue)), 'Polish fix');
setIssues(issues.map((i) => (i.id === issue.id ? { ...i, fixed: true } : i)));
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Logo } from '@/components/atoms/Logo';
import { Button } from '@/components/ui/button';
import { activeView, viewsOf, withFrameViews, VIEW_ORDER, type ViewKind } from '@/engine/views';
import { PROJECT_NAME } from '@/config/demo';
import { cn } from '@/lib/utils';
import { useActiveFrame, useVeect } from '@/store/veect';
/**
* Organism — the editor top bar: project, save state, responsive-view
* switcher (Desktop / Tablet / Mobile / All — variants of one tree),
* export, theme, profile. Panel toggles live in the activity bar.
*/
export function TopBar({ onExport }: { onExport: () => void }) {
const go = useVeect((s) => s.go);
const toggleTheme = useVeect((s) => s.toggleTheme);
const mutate = useVeect((s) => s.mutate);
const frame = useActiveFrame();
const views = frame ? viewsOf(frame) : (['desktop'] as ViewKind[]);
const av = frame ? activeView(frame) : 'desktop';
const allOn = views.length === 3;
const setView = (k: ViewKind | 'all') => {
if (!frame) return;
const next = k === 'all' ? ([...VIEW_ORDER] as ViewKind[]) : [k];
const view = k === 'all' ? av : k;
mutate(
(draft) => Object.assign(draft, withFrameViews(draft, frame.id, next, view)),
k === 'all' ? 'Variants → all' : `View → ${k}`,
);
};
return (
<header className="relative z-30 flex h-9 shrink-0 items-center gap-1 border-b border-line bg-bg px-2">
<Logo />
<Button variant="ghost" size="sm" onClick={() => go('home')} className="font-medium text-ink">
{PROJECT_NAME} <span className="text-[9px] text-ink-3"></span>
</Button>
<span className="h-[16px] w-px bg-line" />
<span className="font-mono text-[10px] text-ink-3"> saved</span>
<div className="absolute left-1/2 flex -translate-x-1/2 gap-0.5 rounded-[2px] border border-line bg-chip p-0.5">
{VIEW_ORDER.map((k) => (
<button
key={k}
title={`${k} — same tree, one codebase`}
onClick={() => setView(k)}
className={cn(
'rounded-[2px] px-2.5 py-0.5 font-mono text-[10.5px]',
!allOn && views.length === 1 && av === k ? 'border border-line-2 bg-raised text-ink' : 'border border-transparent text-ink-3 hover:text-ink-2',
)}
>
{k.charAt(0).toUpperCase() + k.slice(1)}
</button>
))}
<button
title="Show all three variants side by side — one codebase"
onClick={() => setView('all')}
className={cn(
'rounded-[2px] px-2.5 py-0.5 font-mono text-[10.5px]',
allOn ? 'bg-accent-dim text-ink' : 'text-ink-3 hover:text-ink-2',
)}
>
All
</button>
</div>
<span className="flex-1" />
<Button variant="primary" size="sm" onClick={onExport}>
Export
</Button>
<Button variant="ghost" size="icon" title="Toggle theme" onClick={toggleTheme}>
</Button>
<button
onClick={() => go('profile')}
title="Profile"
className="flex h-[22px] w-[22px] items-center justify-center rounded-[2px] border border-line-2 bg-raised text-[10px] font-semibold text-ink-2 hover:border-ink-3"
>
M
</button>
</header>
);
}

View File

@@ -0,0 +1,82 @@
import { ActivityBar } from '@/components/organisms/ActivityBar';
import { BoardCanvas } from '@/components/organisms/BoardCanvas';
import { BoardSidebar } from '@/components/organisms/BoardSidebar';
import { ChatPanel } from '@/components/organisms/ChatPanel';
import { CodePanel } from '@/components/organisms/CodePanel';
import { Inspector } from '@/components/organisms/Inspector';
import { IsolationView } from '@/components/organisms/IsolationView';
import { usePanelResize } from '@/hooks/usePanelResize';
import { useVeect } from '@/store/veect';
/** A resizable docked column with a drag handle on its inner edge. */
function DockedPanel({
children,
width,
onHandleDown,
handleSide,
}: {
children: React.ReactNode;
width: number;
onHandleDown: (e: React.PointerEvent) => void;
handleSide: 'left' | 'right';
}) {
return (
<div className="relative shrink-0" style={{ width }}>
{children}
<div
onPointerDown={onHandleDown}
title="Drag to resize"
className="absolute bottom-0 top-0 z-30 w-[7px] cursor-col-resize hover:bg-accent-dim"
style={handleSide === 'right' ? { right: -3 } : { left: -3 }}
/>
</div>
);
}
/**
* Template — the editor's columns: activity bar, then chat, board rail,
* infinite canvas, inspector, and the code companion. Panels are
* toggled from the activity bar; open ones resize by dragging edges.
*/
export function EditorLayout() {
const { chatOpen, boardRailCollapsed, inspectorCollapsed, codeOpen } = useVeect();
const chat = usePanelResize(284, 250, 480);
const left = usePanelResize(216, 180, 400);
const right = usePanelResize(244, 210, 440, true);
const code = usePanelResize(380, 300, 700, true);
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
<ActivityBar />
{chatOpen && (
<DockedPanel width={chat.width} onHandleDown={chat.onHandleDown} handleSide="right">
<ChatPanel />
</DockedPanel>
)}
{!boardRailCollapsed && (
<DockedPanel width={left.width} onHandleDown={left.onHandleDown} handleSide="right">
<BoardSidebar />
</DockedPanel>
)}
<div className="relative flex min-w-0 flex-1">
<BoardCanvas />
<IsolationView />
</div>
{!inspectorCollapsed && (
<DockedPanel width={right.width} onHandleDown={right.onHandleDown} handleSide="left">
<Inspector />
</DockedPanel>
)}
{codeOpen && (
<DockedPanel width={code.width} onHandleDown={code.onHandleDown} handleSide="left">
<CodePanel />
</DockedPanel>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { Logo } from '@/components/atoms/Logo';
import { Button } from '@/components/ui/button';
import { useVeect } from '@/store/veect';
/**
* Template — shell for the flat pages (Home, Settings, Profile,
* System update): slim header, centered measure, generous bottom air.
*/
export function PageShell({
title,
back,
actions,
width = 760,
children,
}: PropsWithChildren<{ title?: string; back?: boolean; actions?: ReactNode; width?: number }>) {
const go = useVeect((s) => s.go);
const toggleTheme = useVeect((s) => s.toggleTheme);
return (
<div className="flex h-full flex-col">
<header className="flex h-[52px] shrink-0 items-center gap-2.5 border-b border-line px-4">
{back ? (
<Button variant="ghost" size="sm" onClick={() => go('home')}>
Back
</Button>
) : (
<span className="flex items-center gap-2">
<Logo />
</span>
)}
{title && <span className="text-[14px] font-semibold">{title}</span>}
<span className="flex-1" />
{actions}
<Button variant="ghost" size="icon" title="Toggle theme" onClick={toggleTheme}>
</Button>
<button
onClick={() => go('profile')}
title="Profile"
className="flex h-[26px] w-[26px] items-center justify-center rounded-[2px] border border-line-2 bg-raised text-[10.5px] font-semibold text-ink-2 hover:border-ink-3"
>
M
</button>
</header>
<main className="flex-1 overflow-y-auto">
<div className="mx-auto flex flex-col gap-6 px-8 pb-24 pt-8" style={{ maxWidth: width }}>
{children}
</div>
</main>
</div>
);
}
/** Template helper — a labelled settings section. */
export function Section({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<section className="flex flex-col gap-2.5">
<span className="font-mono text-[10px] tracking-[0.08em] text-ink-3">{label}</span>
{children}
</section>
);
}

View File

@@ -0,0 +1,26 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-[2px] px-2 py-0.5 font-mono text-[9.5px] tracking-wide',
{
variants: {
tone: {
neutral: 'border border-line bg-chip text-ink-3',
accent: 'bg-accent-dim text-accent-text',
warn: 'border border-warn/40 bg-warn/10 text-warn',
danger: 'border border-danger/40 bg-danger/10 text-danger',
},
},
defaultVariants: { tone: 'neutral' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, tone, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}

View File

@@ -0,0 +1,39 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[2px] text-[12.5px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-accent font-semibold text-btn-ink hover:opacity-90',
secondary: 'border border-line-2 text-ink hover:border-ink-3',
ghost: 'text-ink-2 hover:bg-white/5 hover:text-ink',
danger: 'border border-line text-danger hover:border-danger',
},
size: {
sm: 'h-7 px-2.5',
md: 'h-8 px-3.5',
lg: 'h-10 px-4 text-[13px]',
icon: 'h-7 w-7',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
},
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'w-full rounded-[2px] border border-line bg-chip px-3 py-2 text-[12.5px] text-ink outline-none placeholder:text-ink-3 focus:border-line-2',
className,
)}
{...props}
/>
));
Input.displayName = 'Input';
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'w-full resize-none rounded-[2px] border border-line bg-chip p-3.5 font-mono text-[12px] leading-relaxed text-ink outline-none placeholder:text-ink-3',
className,
)}
{...props}
/>
));
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Panel({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-[2px] border border-line bg-panel', className)} {...props} />;
}
export function FloatingCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-[2px] border border-line-2 bg-raised shadow-float', className)}
{...props}
/>
);
}
export function Separator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('h-px w-full bg-line', className)} {...props} />;
}

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/** Slim native select styled to the chrome (swap for Radix Select when options grow). */
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
({ className, ...props }, ref) => (
<select
ref={ref}
className={cn(
'cursor-pointer rounded-[2px] border border-line bg-chip px-1.5 py-1 font-mono text-[10.5px] text-ink-2 outline-none',
className,
)}
{...props}
/>
),
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,28 @@
/**
* Demo fiction — single source of truth for the customer identity Veect
* is shown composing against. Swapping these swaps the whole demo brand;
* nothing else hard-codes "Solstice" or the import paths.
*/
/** The customer's mapped component library (what export imports). */
export const CUSTOMER_LIB = '@solstice/ui';
/** Fallback kit for components the user hasn't mapped yet. */
export const BASE_KIT = '@veect/base-kit';
/** The customer design system on show. */
export const SYSTEM_NAME = 'Solstice';
/** The demo project opened in the editor. */
export const PROJECT_NAME = 'Solstice — Marketing site';
/** Workspace members — designer (owner) + design engineer (reviewer/veto). */
export const TEAM = [
{ initials: 'MK', name: 'Maya Kade', role: 'product designer — composes & ships', chip: 'owner' },
{
initials: 'DP',
name: 'Devon Park',
role: `design engineer — owns ${CUSTOMER_LIB}, holds the merge veto`,
chip: 'reviewer',
},
] as const;

View File

@@ -0,0 +1,30 @@
/** The base kit — 10 components, mapped to the customer's real library. */
export const LIBRARY = [
{ type: 'stack', name: 'Stack', primitive: true },
{ type: 'heading', name: 'Heading' },
{ type: 'text', name: 'Text' },
{ type: 'button', name: 'Button' },
{ type: 'badge', name: 'Badge', unmapped: true },
{ type: 'card', name: 'Card' },
{ type: 'input', name: 'Input' },
{ type: 'avatar', name: 'Avatar' },
{ type: 'image', name: 'Image', primitive: true },
{ type: 'divider', name: 'Divider', primitive: true },
] as const;
export type LibraryItem = (typeof LIBRARY)[number];
export const NODE_GLYPHS: Record<string, string> = {
frame: '▣',
stack: '▤',
card: '▢',
heading: 'Aa',
text: '¶',
button: '▭',
badge: '◦',
input: '⌷',
avatar: '◉',
image: '▧',
divider: '—',
custom: '◈',
};

View File

@@ -0,0 +1,116 @@
import { nid } from '@/engine/tree';
import type { VeectNode } from '@/types';
/** Registry-allowed props per type — the AI is hard-limited to this. */
export const AI_TYPES: Record<string, string[]> = {
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: [],
};
export const UNMAPPABLE =
/(3[\s-]?d|globe|chart|graph|map\b|video|carousel|webgl|lottie|animation|game)/i;
export type AiResult =
| { action: 'compose'; nodes: VeectNode[]; summary: string }
| { action: 'refuse'; missing: string; reason: string };
export function composeSystemPrompt(): string {
return [
'You are the constrained composer inside Veect, a design-system-native canvas.',
`You compose UI ONLY from this registry (JSON): ${JSON.stringify({ types: AI_TYPES })}`,
'Brand: Solstice — warm, plainspoken team scheduling. gap/pad in multiples of 4 (max 96).',
'At most one primary button per group; real copy; no lorem; no emoji.',
'Respond with ONLY minified JSON: {"action":"compose","summary":"…","nodes":[…]} or',
'{"action":"refuse","missing":"<thing>","reason":"<short>"} when the request needs anything outside the registry.',
].join(' ');
}
export function editSystemPrompt(subtree: VeectNode): string {
const strip = (x: VeectNode): unknown => ({
type: x.type,
props: x.props,
children: x.children.map(strip),
});
return [
'You are the constrained editor inside Veect.',
`Registry: ${JSON.stringify({ types: AI_TYPES })}`,
`You are EDITING this existing element subtree: ${JSON.stringify(strip(subtree))}`,
'Apply the instruction; keep the element type unless asked otherwise.',
'Respond ONLY minified JSON: {"action":"edit","node":{…}} or {"action":"refuse",…}.',
].join(' ');
}
/**
* Whitelist-sanitize an AI response. Unregistered types are a hard
* refusal — Veect never fails open to generic markup (the 0-unregistered
* -elements guarantee from the PRD).
*/
export function sanitize(raw: unknown[], depth = 0): { nodes: VeectNode[] } | { bad: string } {
if (!Array.isArray(raw) || depth > 6) return { bad: 'structure' };
const out: VeectNode[] = [];
for (const item of raw as Array<Record<string, unknown>>) {
if (!item || typeof item !== 'object') continue;
const type = String(item.type ?? '');
if (!(type in AI_TYPES)) return { bad: type || 'unknown' };
const props: Record<string, unknown> = {};
for (const key of AI_TYPES[type]) {
let v = (item.props as Record<string, unknown> | undefined)?.[key];
if (v === undefined || v === null) continue;
if (['gap', 'pad', 'padX', 'padY', 'maxW'].includes(key)) {
const num = Math.round(Number(v) / 4) * 4;
if (Number.isNaN(num)) continue;
v = Math.max(0, Math.min(key === 'maxW' ? 1200 : 96, num));
} else {
v = String(v).slice(0, 220);
}
props[key] = v;
}
const node: VeectNode = { id: nid('ai'), type: type as VeectNode['type'], props, children: [] };
if ((type === 'stack' || type === 'card') && Array.isArray(item.children)) {
const sub = sanitize(item.children as unknown[], depth + 1);
if ('bad' in sub) return sub;
node.children = sub.nodes;
}
out.push(node);
if (out.length > 36) break;
}
return { nodes: out };
}
/**
* Model client. In the prototype this proxied `window.claude.complete`;
* in production wire it to your own /api/compose endpoint. The offline
* fallback keeps demos deterministic.
*/
export async function complete(opts: {
system: string;
prompt: string;
model: string;
}): Promise<string> {
const w = window as unknown as {
claude?: { complete: (body: unknown) => Promise<string> };
};
if (!w.claude?.complete) throw new Error('no model client available');
return w.claude.complete({
model: opts.model,
system: opts.system,
messages: [{ role: 'user', content: opts.prompt }],
max_tokens: 3200,
});
}
export function parseAiJson(raw: string): Record<string, unknown> {
const s = raw.replace(/```(json)?/g, '').trim();
const a = s.indexOf('{');
const b = s.lastIndexOf('}');
if (a < 0 || b < 0) throw new Error('no json in response');
return JSON.parse(s.slice(a, b + 1)) as Record<string, unknown>;
}

View File

@@ -0,0 +1,170 @@
import { twClasses } from '@/engine/tw';
import { walk } from '@/engine/tree';
import { BASE_KIT, CUSTOMER_LIB } from '@/config/demo';
import type { CodeLine, CustomComponent, DesignSystem, VeectNode } from '@/types';
/** Veect component → the team's real import (name + prop renames). */
export const MAP_NAME: Partial<Record<VeectNode['type'], string>> = {
heading: 'Text',
text: 'Text',
button: 'Button',
card: 'Card',
input: 'TextField',
avatar: 'Avatar',
badge: 'Badge',
};
function spaceCls(prefix: string, px?: number): string {
if (!px) return '';
if (px % 4 === 0 && px / 4 <= 24) return `${prefix}-${px / 4}`;
return `${prefix}-[${px}px]`; // deliberately visible drift — Polish flags it
}
function neutralName(system: DesignSystem): string {
return system.neutralTone === 'cool' ? 'slate' : 'stone';
}
function stackCls(node: VeectNode): string {
const p = node.props;
const out = ['flex'];
if ((p.dir ?? 'col') === 'col') out.push('flex-col');
out.push(spaceCls('gap', p.gap));
if (p.pad !== undefined) out.push(spaceCls('p', p.pad));
if (p.padX !== undefined) out.push(spaceCls('px', p.padX));
if (p.padY !== undefined) out.push(spaceCls('py', p.padY));
if (p.padBottom !== undefined) out.push(spaceCls('pb', p.padBottom));
if (p.align === 'center') out.push('items-center');
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`);
out.push(twClasses(p.tw));
return out.filter(Boolean).join(' ');
}
/**
* Deterministic tree → TSX. Same tree, same code — that invariant is
* the trust bar. Emits the customer's real imports, never inlined clones.
*/
export function buildCode(
frame: VeectNode,
system: DesignSystem,
badgeMapped: boolean,
customComps: CustomComponent[] = [],
): { lines: CodeLine[]; mapped: string[] } {
const nn = neutralName(system);
const lines: CodeLine[] = [];
const L = (t: string, id?: string) => lines.push({ t, id });
const used = new Set<string>();
walk(frame, (node) => {
if (MAP_NAME[node.type]) used.add(node.type);
});
const mapped: string[] = [];
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') && badgeMapped) mapped.push('Badge');
if (used.has('badge') && !badgeMapped)
L(`import { Badge } from "${BASE_KIT}"; // map Badge to your library`, 'imp-badge');
if (mapped.length) L(`import { ${mapped.join(', ')} } from "${CUSTOMER_LIB}";`, 'imp');
const customs: string[] = [];
walk(frame, (node) => {
if (node.type === 'custom' && node.props.comp && !customs.includes(node.props.comp)) customs.push(node.props.comp);
});
customs.forEach((name) => {
const cc = customComps.find((c) => c.name === name);
L(`import ${name} from "./components/${(cc?.file ?? name).replace(/\.(jsx|tsx|js)$/i, '')}"; // your upload`, `imp-${name}`);
});
L('');
const comp = (frame.name ?? 'Screen').replace(/[^A-Za-z0-9]/g, '') || 'Screen';
L(`export default function ${comp}() {`);
L(' return (');
emit(frame, 2, L, nn);
L(' );');
L('}');
return { lines, mapped };
}
function emit(node: VeectNode, depth: number, L: (t: string, id?: string) => void, nn: string): void {
const ind = ' '.repeat(depth);
const p = node.props;
switch (node.type) {
case 'frame': {
L(`${ind}<main className="flex flex-col bg-white">`, node.id);
node.children.forEach((c) => emit(c, depth + 1, L, nn));
L(`${ind}</main>`, node.id);
return;
}
case 'stack': {
const tag = node.name === 'Nav' ? 'nav' : 'div';
L(`${ind}<${tag} className="${stackCls(node)}">`, node.id);
node.children.forEach((c) => emit(c, depth + 1, L, nn));
L(`${ind}</${tag}>`, node.id);
return;
}
case 'card': {
const cls = [spaceCls('p', p.pad ?? 16), spaceCls('gap', p.gap), 'flex-1'].filter(Boolean).join(' ');
L(`${ind}<Card className="${cls}">`, node.id);
node.children.forEach((c) => emit(c, depth + 1, L, nn));
L(`${ind}</Card>`, node.id);
return;
}
case 'heading': {
const sz = { xl: 'text-6xl', lg: 'text-4xl', md: 'text-3xl', sm: 'text-xl' }[p.size as string] ?? 'text-3xl';
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">${p.text ?? ''}</${tag}>`, node.id);
return;
}
case 'text': {
const sz = { sm: 'text-sm', md: 'text-base', lg: 'text-lg' }[p.size as string] ?? 'text-base';
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>`, node.id);
return;
}
case 'button':
L(`${ind}<Button kind="${p.variant ?? 'primary'}" size="${p.size ?? 'md'}">${p.text ?? ''}</Button>`, node.id);
return;
case 'badge':
L(`${ind}<Badge>${p.text ?? ''}</Badge>`, node.id);
return;
case 'input':
L(`${ind}<TextField label="${p.label ?? ''}" placeholder="${p.placeholder ?? ''}" />`, node.id);
return;
case 'avatar':
L(`${ind}<Avatar name="${p.name ?? ''}" />`, node.id);
return;
case 'image':
L(`${ind}<div className="aspect-video w-full rounded-[var(--radius-md)] border border-dashed border-${nn}-300 bg-${nn}-100" /> {/* image slot */}`, node.id);
return;
case 'divider':
L(`${ind}<hr className="w-full border-${nn}-200" />`, node.id);
return;
case 'custom':
L(`${ind}<${p.comp ?? 'Component'} />`, node.id);
return;
default:
return;
}
}
/** tokens.css — generated from the live system. */
export function buildTokensCss(system: DesignSystem): CodeLine[] {
const L: CodeLine[] = [];
const push = (t: string, id?: string) => L.push({ t, id });
push(`/* tokens.css — generated from your system · ${system.name} */`);
push(':root {');
push(` --brand-500: ${system.ramp[500]};`, 'tok-brand');
push(` --brand-600: ${system.ramp[600]};`, 'tok-brand');
push(` --brand-100: ${system.ramp[100]};`, 'tok-brand');
push(` --neutral-600: ${system.neutrals[600]};`);
push(` --neutral-900: ${system.neutrals[900]};`);
push(` --radius-md: ${system.radius}px;`, 'tok-radius');
push(` --font-display: "${system.font}";`);
push(` --space-base: ${system.space}px;`);
push('}');
return L;
}

View File

@@ -0,0 +1,62 @@
import type { NeutralRamp } from '@/types';
export function hexToRgb(hex: string): [number, number, number] {
let h = hex.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)];
}
export function rgbToHex(r: number, g: number, b: number): string {
const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
return `#${c(r)}${c(g)}${c(b)}`.toUpperCase();
}
/** Linear mix of two hex colors, t ∈ [0,1]. */
export function mix(a: string, b: string, t: number): string {
const [ar, ag, ab] = hexToRgb(a);
const [br, bg, bb] = hexToRgb(b);
return rgbToHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t);
}
/** Derive a 50900 ramp from a single brand hex (500 = the brand itself). */
export function ramp(hex: string): NeutralRamp {
return {
50: mix(hex, '#FFFFFF', 0.94),
100: mix(hex, '#FFFFFF', 0.86),
200: mix(hex, '#FFFFFF', 0.68),
300: mix(hex, '#FFFFFF', 0.45),
400: mix(hex, '#FFFFFF', 0.22),
500: hex,
600: mix(hex, '#000000', 0.16),
700: mix(hex, '#000000', 0.34),
800: mix(hex, '#000000', 0.52),
900: mix(hex, '#000000', 0.68),
};
}
function luminance(hex: string): number {
const [r, g, b] = hexToRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** WCAG contrast ratio between two hex colors. */
export function contrastRatio(a: string, b: string): number {
const l1 = luminance(a);
const l2 = luminance(b);
const hi = Math.max(l1, l2);
const lo = Math.min(l1, l2);
return (hi + 0.05) / (lo + 0.05);
}
export const WARM_NEUTRALS: NeutralRamp = {
50: '#FAFAF9', 100: '#F5F5F4', 200: '#E7E5E4', 300: '#D6D3D1', 400: '#A8A29E',
500: '#78716C', 600: '#57534E', 700: '#44403C', 800: '#292524', 900: '#1C1917',
};
export const COOL_NEUTRALS: NeutralRamp = {
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1', 400: '#94A3B8',
500: '#64748B', 600: '#475569', 700: '#334155', 800: '#1E293B', 900: '#0F172A',
};

View File

@@ -0,0 +1,124 @@
import { nid } from '@/engine/tree';
import { MAP_NAME } from '@/engine/codegen';
import { CUSTOMER_LIB } from '@/config/demo';
import type { CustomComponent, NodeProps, VeectNode } from '@/types';
export interface IsolationCell {
label: string;
node: VeectNode;
}
export interface IsolationSpec {
title: string;
mapNote: string;
cells: IsolationCell[];
}
const mk = (type: VeectNode['type'], props: NodeProps, children: VeectNode[] = []): VeectNode => ({
id: nid('iso'),
type,
props,
children,
});
/**
* Variant × state grid for a library component, rendered by the SAME
* runtime as canvas and preview — isolation shows the real thing.
*/
export function isolationSpec(type: string, customComps: CustomComponent[] = []): IsolationSpec {
const custom = customComps.find((c) => c.name === type);
if (custom) {
return {
title: custom.name,
mapNote: `your code · ${custom.file}`,
cells: [{ label: custom.file, node: mk('custom', { comp: custom.name, file: custom.file }) }],
};
}
const mapNote =
type === 'badge'
? 'unmapped — Veect fallback'
: MAP_NAME[type as VeectNode['type']]
? `${CUSTOMER_LIB}/${MAP_NAME[type as VeectNode['type']]}`
: '→ semantic HTML';
const title = type.charAt(0).toUpperCase() + type.slice(1);
switch (type) {
case 'button':
return {
title,
mapNote,
cells: (['primary', 'secondary', 'ghost'] as const).map((variant) => ({
label: `${variant} — hover me`,
node: mk('button', { text: 'Get started', variant, size: 'md' }),
})),
};
case 'heading':
return {
title,
mapNote,
cells: (['xl', 'lg', 'md', 'sm'] as const).map((size) => ({
label: `size ${size}`,
node: mk('heading', { text: 'Scheduling, kindly', size, weight: 'semibold' }),
})),
};
case 'text':
return {
title,
mapNote,
cells: (['default', 'muted', 'faint'] as const).map((tone) => ({
label: `tone ${tone}`,
node: mk('text', { text: 'Body copy that explains one idea clearly.', size: 'md', tone }),
})),
};
case 'card':
return {
title,
mapNote,
cells: [
{
label: 'default — hover me',
node: mk('card', { pad: 20, gap: 10 }, [
mk('heading', { text: 'Card title', size: 'sm', weight: 'semibold' }),
mk('text', { text: 'The runtime here is real.', size: 'sm', tone: 'muted' }),
]),
},
],
};
case 'input':
return { title, mapNote, cells: [{ label: 'default', node: mk('input', { label: 'Email', placeholder: 'you@company.com' }) }] };
case 'avatar':
return {
title,
mapNote,
cells: [
{ label: 'MK', node: mk('avatar', { name: 'Maya Kade' }) },
{ label: 'DP', node: mk('avatar', { name: 'Devon Park' }) },
],
};
case 'image':
return {
title,
mapNote,
cells: [
{ label: '16 / 9', node: mk('image', { label: 'image slot', ratio: '16/9' }) },
{ label: '1 / 1', node: mk('image', { label: 'image slot', ratio: '1/1' }) },
],
};
case 'badge':
return { title, mapNote, cells: [{ label: 'default', node: mk('badge', { text: 'New — shared calendars' }) }] };
case 'divider':
return { title, mapNote, cells: [{ label: 'hairline', node: mk('divider', {}) }] };
default:
return {
title,
mapNote,
cells: [
{
label: 'row · gap 12',
node: mk('stack', { dir: 'row', gap: 12 }, [mk('badge', { text: 'One' }), mk('badge', { text: 'Two' }), mk('badge', { text: 'Three' })]),
},
],
};
}
}

View File

@@ -0,0 +1,132 @@
import { nid } from '@/engine/tree';
import type { CustomComponent, NodeProps, VeectNode } from '@/types';
export type ParseResult = { children: VeectNode[]; name: string | null } | { error: string; line: number };
const node = (type: VeectNode['type'], props: NodeProps, name?: string): VeectNode => ({
id: nid(),
type,
name,
props,
children: [],
});
function twNum(v: string): number {
const bracket = v.match(/^\[(\d+)px\]$/);
if (bracket) return parseInt(bracket[1], 10);
const n = parseFloat(v);
return Number.isNaN(n) ? 0 : Math.round(n * 4);
}
function parseClasses(str = ''): NodeProps {
const p: NodeProps = {};
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 (c.startsWith('gap-')) p.gap = twNum(c.slice(4));
else if (c.startsWith('px-')) p.padX = twNum(c.slice(3));
else if (c.startsWith('py-')) p.padY = twNum(c.slice(3));
else if (c.startsWith('pb-')) p.padBottom = twNum(c.slice(3));
else if (c.startsWith('p-')) p.pad = twNum(c.slice(2));
else if (/^max-w-\[(\d+)px\]$/.test(c)) p.maxW = parseInt(c.match(/\d+/)![0], 10);
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;
}
/**
* Line-grammar parser for Veect's OWN generated TSX. Because codegen is
* deterministic, editing round-trips: the grammar runs both ways. Anything
* outside the grammar is an explicit, line-referenced error — never a guess.
*/
export function parseGeneratedTsx(text: string, customComps: CustomComponent[] = []): ParseResult {
const lines = text.split('\n');
const root: { children: VeectNode[] } = { children: [] };
const stack: Array<{ children: VeectNode[] }> = [root];
let fnName: string | null = null;
const push = (n: VeectNode) => 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: RegExpMatchArray | null;
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 = node('stack', parseClasses(m[2]), m[1] === 'nav' ? 'Nav' : undefined);
push(n);
stack.push(n);
continue;
}
if ((m = t.match(/^<Card(?: className="([^"]*)")?>$/))) {
const cls = parseClasses(m[1]);
const n = node('card', { pad: cls.pad ?? 16, gap: cls.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(node('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(node('text', { text: m[2], size, tone }));
continue;
}
if ((m = t.match(/^<Button kind="(\w+)"(?: size="(\w+)")?(?: className="[^"]*")?>(.*)<\/Button>$/))) {
push(node('button', { text: m[3], variant: m[1] as NodeProps['variant'], size: (m[2] ?? 'md') as NodeProps['size'] }));
continue;
}
if ((m = t.match(/^<Badge>(.*)<\/Badge>$/))) {
push(node('badge', { text: m[1] }));
continue;
}
if ((m = t.match(/^<TextField label="([^"]*)" placeholder="([^"]*)"\s*\/>$/))) {
push(node('input', { label: m[1], placeholder: m[2] }));
continue;
}
if ((m = t.match(/^<Avatar name="([^"]*)"\s*\/>$/))) {
push(node('avatar', { name: m[1] }));
continue;
}
if (/^<hr\b/.test(t)) {
push(node('divider', {}));
continue;
}
if (/^<div className="aspect-/.test(t)) {
push(node('image', { label: 'image — drop artwork', ratio: '16/9' }));
continue;
}
if ((m = t.match(/^<([A-Z]\w*)\s*\/>$/)) && customComps.some((c) => c.name === m![1])) {
const cc = customComps.find((c) => c.name === m![1])!;
push(node('custom', { comp: cc.name, file: cc.file }, cc.name));
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 };
}

View File

@@ -0,0 +1,108 @@
import { contrastRatio } from '@/engine/color';
import { clone, findNode, isContainer, walk } from '@/engine/tree';
import type { DesignSystem, PolishIssue, VeectNode } from '@/types';
const snippet = (n: VeectNode): string => {
const s = n.props.text ?? '';
return s.length > 30 ? `${s.slice(0, 30)}` : s;
};
/**
* The Polish pass — critique a frame against the craft standard
* (Impeccable-derived rules). Every issue carries a *why* (teach)
* and a one-click fix expressed in the user's own tokens.
*/
export function detectIssues(frame: VeectNode, system: DesignSystem): PolishIssue[] {
const out: PolishIssue[] = [];
walk(frame, (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 (isContainer(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 ${system.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 },
});
}
const cards = n.children.filter((c) => c.type === 'card');
if (cards.length >= 2) {
const pads = cards.map((c) => c.props.pad ?? 16);
if (new Set(pads).size > 1) {
out.push({
id: `pad-${n.id}`,
sev: 'LOW',
title: `Uneven card padding — ${pads.join(' / ')}`,
why: "Siblings should read as one family. The odd card breaks the row's optical rhythm — same padding, same rhythm.",
fixLabel: 'Fix — pad → 16px (space-4)',
apply: { op: 'padAll', ids: cards.map((c) => c.id), val: 16 },
});
}
}
}
if (n.type === 'text' && (p.tone ?? 'default') === 'faint') {
const r = contrastRatio(system.neutrals[400], '#FFFFFF').toFixed(1);
const r2 = contrastRatio(system.neutrals[600], '#FFFFFF').toFixed(1);
out.push({
id: `con-${n.id}`,
sev: 'HIGH',
title: 'Contrast below AA — neutral-400 on white',
why: `${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.`,
fixLabel: 'Fix — tone → neutral-600',
apply: { op: 'set', id: n.id, key: 'tone', val: 'muted' },
});
}
});
const rank: Record<PolishIssue['sev'], number> = { HIGH: 0, MED: 1, LOW: 2 };
return out.sort((a, b) => rank[a.sev] - rank[b.sev]);
}
/** Apply a fix immutably; returns a new board. */
export function applyIssue(board: VeectNode, issue: PolishIssue): VeectNode {
const next = clone(board);
const a = issue.apply;
if (a.op === 'set') {
const n = findNode(next, a.id);
if (n) (n.props as Record<string, unknown>)[a.key] = a.val;
} else {
a.ids.forEach((id) => {
const n = findNode(next, id);
if (n) n.props.pad = a.val;
});
}
return next;
}

View File

@@ -0,0 +1,70 @@
import { nid } from '@/engine/tree';
import type { VeectNode } from '@/types';
const n = (
type: VeectNode['type'],
props: VeectNode['props'],
children: VeectNode[] = [],
name?: string,
): VeectNode => ({ id: nid(), type, name, props, children });
/**
* The starter board: a Home frame with deliberate craft flaws
* (weight-400 display heading, competing primary CTAs, off-scale
* gap, AA-failing subcopy, uneven card padding) so the Polish pass
* has honest work to do — plus an empty Pricing frame for AI compose.
*/
export function starterBoard(): VeectNode {
const home = n(
'frame',
{ dir: 'col', gap: 0, pad: 0, x: 0, y: 0, w: 1280 },
[
n('stack', { dir: 'row', gap: 16, padX: 40, padY: 20, align: 'center', justify: 'between' }, [
n('heading', { text: 'Solstice', size: 'sm', weight: 'bold' }, [], 'Logo'),
n('stack', { dir: 'row', gap: 28, align: 'center' }, [
n('text', { text: 'Product', size: 'sm', tone: 'muted' }),
n('text', { text: 'Pricing', size: 'sm', tone: 'muted' }),
n('text', { text: 'Journal', size: 'sm', tone: 'muted' }),
], 'Links'),
n('stack', { dir: 'row', gap: 12, align: 'center' }, [
n('button', { text: 'Sign in', variant: 'ghost', size: 'sm' }),
n('button', { text: 'Get started', variant: 'primary', size: 'sm' }),
], 'Actions'),
], 'Nav'),
n('stack', { dir: 'col', gap: 18, padX: 32, padY: 76, align: 'center' }, [
n('badge', { text: 'New — shared team calendars' }),
n('heading', { text: 'Scheduling that feels like sunlight', size: 'xl', weight: 'regular' }),
n('text', {
text: 'Solstice finds the hour that works in every timezone — automatically, and kindly.',
size: 'lg',
tone: 'faint',
}),
n('stack', { dir: 'row', gap: 12, align: 'center' }, [
n('button', { text: 'Start free', variant: 'primary', size: 'md' }),
n('button', { text: 'Book a demo', variant: 'primary', size: 'md' }),
], 'CTAs'),
], 'Hero'),
n('stack', { dir: 'row', gap: 24, padX: 40, padY: 12, padBottom: 64 }, [
n('card', { pad: 14, gap: 12 }, [
n('heading', { text: 'One calendar, every timezone', size: 'sm', weight: 'semibold' }),
n('text', { text: "See the whole team's day in your own hours — no math.", size: 'sm', tone: 'muted' }),
], 'Card — Timezones'),
n('card', { pad: 16, gap: 12 }, [
n('heading', { text: 'Meetings that fit', size: 'sm', weight: 'semibold' }),
n('text', { text: "Solstice proposes times inside everyone's focus hours.", size: 'sm', tone: 'muted' }),
], 'Card — Meetings'),
n('card', { pad: 16, gap: 12 }, [
n('heading', { text: 'Quiet by default', size: 'sm', weight: 'semibold' }),
n('text', { text: 'Protected mornings and no-meeting days, enforced gently.', size: 'sm', tone: 'muted' }),
], 'Card — Quiet'),
], 'Features'),
],
'Home',
);
home.id = 'frame';
const pricing = n('frame', { dir: 'col', gap: 0, pad: 0, x: 1420, y: 0, w: 1280 }, [], 'Pricing');
pricing.id = 'frame2';
return { id: 'board', type: 'board', name: 'Board', props: {}, children: [home, pricing] };
}

View File

@@ -0,0 +1,37 @@
import { COOL_NEUTRALS, WARM_NEUTRALS, ramp } from '@/engine/color';
import type { DesignSystem } from '@/types';
export interface SystemSeed {
brand: string;
neutralTone: 'warm' | 'cool';
radius: number;
font: string;
space: 4 | 8;
}
/** The demo customer system — a warm scheduling brand. */
export const SOLSTICE_SEED: SystemSeed = {
brand: '#E4572E',
neutralTone: 'warm',
radius: 12,
font: 'Bricolage Grotesque',
space: 4,
};
export function buildSystem(seed: SystemSeed, name = 'Solstice'): DesignSystem {
return {
name,
brand: seed.brand,
ramp: ramp(seed.brand),
neutrals: seed.neutralTone === 'cool' ? COOL_NEUTRALS : WARM_NEUTRALS,
neutralTone: seed.neutralTone,
radius: seed.radius,
font: seed.font,
space: seed.space,
};
}
/** Control radius derives from the system radius — never a magic number. */
export function controlRadius(system: DesignSystem): number {
return Math.max(4, Math.round(system.radius * 0.66));
}

View File

@@ -0,0 +1,73 @@
import type { VeectNode } from '@/types';
let counter = 0;
export function nid(prefix = 'n'): string {
counter += 1;
return `${prefix}${counter}`;
}
export function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function findNode(root: VeectNode | null, id: string | null): VeectNode | null {
if (!root || !id) return null;
if (root.id === id) return root;
for (const child of root.children) {
const hit = findNode(child, id);
if (hit) return hit;
}
return null;
}
export function findParent(root: VeectNode, id: string): VeectNode | null {
for (const child of root.children) {
if (child.id === id) return root;
const hit = findParent(child, id);
if (hit) return hit;
}
return null;
}
export function walk(node: VeectNode, fn: (n: VeectNode, depth: number) => void, depth = 0): void {
fn(node, depth);
node.children.forEach((c) => walk(c, fn, depth + 1));
}
export function countNodes(nodes: VeectNode[]): number {
let n = 0;
nodes.forEach((node) => walk(node, () => (n += 1)));
return n;
}
/** Re-assign fresh ids across a subtree (used when duplicating / inserting patterns). */
export function reId(node: VeectNode): VeectNode {
node.id = nid();
node.children.forEach(reId);
return node;
}
export function isContainer(node: VeectNode | null): boolean {
return !!node && (node.type === 'stack' || node.type === 'card' || node.type === 'frame');
}
export function frames(board: VeectNode | null): VeectNode[] {
if (!board) return [];
return board.type === 'board' ? board.children : [board];
}
/** The frame that contains a given node id (or the node itself if it is a frame). */
export function frameOf(board: VeectNode | null, id: string): VeectNode | null {
let result: VeectNode | null = null;
const dfs = (n: VeectNode, current: VeectNode | null): boolean => {
const cur = n.type === 'frame' ? n : current;
if (n.id === id) {
result = cur;
return true;
}
return n.children.some((c) => dfs(c, cur));
};
if (board) dfs(board, null);
return result;
}

View File

@@ -0,0 +1,61 @@
import type { NodeProps } from '@/types';
/**
* Curated Tailwind utilities the inspector exposes, grouped by category.
* Canvas rendering and codegen read the same map, so what you pick is
* what renders AND what exports — no divergence.
*/
export const TW_CATEGORIES = [
{ key: 'w', label: 'width', options: ['w-full', 'w-1/2', 'w-1/3', 'w-2/3', 'w-64', 'w-96'] },
{ key: 'h', label: 'height', options: ['h-auto', 'h-24', 'h-48', 'h-64', 'h-96'] },
{ key: 'maxw', label: 'max-w', options: ['max-w-sm', 'max-w-md', 'max-w-lg', 'max-w-2xl'] },
{ key: 'shadow', label: 'shadow', options: ['shadow-sm', 'shadow-md', 'shadow-lg'] },
{ key: 'opacity', label: 'opacity', options: ['opacity-75', 'opacity-50'] },
] as const;
export type TwCategory = (typeof TW_CATEGORIES)[number]['key'];
const TW_MAP: Record<string, React.CSSProperties> = {
'w-full': { width: '100%' },
'w-1/2': { width: '50%' },
'w-1/3': { width: '33.333%' },
'w-2/3': { width: '66.667%' },
'w-64': { width: 256 },
'w-96': { width: 384 },
'h-auto': { height: 'auto' },
'h-24': { height: 96 },
'h-48': { height: 192 },
'h-64': { height: 256 },
'h-96': { height: 384 },
'max-w-sm': { maxWidth: 384 },
'max-w-md': { maxWidth: 448 },
'max-w-lg': { maxWidth: 512 },
'max-w-2xl': { maxWidth: 672 },
'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 },
};
/** Inline styles for the canvas renderer. */
export function twStyles(tw: NodeProps['tw']): React.CSSProperties {
if (!tw) return {};
const out: React.CSSProperties = {};
(Object.keys(tw) as Array<keyof NonNullable<NodeProps['tw']>>).forEach((key) => {
const cls = tw[key];
if (key !== 'custom' && cls && TW_MAP[cls]) Object.assign(out, TW_MAP[cls]);
});
return out;
}
/** Class string for codegen (custom classes pass through verbatim). */
export function twClasses(tw: NodeProps['tw']): string {
if (!tw) return '';
const ordered: Array<keyof NonNullable<NodeProps['tw']>> = ['w', 'h', 'maxw', 'shadow', 'opacity', 'custom'];
return ordered
.map((k) => tw[k])
.filter(Boolean)
.join(' ')
.trim();
}

View File

@@ -0,0 +1,28 @@
import type { CustomComponent } from '@/types';
/**
* Parse an uploaded React source file into a library entry:
* component name (export default / function / const) and the first
* destructured props. Pure and unit-testable.
*/
export function parseComponentSource(src: string, filename: string): CustomComponent {
const 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*[:=]/);
const fallback = filename
.replace(/\.(jsx|tsx|js)$/i, '')
.replace(/(^|[-_ ])(\w)/g, (_a, _b, c: string) => c.toUpperCase());
const name = m?.[1] ?? (/^[A-Z]/.test(fallback) ? fallback : `C${fallback}`);
const pm = src.match(/\(\s*\{\s*([^}]{0,140})\}/);
const props = pm
? pm[1]
.split(',')
.map((x) => x.trim().split(/[=:]/)[0].trim())
.filter((x) => /^\w+$/.test(x))
.slice(0, 6)
: [];
return { name, file: filename, props };
}

View File

@@ -0,0 +1,60 @@
import { clone, findNode, frames } from '@/engine/tree';
import type { VeectNode } from '@/types';
export const VIEW_ORDER = ['desktop', 'tablet', 'mobile'] as const;
export type ViewKind = (typeof VIEW_ORDER)[number];
/** Width of one responsive view of a frame (desktop honors the frame's own w). */
export function viewW(frame: VeectNode, view: ViewKind): number {
if (view === 'tablet') return 768;
if (view === 'mobile') return 390;
return frame.props.w ?? 1280;
}
/** Enabled views, in canonical order. Every frame has at least one. */
export function viewsOf(frame: VeectNode): ViewKind[] {
const v = frame.props.views as ViewKind[] | undefined;
const arr = Array.isArray(v) && v.length ? v : ['desktop' as ViewKind];
return VIEW_ORDER.filter((k) => arr.includes(k));
}
/** The active view — drives preview, code panel and export width. */
export function activeView(frame: VeectNode): ViewKind {
const views = viewsOf(frame);
const av = frame.props.view as ViewKind | undefined;
return av && views.includes(av) ? av : views[0];
}
export const VIEW_GAP = 120;
/** Total board width of a frame's view cluster. */
export function clusterW(frame: VeectNode): number {
const views = viewsOf(frame);
return views.reduce((acc, k) => acc + viewW(frame, k), 0) + VIEW_GAP * (views.length - 1);
}
/**
* Pure board transform: set a frame's views/active view and shift every
* frame to its right by the cluster-width delta so clusters never collide.
*/
export function withFrameViews(
board: VeectNode,
frameId: string,
views: ViewKind[],
view?: ViewKind,
): VeectNode {
const next = clone(board);
const frame = findNode(next, frameId);
if (!frame) return next;
const before = clusterW(frame);
frame.props.views = views;
if (view) frame.props.view = view;
const delta = clusterW(frame) - before;
if (delta !== 0) {
const x0 = frame.props.x ?? 0;
frames(next).forEach((f) => {
if (f.id !== frameId && (f.props.x ?? 0) > x0) f.props.x = (f.props.x ?? 0) + delta;
});
}
return next;
}

View File

@@ -0,0 +1,115 @@
import { useMemo, useState } from 'react';
import { complete, composeSystemPrompt, editSystemPrompt, parseAiJson, sanitize, UNMAPPABLE } from '@/engine/ai';
import { countNodes, findNode, findParent } from '@/engine/tree';
import { useActiveFrame, useVeect } from '@/store/veect';
import type { VeectNode } from '@/types';
/**
* Hook — the constrained-AI orchestration: compose into the active frame,
* or (when a target is scoped) stage an edit for one subtree. Proposals
* are ghosts until accepted; anything off-registry is a refusal.
*/
export function useAiCompose() {
const pushChat = useVeect((s) => s.pushChat);
const proposal = useVeect((s) => s.proposal);
const setProposal = useVeect((s) => s.setProposal);
const model = useVeect((s) => s.model);
const mutate = useVeect((s) => s.mutate);
const board = useVeect((s) => s.board);
const aiTarget = useVeect((s) => s.aiTarget);
const setAiTarget = useVeect((s) => s.setAiTarget);
const activeFrame = useActiveFrame();
const [thinking, setThinking] = useState(false);
const targetNode: VeectNode | null = useMemo(
() => (aiTarget ? findNode(board, aiTarget) : null),
[aiTarget, board],
);
const submit = async (text: string) => {
if (!text.trim() || thinking) return;
pushChat({ role: 'user', text });
const blocked = text.match(UNMAPPABLE);
if (blocked) {
pushChat({
role: 'assistant',
kind: 'refusal',
text: `You don't have a component for this yet. Your registry maps 10 Solstice components — nothing renders a ${blocked[0]}. Veect never falls back to generic markup.`,
});
return;
}
setThinking(true);
try {
if (targetNode) {
const raw = await complete({ system: editSystemPrompt(targetNode), prompt: text, model });
const json = parseAiJson(raw);
if (json.action === 'refuse') {
pushChat({ role: 'assistant', kind: 'refusal', text: String(json.reason ?? 'Outside the registry.') });
return;
}
const one = sanitize([json.node].filter(Boolean) as unknown[]);
if ('bad' in one || one.nodes.length === 0) {
pushChat({ role: 'assistant', kind: 'refusal', text: 'That edit left the registry — refused rather than faked.' });
return;
}
setProposal({ kind: 'edit', targetId: targetNode.id, nodes: one.nodes, summary: text });
pushChat({ role: 'assistant', kind: 'proposal', text: `Staged an edit to this ${targetNode.type} — accept to swap it in.` });
return;
}
const raw = await complete({ system: composeSystemPrompt(), prompt: text, model });
const json = parseAiJson(raw);
if (json.action === 'refuse') {
pushChat({ role: 'assistant', kind: 'refusal', text: String(json.reason ?? 'Outside the registry.') });
return;
}
const result = sanitize((json.nodes as unknown[]) ?? []);
if ('bad' in result) {
pushChat({ role: 'assistant', kind: 'refusal', text: `${result.bad}” is not in your registry — refused rather than faked.` });
return;
}
const frameId = activeFrame?.id ?? 'frame';
setProposal({ kind: 'compose', targetId: frameId, nodes: result.nodes, summary: text });
pushChat({
role: 'assistant',
kind: 'proposal',
text: `Composed from your system — ${countNodes(result.nodes)} components staged on ${activeFrame?.name ?? 'the board'}. The ghost is on the canvas.`,
});
} catch {
pushChat({ role: 'assistant', text: 'Model unavailable — wire /api/compose or retry.' });
} finally {
setThinking(false);
}
};
const accept = () => {
if (!proposal) return;
if (proposal.kind === 'edit') {
mutate((draft) => {
const parent = findParent(draft, proposal.targetId);
if (!parent) return;
const i = parent.children.findIndex((c) => c.id === proposal.targetId);
if (i >= 0) parent.children.splice(i, 1, ...proposal.nodes);
}, 'AI edit — accepted');
setProposal(null);
setAiTarget(proposal.nodes[0]?.id ?? null);
pushChat({ role: 'assistant', text: 'Applied — still 0 unregistered elements.' });
return;
}
mutate((draft) => {
const target = findNode(draft, proposal.targetId);
if (target) target.children.push(...proposal.nodes);
}, 'AI compose — accepted');
setProposal(null);
pushChat({ role: 'assistant', text: `Accepted — ${countNodes(proposal.nodes)} components, 0 unregistered elements.` });
};
const discard = () => {
setProposal(null);
pushChat({ role: 'assistant', text: 'Discarded — nothing was applied.' });
};
return { submit, accept, discard, thinking, proposal, targetNode, clearTarget: () => setAiTarget(null) };
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from 'react';
import { clamp } from '@/lib/utils';
/**
* Drag-to-resize for docked panels (chat, rails, code) — the same
* affordance shadcn's Resizable provides, kept dependency-free.
*/
export function usePanelResize(initial: number, min: number, max: number, invert = false) {
const [width, setWidth] = useState(initial);
const onHandleDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const start = width;
const onMove = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) * (invert ? -1 : 1);
setWidth(clamp(start + dx, min, max));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[invert, max, min, width],
);
return { width, onHandleDown };
}

View File

@@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Veect chrome tokens — ultra-minimal, flat, typography-first.
* Light (paper/ink) is the default; dark is true carbon. The accent IS
* the ink: color belongs to the customer's canvas, not the instrument.
*/
:root,
[data-theme='light'] {
--bg: #fcfcfb;
--panel: #fcfcfb;
--raised: #ffffff;
--line: #e7e6e2;
--line-2: #cfcec8;
--chip: #f4f3f0;
--t1: #161513;
--t2: #63615b;
--t3: #9a978f;
--accent: #161513;
--accent-text: #161513;
--accent-dim: rgba(22, 21, 19, 0.06);
--btn-ink: #ffffff;
--live: #5b7f5e;
--warn: #9a7b3f;
--danger: #b05e5e;
}
[data-theme='dark'] {
--bg: #0e0e0d;
--panel: #0e0e0d;
--raised: #161615;
--line: #262624;
--line-2: #3a3a37;
--chip: #161615;
--t1: #f4f4f2;
--t2: #a6a6a1;
--t3: #73736e;
--accent: #f4f4f2;
--accent-text: #f4f4f2;
--accent-dim: rgba(244, 244, 242, 0.09);
--btn-ink: #0e0e0d;
--live: #8ba88e;
--warn: #c2a36b;
--danger: #c97070;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg font-sans text-[13px] leading-normal text-ink antialiased;
}
::selection {
background: rgba(127, 127, 120, 0.3);
}
*:focus-visible {
outline: 1.5px solid var(--t1);
outline-offset: 1px;
border-radius: 0;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,18 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** shadcn-style class combiner: clsx + tailwind-merge. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/** Clamp a number into [min, max]. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/** Truncate a string on a word boundary with an ellipsis. */
export function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max - 1).replace(/\s+\S*$/, '')}`;
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '@/App';
import '@/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,18 @@
import { useState } from 'react';
import { EditorLayout } from '@/components/templates/EditorLayout';
import { ExportDialog } from '@/components/organisms/ExportDialog';
import { TopBar } from '@/components/organisms/TopBar';
/** Page — the editor: top bar over the activity-bar-driven column layout. */
export function EditorPage() {
const [exportOpen, setExportOpen] = useState(false);
// Panel toggling and the Polish pass live in ActivityBar / PolishTab;
// this page only frames the layout and owns the export dialog.
return (
<div className="flex h-full flex-col">
<TopBar onExport={() => setExportOpen(true)} />
<EditorLayout />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { PageShell } from '@/components/templates/PageShell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { frames } from '@/engine/tree';
import { PROJECT_NAME } from '@/config/demo';
import { useVeect } from '@/store/veect';
const PLACEHOLDERS = [
{ name: 'Solstice — App shell', meta: '6 frames · edited 3d ago' },
{ name: 'Fieldwork — Docs', meta: '4 frames · edited 2w ago' },
];
/** Page — Home: projects grid + design systems. */
export function HomePage() {
const go = useVeect((s) => s.go);
const board = useVeect((s) => s.board);
const system = useVeect((s) => s.system);
return (
<PageShell
width={1060}
actions={
<Button variant="primary" size="sm" onClick={() => go('onboarding')}>
New project
</Button>
}
>
<div className="flex items-baseline gap-3">
<h1 className="text-[22px] font-semibold tracking-tight">Projects</h1>
<span className="font-mono text-[10.5px] text-ink-3">1 live · 2 placeholders</span>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
<button
onClick={() => go('editor')}
className="overflow-hidden rounded-[14px] border border-line bg-panel text-left transition-colors hover:border-accent"
>
<div className="relative h-[140px] border-b border-line" style={{ background: system.neutrals[50] }}>
<div className="absolute left-[8%] top-[14%] h-2.5 w-1/3 rounded-full" style={{ background: system.ramp[500] }} />
<div className="absolute left-[8%] top-[34%] h-2 w-3/5 rounded" style={{ background: system.neutrals[200] }} />
<div className="absolute left-[8%] top-[52%] h-11 w-[84%] rounded-lg" style={{ background: system.ramp[100] }} />
</div>
<div className="flex flex-col gap-1 p-3.5">
<span className="text-[13.5px] font-semibold">{PROJECT_NAME}</span>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-ink-3">{frames(board).length} frames · edited 2h ago</span>
<span className="flex-1" />
<Badge tone="accent">Solstice</Badge>
</div>
</div>
</button>
{PLACEHOLDERS.map((p) => (
<div key={p.name} className="overflow-hidden rounded-[14px] border border-line bg-panel opacity-70">
<div className="h-[140px] border-b border-line bg-chip" />
<div className="flex flex-col gap-1 p-3.5">
<span className="text-[13.5px] font-semibold">{p.name}</span>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-ink-3">{p.meta}</span>
<span className="flex-1" />
<Badge>placeholder</Badge>
</div>
</div>
</div>
))}
<button
onClick={() => go('onboarding')}
className="flex min-h-[210px] flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-line-2 text-ink-3 hover:border-accent hover:text-ink"
>
<span className="text-[20px]"></span>
<span className="text-[12.5px] font-medium">New project</span>
<span className="font-mono text-[10px]">start from your system</span>
</button>
</div>
<h2 className="mt-4 text-[15px] font-semibold">Design systems</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-4">
<Panel className="flex flex-col gap-3 p-4">
<div className="flex overflow-hidden rounded-[7px] border border-line">
{([100, 300, 500, 700, 900] as const).map((k) => (
<span key={k} className="h-5 flex-1" style={{ background: system.ramp[k] }} />
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-[120px] flex-1 flex-col">
<span className="text-[13.5px] font-semibold">{system.name}</span>
<span className="font-mono text-[10px] text-ink-3">
10 components · {system.font} · {system.radius}px radius
</span>
</div>
<Button size="sm" onClick={() => go('editor')}>
Open tokens
</Button>
<Button size="sm" onClick={() => go('sysupdate')}>
Re-import
</Button>
</div>
</Panel>
<button
onClick={() => go('onboarding')}
className="flex flex-col items-start justify-center gap-1.5 rounded-[14px] border border-dashed border-line-2 p-4 text-left text-ink-3 hover:border-accent hover:text-ink"
>
<span className="font-mono text-[11px] text-accent-text">{'{ }'}</span>
<span className="text-[12.5px] font-semibold">Bring a design system</span>
<span className="text-[11.5px] leading-relaxed">
Tailwind config, CSS variables or the 5-token quick path.
</span>
</button>
</div>
</PageShell>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { Logo } from '@/components/atoms/Logo';
import { Swatch } from '@/components/atoms/primitives';
import { SegmentedControl } from '@/components/molecules/SegmentedControl';
import { Button } from '@/components/ui/button';
import { buildSystem, SOLSTICE_SEED, type SystemSeed } from '@/engine/system';
import { useVeect } from '@/store/veect';
const BRANDS = ['#E4572E', '#2A6FDB', '#1F8A5B', '#111827'];
const FONTS = ['Bricolage Grotesque', 'Space Grotesk', 'Lora'];
/**
* Page — onboarding, the no-file path: set five tokens over the base
* kit and watch it re-theme live. (The paste-a-config path lands with
* the token parser service.)
*/
export function OnboardingPage() {
const [seed, setSeed] = useState<SystemSeed>(SOLSTICE_SEED);
const setSystem = useVeect((s) => s.setSystem);
const go = useVeect((s) => s.go);
const preview = buildSystem(seed);
return (
<div className="flex h-full items-center justify-center overflow-y-auto p-8">
<div className="flex w-[920px] max-w-full flex-col">
<div className="flex items-center gap-2">
<Logo size={15} />
<span className="ml-1 font-mono text-[10.5px] text-ink-3">design-system-native canvas</span>
</div>
<h1 className="mt-7 text-[clamp(30px,6vw,42px)] font-semibold leading-[1.1] tracking-tight">
Bring your design system to life.
</h1>
<div className="mt-8 flex flex-wrap gap-6">
<div className="flex min-w-[280px] flex-1 flex-col gap-4">
<label className="flex flex-col gap-2">
<span className="font-mono text-[10px] tracking-[0.08em] text-ink-3">BRAND COLOR</span>
<div className="flex gap-3">
{BRANDS.map((hex) => (
<button key={hex} onClick={() => setSeed({ ...seed, brand: hex })} className="rounded-[2px]" style={{ boxShadow: seed.brand === hex ? '0 0 0 2px var(--bg), 0 0 0 4px var(--accent)' : undefined }}>
<Swatch hex={hex} size={30} />
</button>
))}
</div>
</label>
<label className="flex flex-col gap-2">
<span className="font-mono text-[10px] tracking-[0.08em] text-ink-3">CORNER RADIUS {seed.radius}px</span>
<input
type="range"
min={0}
max={20}
step={2}
value={seed.radius}
onChange={(e) => setSeed({ ...seed, radius: Number(e.target.value) })}
className="accent-[var(--accent)]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="font-mono text-[10px] tracking-[0.08em] text-ink-3">TYPE</span>
<SegmentedControl
options={FONTS.map((f) => ({ value: f, label: f.split(' ')[0] }))}
value={seed.font}
onChange={(font) => setSeed({ ...seed, font })}
/>
</label>
<Button
variant="primary"
size="lg"
className="mt-1 self-start"
onClick={() => {
setSystem(buildSystem(seed));
go('editor');
}}
>
Bring it to life
</Button>
</div>
{/* live mini-kit preview — the customer's tokens over the base kit */}
<div
className="flex w-[320px] max-w-full flex-col gap-3.5 self-start rounded-2xl border p-5"
style={{ background: '#fff', borderColor: '#ECE8E2', fontFamily: `'${preview.font}', sans-serif` }}
>
<span className="self-start rounded-[2px] px-2.5 py-0.5 text-[11px] font-semibold" style={{ background: preview.ramp[100], color: preview.ramp[600] }}>
NEW
</span>
<div className="flex flex-col gap-1.5">
<span className="text-[12px] font-semibold" style={{ color: preview.neutrals[900] }}>Email</span>
<div className="px-3 py-2 text-[13px]" style={{ border: `1px solid ${preview.neutrals[200]}`, borderRadius: Math.max(4, preview.radius * 0.66), color: preview.neutrals[600] }}>
you@company.com
</div>
</div>
<div className="flex gap-2">
<span className="px-4 py-2 text-[13.5px] font-semibold text-white" style={{ background: preview.ramp[500], borderRadius: Math.max(4, preview.radius * 0.66) }}>
Get started
</span>
<span className="px-4 py-2 text-[13.5px] font-semibold" style={{ border: `1px solid ${preview.neutrals[200]}`, color: preview.neutrals[900], borderRadius: Math.max(4, preview.radius * 0.66) }}>
Docs
</span>
</div>
<span className="font-mono text-[10px]" style={{ color: '#8A8279' }}>
live preview your tokens over the base kit
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { PageShell, Section } from '@/components/templates/PageShell';
import { SegmentedControl } from '@/components/molecules/SegmentedControl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { TEAM } from '@/config/demo';
import { useVeect } from '@/store/veect';
/** Page — Profile: identity, preferences, workspace, shortcuts. */
export function ProfilePage() {
const theme = useVeect((s) => s.theme);
const toggleTheme = useVeect((s) => s.toggleTheme);
const go = useVeect((s) => s.go);
return (
<PageShell title="Profile" back width={640}>
<Panel className="flex flex-wrap items-center gap-3.5 p-4">
<span className="flex h-[52px] w-[52px] items-center justify-center rounded-[2px] border border-line-2 bg-accent-dim text-[18px] font-semibold text-accent-text">
M
</span>
<div className="flex min-w-[180px] flex-1 flex-col gap-0.5">
<span className="text-[16px] font-semibold">Maya Kade</span>
<span className="text-[12.5px] text-ink-2">maya@solstice.team · Product designer</span>
</div>
<Badge tone="accent">DESIGN PARTNER</Badge>
</Panel>
<Section label="PREFERENCES">
<Panel className="flex items-center gap-3 p-3.5">
<span className="flex-1 text-[12.5px]">App theme</span>
<SegmentedControl
className="w-[190px]"
options={[
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]}
value={theme}
onChange={(v) => v !== theme && toggleTheme()}
/>
</Panel>
</Section>
<Section label="WORKSPACE — SOLSTICE">
<Panel className="flex flex-col p-1.5">
{TEAM.map((m) => (
<div key={m.initials} className="flex items-center gap-2.5 px-2.5 py-2">
<span className="flex h-[30px] w-[30px] items-center justify-center rounded-[2px] border border-line bg-chip font-mono text-[10px] text-ink-2">
{m.initials}
</span>
<div className="flex flex-1 flex-col">
<span className="text-[12.5px] font-medium">{m.name}</span>
<span className="font-mono text-[10px] text-ink-3">{m.role}</span>
</div>
<Badge tone={m.chip === 'owner' ? 'accent' : 'neutral'}>{m.chip}</Badge>
</div>
))}
</Panel>
</Section>
<Section label="KEYBOARD">
<Panel className="flex flex-col gap-2 p-3.5 font-mono text-[11px] leading-relaxed text-ink-2">
<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>
</Panel>
</Section>
<Button size="sm" className="self-start" onClick={() => go('signin')}>
Sign out
</Button>
</PageShell>
);
}

View File

@@ -0,0 +1,102 @@
import { Swatch } from '@/components/atoms/primitives';
import { LIBRARY } from '@/config/library';
import { PageShell, Section } from '@/components/templates/PageShell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { MAP_NAME } from '@/engine/codegen';
import { BASE_KIT, CUSTOMER_LIB } from '@/config/demo';
import { useVeect } from '@/store/veect';
import type { NodeType } from '@/types';
const CRAFT_STANDARDS = [
{ key: 'veect', name: 'Veect default', desc: 'Spacing, hierarchy and contrast checks tuned to the base kit.' },
{ key: 'impeccable', name: 'Impeccable', desc: 'The anti-slop standard — type contrast, 4px scale, one focal point, every state designed.' },
{ key: 'custom', name: 'Custom', desc: 'Bring your own ruleset — a DESIGN.md your Polish pass enforces.' },
];
/** Page — Settings: system source, mappings (the merge bar), craft standard, export defaults. */
export function SettingsPage() {
const system = useVeect((s) => s.system);
const go = useVeect((s) => s.go);
return (
<PageShell title="Settings" back>
<Section label="DESIGN SYSTEM">
<Panel className="flex flex-wrap items-center gap-3 p-4">
<Swatch hex={system.ramp[500]} size={30} radius={9} />
<div className="flex min-w-[160px] flex-1 flex-col">
<span className="text-[13.5px] font-semibold">{system.name}</span>
<span className="font-mono text-[10px] text-ink-3">tokens.css · pasted July 9 · {system.font}</span>
</div>
<Button size="sm" onClick={() => go('sysupdate')}>
Re-import tokens
</Button>
</Panel>
</Section>
<Section label="COMPONENT MAPPINGS — THE MERGE BAR">
<Panel className="flex flex-col p-1.5">
{LIBRARY.map((item) => {
const primitive = 'primitive' in item && item.primitive;
const unmapped = 'unmapped' in item && item.unmapped;
return (
<div key={item.type} className="flex items-center gap-2.5 rounded-[2px] px-2.5 py-2 hover:bg-white/5">
<span className="w-[88px] shrink-0 text-[12.5px] font-medium">{item.name}</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10.5px] text-ink-2">
{primitive
? 'semantic HTML — no import'
: unmapped
? `${BASE_KIT} (fallback)`
: `${CUSTOMER_LIB}/${MAP_NAME[item.type as NodeType]}${item.type === 'button' ? ' · variant→kind' : ''}`}
</span>
<Badge tone={primitive ? 'neutral' : unmapped ? 'warn' : 'accent'}>
{primitive ? 'built-in' : unmapped ? 'map now' : 'mapped'}
</Badge>
</div>
);
})}
</Panel>
<span className="font-mono text-[10px] text-ink-3">
unmapped components fall back to the Veect base kit at export the warning follows them
</span>
</Section>
<Section label="CRAFT STANDARD — POLISH RULESET">
<div className="flex flex-wrap gap-2.5">
{CRAFT_STANDARDS.map((c) => (
<button
key={c.key}
className={`flex flex-1 basis-[200px] flex-col items-start gap-1.5 rounded-[2px] border p-3.5 text-left ${
c.key === 'impeccable' ? 'border-accent bg-raised' : 'border-line bg-panel'
}`}
>
<span className="text-[13px] font-semibold">{c.name}</span>
<span className="text-[11.5px] leading-relaxed text-ink-2">{c.desc}</span>
</button>
))}
</div>
</Section>
<Section label="EXPORT DEFAULTS">
<Panel className="flex flex-col p-1.5">
{[
['Framework', 'fixed for the MVP', 'React + Tailwind'],
['Tokens', 'CSS variables + Tailwind theme', 'on'],
['Prettier + tsc + eslint', 'runs before every export', 'on'],
].map(([label, note, chip]) => (
<div key={label} className="flex items-center gap-2.5 px-2.5 py-2">
<span className="flex-1 text-[12.5px]">{label}</span>
<span className="font-mono text-[10px] text-ink-3">{note}</span>
<Badge tone="accent">{chip}</Badge>
</div>
))}
</Panel>
</Section>
<Button variant="danger" size="sm" className="self-start">
Delete project
</Button>
</PageShell>
);
}

View File

@@ -0,0 +1,36 @@
import { Logo } from '@/components/atoms/Logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useVeect } from '@/store/veect';
/** Page — sign in. Design-partner MVP: email continues straight through. */
export function SignInPage() {
const go = useVeect((s) => s.go);
return (
<div className="flex h-full items-center justify-center p-8">
<div className="flex w-[360px] max-w-full flex-col gap-3.5">
<div className="flex items-center gap-2">
<Logo size={18} />
</div>
<h1 className="mt-2 text-[26px] font-semibold tracking-tight">Sign in.</h1>
<p className="text-[13px] leading-relaxed text-ink-2">
Your system is waiting everything you compose exports as real React.
</p>
<Input placeholder="you@company.com" onKeyDown={(e) => e.key === 'Enter' && go('home')} />
<Button variant="primary" size="lg" onClick={() => go('home')}>
Continue
</Button>
<div className="my-1 flex items-center gap-2.5">
<span className="h-px flex-1 bg-line" />
<span className="font-mono text-[10px] text-ink-3">or</span>
<span className="h-px flex-1 bg-line" />
</div>
<Button size="lg">Continue with Google</Button>
<Button size="lg">Continue with GitHub</Button>
<span className="mt-2 font-mono text-[10px] leading-relaxed text-ink-3">
design-partner MVP accounts are provisioned manually
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react';
import { Swatch } from '@/components/atoms/primitives';
import { PageShell } from '@/components/templates/PageShell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { Textarea } from '@/components/ui/input';
import { useVeect } from '@/store/veect';
type Stage = 'paste' | 'diff' | 'applied';
const UPDATED_TOKENS = `:root {
--brand-500: #E4572E;
--brand-600: #B23A18; /* was #C7431F */
--radius-md: 10px; /* was 12px */
--space-7: 28px; /* new */
}`;
/**
* Page — System update (US-6): re-import tokens → diff + impact →
* apply with undo. Keeps design and code in lockstep when the
* system changes underneath you.
*/
export function SystemUpdatePage() {
const [stage, setStage] = useState<Stage>('paste');
const [text, setText] = useState(UPDATED_TOKENS);
const system = useVeect((s) => s.system);
const setSystem = useVeect((s) => s.setSystem);
const go = useVeect((s) => s.go);
const rows = [
{ tag: 'CHANGED', tone: 'warn' as const, tok: 'brand-600', from: system.ramp[600], to: '#B23A18', swatch: true },
{ tag: 'CHANGED', tone: 'warn' as const, tok: 'radius-md', from: `${system.radius}px`, to: '10px' },
{ tag: 'ADDED', tone: 'accent' as const, tok: 'space-7', from: '—', to: '28px' },
{ tag: 'REMOVED', tone: 'danger' as const, tok: 'shadow-soft', from: '0 1px 2px …', to: '—' },
];
return (
<PageShell title="System update" back width={820}>
{stage === 'paste' && (
<>
<p className="text-[13px] leading-relaxed text-ink-2">
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={text} onChange={(e) => setText(e.target.value)} rows={9} spellCheck={false} />
<Button variant="primary" className="self-start" onClick={() => setStage('diff')}>
Compare with the live system
</Button>
</>
)}
{stage === 'diff' && (
<>
<Panel className="flex flex-col p-1.5">
{rows.map((r) => (
<div key={r.tok} className="flex items-center gap-2.5 px-2.5 py-2">
<Badge tone={r.tone} className="w-[64px] justify-center">
{r.tag}
</Badge>
<span className="w-[110px] shrink-0 font-mono text-[11.5px]">{r.tok}</span>
{r.swatch && <Swatch hex={r.from} size={13} radius={4} />}
<span className="font-mono text-[10.5px] text-ink-3">{r.from}</span>
<span className="text-[10px] text-ink-3"></span>
{r.swatch && <Swatch hex={r.to} size={13} radius={4} />}
<span className="font-mono text-[10.5px]">{r.to}</span>
</div>
))}
</Panel>
<div className="flex items-center gap-2.5 rounded-[2px] border border-line-2 bg-accent-dim px-3 py-2 text-[12px]">
affects 9 components across 2 frames · bindings preserved · nothing breaks
</div>
<div className="flex gap-2.5">
<Button
variant="primary"
onClick={() => {
setSystem({ ...system, radius: 10 });
setStage('applied');
}}
>
Apply with undo
</Button>
<Button variant="ghost" onClick={() => setStage('paste')}>
Cancel
</Button>
</div>
</>
)}
{stage === 'applied' && (
<div className="flex flex-col items-center gap-2.5 pt-10 text-center">
<span className="text-[22px] text-accent-text"></span>
<span className="text-[15px] font-semibold">System updated.</span>
<p className="max-w-[420px] text-[12.5px] leading-relaxed text-ink-2">
radius-md 12 10 rippled across every bound component on the canvas and in the code.
Bindings preserved nothing broke silently.
</p>
<div className="mt-2 flex items-center gap-3">
<Button variant="primary" onClick={() => go('editor')}>
Back to the editor
</Button>
<Button
variant="ghost"
onClick={() => {
setSystem({ ...system, radius: 12 });
setStage('diff');
}}
>
Undo
</Button>
</div>
</div>
)}
</PageShell>
);
}

View File

@@ -0,0 +1,197 @@
import { create } from 'zustand';
import { buildSystem, SOLSTICE_SEED } from '@/engine/system';
import { starterBoard } from '@/engine/starter';
import { clone, findNode, findParent, frameOf, frames } from '@/engine/tree';
import type {
ChatMessage,
CustomComponent,
DesignSystem,
HistoryEntry,
PolishIssue,
Proposal,
Screen,
VeectNode,
} from '@/types';
interface VeectState {
// navigation
screen: Screen;
go: (to: Screen) => void;
// theme
theme: 'dark' | 'light';
toggleTheme: () => void;
// design system (the customer's, not Veect chrome)
system: DesignSystem;
setSystem: (s: DesignSystem) => void;
// document + labelled, restorable history
board: VeectNode;
history: HistoryEntry[];
future: HistoryEntry[];
/** When set, the user has restored to this step; newer entries render dimmed
* and are truncated by the next meaningful mutation. */
histCursor: number | null;
mutate: (fn: (draft: VeectNode) => void, label?: string) => void;
undo: () => void;
redo: () => void;
restoreTo: (index: number) => void;
// selection
selectedId: string | null;
activeFrameId: string;
select: (id: string | null) => void;
// panels — activity bar toggles; collapsed by default except chat
chatOpen: boolean;
boardRailCollapsed: boolean;
inspectorCollapsed: boolean;
codeOpen: boolean;
setPanel: (key: 'chat' | 'board' | 'inspector' | 'code', open: boolean) => void;
boardTab: 'layers' | 'library' | 'tokens';
setBoardTab: (t: 'layers' | 'library' | 'tokens') => void;
inspTab: 'inspector' | 'polish';
setInspTab: (t: 'inspector' | 'polish') => void;
// AI
chat: ChatMessage[];
pushChat: (m: Omit<ChatMessage, 'id'>) => void;
proposal: Proposal | null;
setProposal: (p: Proposal | null) => void;
aiTarget: string | null;
setAiTarget: (id: string | null) => void;
model: string;
setModel: (m: string) => void;
// library extensions
customComps: CustomComponent[];
addCustomComp: (c: CustomComponent) => void;
/** Library component (or custom component name) shown in isolation mode. */
isolate: string | null;
setIsolate: (type: string | null) => void;
// polish
issues: PolishIssue[];
setIssues: (i: PolishIssue[]) => void;
}
let chatId = 0;
const entry = (board: VeectNode, label: string): HistoryEntry => ({
snap: JSON.stringify(board),
label,
at: Date.now(),
});
export const useVeect = create<VeectState>((set, get) => ({
screen: 'home',
go: (to) => set({ screen: to }),
theme: 'dark',
toggleTheme: () => {
const theme = get().theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = theme;
set({ theme });
},
system: buildSystem(SOLSTICE_SEED),
setSystem: (system) => set({ system }),
board: starterBoard(),
history: [],
future: [],
histCursor: null,
mutate: (fn, label = 'Edit') => {
const { board, history, histCursor } = get();
// a restore cursor means the dimmed (newer) trail is discarded now
const base = histCursor === null ? history : history.slice(0, histCursor);
const draft = clone(board);
fn(draft);
set({
board: draft,
history: [...base, entry(board, label)].slice(-60),
future: [],
histCursor: null,
});
},
undo: () => {
const { history, board, future } = get();
const prev = history.at(-1);
if (!prev) return;
set({
board: JSON.parse(prev.snap) as VeectNode,
history: history.slice(0, -1),
future: [...future, entry(board, 'Now')],
histCursor: null,
});
},
redo: () => {
const { future, board, history } = get();
const next = future.at(-1);
if (!next) return;
set({
board: JSON.parse(next.snap) as VeectNode,
future: future.slice(0, -1),
history: [...history, entry(board, 'Now')],
});
},
restoreTo: (index) => {
const target = get().history[index];
if (!target) return;
set({ board: JSON.parse(target.snap) as VeectNode, histCursor: index, selectedId: null });
},
selectedId: null,
activeFrameId: 'frame',
select: (id) => {
if (!id) return set({ selectedId: null });
const frame = frameOf(get().board, id);
set({ selectedId: id, activeFrameId: frame?.id ?? get().activeFrameId });
},
chatOpen: true,
boardRailCollapsed: true,
inspectorCollapsed: true,
codeOpen: false,
boardTab: 'library',
setBoardTab: (boardTab) => set({ boardTab }),
inspTab: 'inspector',
setInspTab: (inspTab) => set({ inspTab }),
setPanel: (key, open) =>
set(
key === 'chat'
? { chatOpen: open }
: key === 'board'
? { boardRailCollapsed: !open }
: key === 'inspector'
? { inspectorCollapsed: !open }
: { codeOpen: open },
),
chat: [],
pushChat: (m) => set({ chat: [...get().chat, { ...m, id: ++chatId }].slice(-60) }),
proposal: null,
setProposal: (proposal) => set({ proposal }),
aiTarget: null,
setAiTarget: (aiTarget) => set({ aiTarget }),
model: 'claude-haiku-4-5',
setModel: (model) => set({ model }),
customComps: [],
addCustomComp: (c) => set({ customComps: [...get().customComps, c] }),
isolate: null,
setIsolate: (isolate) => set({ isolate }),
issues: [],
setIssues: (issues) => set({ issues }),
}));
/** Convenience selectors. */
export const useActiveFrame = (): VeectNode | null => {
const board = useVeect((s) => s.board);
const selectedId = useVeect((s) => s.selectedId);
const activeFrameId = useVeect((s) => s.activeFrameId);
const bySelection = selectedId ? frameOf(board, selectedId) : null;
return bySelection ?? frames(board).find((f) => f.id === activeFrameId) ?? frames(board)[0] ?? null;
};
export const useSelectedNode = (): VeectNode | null => {
const board = useVeect((s) => s.board);
const selectedId = useVeect((s) => s.selectedId);
return findNode(board, selectedId);
};
export { findNode, findParent };

View File

@@ -0,0 +1,136 @@
/** Node types the canvas can render. `board` is the infinite surface, `frame` a screen. */
export type NodeType =
| 'board'
| 'frame'
| 'stack'
| 'card'
| 'heading'
| 'text'
| 'button'
| 'badge'
| 'input'
| 'avatar'
| 'image'
| 'divider'
| 'custom';
export type StackDir = 'row' | 'col';
export type Align = 'start' | 'center' | 'end' | 'stretch';
export type Justify = 'start' | 'center' | 'end' | 'between';
export type HeadingSize = 'sm' | 'md' | 'lg' | 'xl';
export type TextTone = 'default' | 'muted' | 'faint';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
export type Weight = 'regular' | 'semibold' | 'bold';
export interface NodeProps {
// layout (stack / card / frame)
dir?: StackDir;
gap?: number;
pad?: number;
padX?: number;
padY?: number;
padBottom?: number;
align?: Align;
justify?: Justify;
maxW?: number;
bg?: 'white' | 'soft';
// frame placement on the board
x?: number;
y?: number;
w?: number;
// responsive variants — one tree, 1..3 rendered widths (engine/views.ts)
views?: string[];
view?: string;
// content
text?: string;
size?: HeadingSize | 'sm' | 'md' | 'lg';
weight?: Weight;
tone?: TextTone;
variant?: ButtonVariant;
label?: string;
placeholder?: string;
name?: string;
ratio?: '16/9' | '1/1' | '4/3';
/* uploaded custom component reference */
comp?: string;
file?: string;
/* curated Tailwind utilities — see engine/tw.ts */
tw?: { w?: string; h?: string; maxw?: string; shadow?: string; opacity?: string; custom?: string };
}
export interface CustomComponent {
name: string;
file: string;
props: string[];
}
export interface HistoryEntry {
snap: string;
label: string;
at: number;
}
export interface VeectNode {
id: string;
type: NodeType;
name?: string;
props: NodeProps;
children: VeectNode[];
}
export type NeutralRamp = Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900, string>;
export interface DesignSystem {
name: string;
brand: string;
ramp: NeutralRamp;
neutrals: NeutralRamp;
neutralTone: 'warm' | 'cool';
radius: number;
font: string;
space: 4 | 8;
}
export type ChatRole = 'user' | 'assistant';
export type ChatKind = 'proposal' | 'refusal' | 'info';
export interface Attachment {
name: string;
url: string;
}
export interface ChatMessage {
id: number;
role: ChatRole;
text: string;
kind?: ChatKind;
atts?: Attachment[];
}
export type ProposalKind = 'compose' | 'edit';
export interface Proposal {
kind: ProposalKind;
targetId: string;
nodes: VeectNode[];
summary: string;
}
export type Severity = 'HIGH' | 'MED' | 'LOW';
export interface PolishIssue {
id: string;
sev: Severity;
title: string;
why: string;
fixLabel: string;
fixed?: boolean;
apply: { op: 'set'; id: string; key: keyof NodeProps; val: unknown } | { op: 'padAll'; ids: string[]; val: number };
}
export type Screen = 'signin' | 'home' | 'onboarding' | 'editor' | 'settings' | 'profile' | 'sysupdate';
export interface CodeLine {
t: string;
id?: string;
}

View File

@@ -0,0 +1,40 @@
import type { Config } from 'tailwindcss';
/**
* Veect chrome palette — deliberately quiet, warm neutrals so the
* user's brand (rendered on the canvas) is always the hero.
* All colors resolve through CSS variables so dark/light theming
* is a single `data-theme` attribute swap. See src/index.css.
*/
export default {
darkMode: ['selector', '[data-theme="dark"]'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
bg: 'var(--bg)',
panel: 'var(--panel)',
raised: 'var(--raised)',
line: 'var(--line)',
'line-2': 'var(--line-2)',
chip: 'var(--chip)',
ink: { DEFAULT: 'var(--t1)', 2: 'var(--t2)', 3: 'var(--t3)' },
accent: { DEFAULT: 'var(--accent)', text: 'var(--accent-text)', dim: 'var(--accent-dim)' },
'btn-ink': 'var(--btn-ink)',
live: 'var(--live)',
warn: 'var(--warn)',
danger: 'var(--danger)',
},
fontFamily: {
sans: ['"Instrument Sans"', 'system-ui', 'sans-serif'],
mono: ['"Fragment Mono"', 'ui-monospace', 'monospace'],
},
borderRadius: { sm: '2px', md: '2px', lg: '2px' },
boxShadow: {
float: '0 0 0 1px var(--line-2)',
raise: 'none',
},
},
},
plugins: [],
} satisfies Config;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});

View File

@@ -27,6 +27,8 @@ export default [
"apps/**", "apps/**",
"turbo/**", "turbo/**",
"playwright.config.ts", "playwright.config.ts",
// Design reference material (ADR-029) — read-only prototypes, never linted
"docs/product/reference/**",
], ],
}, },
js.configs.recommended, js.configs.recommended,

View File

@@ -52,6 +52,9 @@ const ALLOWED_GLOBS = [
/\.prettierignore$/, /\.prettierignore$/,
/\.npmrc$/, /\.npmrc$/,
/(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc. /(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc.
// Design reference material (ADR-029) — read-only prototypes committed
// under docs/product/reference/; never executed, never tested.
/^docs\/product\/reference\//,
// Shell scripts (not Vitest-covered) // Shell scripts (not Vitest-covered)
/\.sh$/, /\.sh$/,
/\.bash$/, /\.bash$/,

View File

@@ -281,6 +281,26 @@ describe("computeDiffCoverage", () => {
assert.equal(result.summary.filesChanged, 2); assert.equal(result.summary.filesChanged, 2);
}); });
test("skips docs/product/reference/ files (design reference material, ADR-029)", () => {
const lcov = parseLcov(lcovText);
const diff = new Map([
// Read-only design prototypes — never executed, never in any lcov
[
"docs/product/reference/project/veect-codebase/src/main.tsx",
new Set([1, 2, 3]),
],
[
"docs/product/reference/project/veect-codebase/src/store/veect.ts",
new Set([1, 2, 3, 4]),
],
["docs/product/reference/project/support.js", new Set([1, 2])],
]);
const result = computeDiffCoverage(diff, lcov);
assert.equal(result.status, "pass");
assert.equal(result.summary.filesGated, 0);
assert.equal(result.summary.filesChanged, 3);
});
test("end-to-end fixture: mixed pass/fail/skip/no-data", () => { test("end-to-end fixture: mixed pass/fail/skip/no-data", () => {
const lcov = parseLcov(lcovText); const lcov = parseLcov(lcovText);
const diff = parseGitDiff(diffText); const diff = parseGitDiff(diffText);