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:
17
docs/product/reference/project/veect-codebase/.eslintrc.cjs
Normal file
17
docs/product/reference/project/veect-codebase/.eslintrc.cjs
Normal 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: '^_' }],
|
||||
},
|
||||
};
|
||||
6
docs/product/reference/project/veect-codebase/.gitignore
vendored
Normal file
6
docs/product/reference/project/veect-codebase/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
87
docs/product/reference/project/veect-codebase/README.md
Normal file
87
docs/product/reference/project/veect-codebase/README.md
Normal 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 1–3 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`).
|
||||
|
||||
251
docs/product/reference/project/veect-codebase/SPEC.md
Normal file
251
docs/product/reference/project/veect-codebase/SPEC.md
Normal 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, 11–12.5px controls.
|
||||
- **Labels**: Fragment Mono, 9–11px, 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
|
||||
120–450ms, `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 (§4–5) | |
|
||||
| **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-line→select-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 | `<h1–h4>/<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`.
|
||||
18
docs/product/reference/project/veect-codebase/index.html
Normal file
18
docs/product/reference/project/veect-codebase/index.html
Normal 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>
|
||||
47
docs/product/reference/project/veect-codebase/package.json
Normal file
47
docs/product/reference/project/veect-codebase/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
48
docs/product/reference/project/veect-codebase/src/App.tsx
Normal file
48
docs/product/reference/project/veect-codebase/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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: '◈',
|
||||
};
|
||||
116
docs/product/reference/project/veect-codebase/src/engine/ai.ts
Normal file
116
docs/product/reference/project/veect-codebase/src/engine/ai.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 50–900 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',
|
||||
};
|
||||
@@ -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' })]),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
74
docs/product/reference/project/veect-codebase/src/index.css
Normal file
74
docs/product/reference/project/veect-codebase/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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*$/, '')}…`;
|
||||
}
|
||||
10
docs/product/reference/project/veect-codebase/src/main.tsx
Normal file
10
docs/product/reference/project/veect-codebase/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
197
docs/product/reference/project/veect-codebase/src/store/veect.ts
Normal file
197
docs/product/reference/project/veect-codebase/src/store/veect.ts
Normal 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 };
|
||||
136
docs/product/reference/project/veect-codebase/src/types/index.ts
Normal file
136
docs/product/reference/project/veect-codebase/src/types/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
21
docs/product/reference/project/veect-codebase/tsconfig.json
Normal file
21
docs/product/reference/project/veect-codebase/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
docs/product/reference/project/veect-codebase/vite.config.ts
Normal file
10
docs/product/reference/project/veect-codebase/vite.config.ts
Normal 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') },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user