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

@@ -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') },
},
});