made tiny updates

This commit is contained in:
SinachPat
2026-05-03 20:34:18 +01:00
parent 4dca0f1bac
commit 3f029e15c2
30 changed files with 3463 additions and 357 deletions
+57 -14
View File
@@ -367,7 +367,7 @@ Canvas transform (pan/zoom) is **session-only** — stored in Zustand + localSto
| `packages/app/src/components/chrome/AppChrome.tsx` | Add device preset picker to top toolbar for selected artboard |
| `packages/app/src/store/canvas.ts` | Add `canvasTransform`, `artboardFrames`, `createArtboard`, `deleteArtboard`, `updateArtboardPosition`, `setArtboardDevicePreset` |
| `packages/app/src/hooks/useArtboards.ts` | Extend to load new schema fields |
| `packages/cli/src/proxy.ts` | Inject `window.__OM_INDEX_URL__` and `window.__OM_ISO_BASE__` into proxied HTML; intercept `/__om_isolation__` for Vite projects (Next.js isolation pages handled in Phase 3). The boundary: `proxy.ts` intercepts the request and calls `isolationServer.handleRequest(req, res)`. `isolation-server.ts` (Phase 3) contains all the HTML generation and framework detection logic. `proxy.ts` has no HTML generation logic. |
| `packages/cli/src/proxy.ts` | Inject `window.__OM_INDEX_URL__` and `window.__OM_ISO_BASE__` into proxied HTML; intercept `/__om_isolation__` and delegate to `isolationServer.handleRequest(req, res)`. **Phase 0 stub:** `isolation-server.ts` does not exist yet — create a minimal stub that returns `501 Not Implemented` with body `"Isolation artboards require CLI indexer (Phase 3)"`. Replace with the full implementation in Phase 3 (§6.6). |
| Database | Migration: `alter-artboards-v2.sql` with new columns above |
---
@@ -718,7 +718,7 @@ POST /reindex → triggers full rescan
**Why `projectMeta` matters:** The diff generator (`packages/app/src/lib/diff-generator.ts`) runs entirely in the browser and has no direct filesystem access. It must know the project's CSS strategy to choose the right search strategy — especially for Tailwind (where a CSS change means a class swap, not a property edit). The canvas fetches `GET /health` once per CLI session and stores `projectMeta` in Zustand (`canvasStore.projectMeta`). The diff generator reads it from there. The `tailwind` flag triggers the "Tailwind detected — diff is approximate" annotation (§7.3) without the diff generator needing any filesystem access.
**Security on `GET /file`:**
- Validate the `path` parameter is within the project root: resolve to absolute path first using `path.resolve(projectRoot, requestedPath)`, then verify `absolute === projectRoot || absolute.startsWith(projectRoot + path.sep)`. The `+ path.sep` suffix prevents path prefix confusion (e.g., `/home/user/app-secrets` starting with `/home/user/app`).
- Validate the `path` parameter is within the project root: resolve to absolute path first using `path.resolve(projectRoot, requestedPath)`, then verify `absolute.startsWith(projectRoot + path.sep)`. The `+ path.sep` suffix prevents path prefix confusion (e.g., `/home/user/app-secrets` starting with `/home/user/app`). **Do not** include an `absolute === projectRoot` check — the project root itself is a directory, not a file, and serving it would be a bug.
- Path resolution order: (1) URL-decode the `path` parameter first (`decodeURIComponent`), (2) resolve to absolute using `path.resolve(projectRoot, decoded)`, (3) verify the resolved absolute path is within `projectRoot + path.sep`. Do NOT do a raw string `..` check before resolution — URL-encoded traversal (`%2F..%2F`) bypasses raw string checks.
- Only serve `.ts`, `.tsx`, `.js`, `.jsx`, `.css`, `.scss`, `.json` files
- Never serve: `.env`, `.env.*`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`, `*.crt`, `*.cer`, `*.der`, `*.secret`, `*.secrets`, anything in `.git/`, `node_modules/.*/`, or any file whose name matches `/password|secret|credential|token|private/i`.
@@ -750,6 +750,14 @@ originmain dev --target http://localhost:3000 [--port 4170] [--index-port 4171]
`--no-index` disables the AST indexer. The canvas degrades gracefully: Props tab hidden, diff generation uses component name only (no prop schema).
**Environment variables read by the CLI:**
| Variable | Description | Default |
|---|---|---|
| `ORIGINMAIN_BRIDGE_URL` | URL of the Agent Bridge server. Set in `.env.local` or `~/.originmain/config.json` (written by `originmain login`). | `http://localhost:4172` |
If `ORIGINMAIN_BRIDGE_URL` is not set and `~/.originmain/config.json` has no `bridgeUrl`, the CLI falls back to the default and logs: "Using default Agent Bridge URL: http://localhost:4172".
### 6.6 Files to Create / Change
| File | Change |
@@ -1369,7 +1377,7 @@ The `CodeTabFooter` shows:
- `status === 'IMPLEMENTED'`: green "✓ Applied" badge
- `status === 'BLOCKED'`: red "⚠ Agent could not apply" + reason from `payload.blocked_reason` (add `blocked_reason text` column to `intent_diffs` — the agent calls `update_diff_status` with `status: 'BLOCKED', reason: '...'`)
Add `blocked_reason text` to the `intent_diffs` migration. Add `subscribeToIntentStatus` and a `blocked_reason` column to `§8.4a`.
Add `blocked_reason text` to the `intent_diffs` migration (already included in §8.5 table — no additional change needed).
### 7.5 Files to Create / Change
@@ -1498,7 +1506,7 @@ originmain dev starts
Claude Code sessions receive this immediately after `push_intent` is called.
### 8.4a Existing `intent_diffs` Table (Reference)
### 8.5 Existing `intent_diffs` Table (Reference)
`push_intent` writes to this existing table. Schema for reference:
@@ -1521,7 +1529,7 @@ CREATE TABLE intent_diffs (
`update_diff_status` updates the `status` column.
`get_pending_diffs` queries `WHERE status = 'EXPORTED'`.
### 8.5 Files to Create / Change
### 8.6 Files to Create / Change
| File | Change |
|---|---|
@@ -1629,7 +1637,16 @@ interface DesignToken {
- Format A/B: `{ "color": { "primary": ... } }``--color-primary`
- Format C: key is already the CSS custom property name (used as-is)
- Nested paths: `{ "color": { "brand": { "500": ... } } }``--color-brand-500`
- **camelCase segments are converted to kebab-case before joining.** Each path segment is passed through `segment.replace(/([A-Z])/g, '-$1').toLowerCase()` before joining with `-`. Examples: `"borderRadius"``border-radius`, `"fontSize"``font-size`, `"boxShadow"``box-shadow`. This ensures `{ "borderRadius": { "sm": ... } }``--border-radius-sm`, not `--borderRadius-sm`. Add a `toKebabCase(s: string): string` utility to `packages/design-language/src/parser.ts`.
- **camelCase segments are converted to kebab-case before joining.** Each path segment is passed through a `toKebabCase` function before joining with `-`:
```ts
function toKebabCase(s: string): string {
return s
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, ''); // ← strip leading hyphen: "Color" → "-color" → "color" (not "--color-primary" → "---color-primary")
}
```
Examples: `"borderRadius"` → `border-radius`, `"fontSize"` → `font-size`, `"Color"` → `color`, `"BoxShadow"` → `box-shadow`. This ensures `{ "borderRadius": { "sm": ... } }` → `--border-radius-sm` and `{ "Color": { "primary": ... } }` → `--color-primary`. The leading-hyphen guard is mandatory — any group name starting with a capital (common in real token files) would otherwise produce `---token-name` without it. Add `toKebabCase` to `packages/design-language/src/parser.ts`.
**Human label derivation:**
Path segments joined with ` / `: `"color" + "primary"` → `"Color / Primary"`.
@@ -1835,12 +1852,13 @@ CREATE TABLE design_languages (
-- Version history: keep the last 10 versions
CREATE TABLE design_language_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
design_language_id uuid REFERENCES design_languages(id) NOT NULL,
design_language_id uuid REFERENCES design_languages(id) ON DELETE CASCADE NOT NULL,
version integer NOT NULL,
raw_json jsonb NOT NULL,
normalized jsonb NOT NULL,
source_format text NOT NULL,
created_at timestamptz DEFAULT now()
created_at timestamptz DEFAULT now(),
UNIQUE (design_language_id, version) -- prevents duplicate version numbers; required for prune trigger correctness
);
-- Enforce max 10 versions via a trigger that deletes the oldest on insert
@@ -1958,17 +1976,17 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
│ │ │──────────────────►│
│ │ │ │
│ │ │ FIBER_TREE_UPDATE │
│ │───────────────────────────────────
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
│ │ │ user clicks │
│ │ COMPONENT_SELECTED + ELEMENT_STYLES│
│ │───────────────────────────────────
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
│ │ │ panel renders, │
│ │ │ deviation check │
│ │ │ against tokens │
│ │ │ │
│ │ │ user adjusts │
│ │ PATCH_ELEMENT_STYLE (DOM preview)
│ │───────────────────────────────────►│
│ │◄───────────────────────────────────│ PATCH_ELEMENT_STYLE (DOM preview)
│ │ │ │ (postMessage: canvas→iframe)
│ │ │ "Preview Code" │
│ │ │ GET /file?path=… │
│ │ │◄──────────────────│
@@ -2002,7 +2020,7 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
│ hot reload │ │ │
│◄──────────────────│ │ │
│ │ FIBER_TREE_UPDATE (post-edit) │
│ │───────────────────────────────────
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
│ │ │ update_diff_status(IMPLEMENTED)
│ │ │ │◄─────────────────│
│ │ │ canvas shows ✓ │
@@ -2010,6 +2028,31 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
---
## 10.1 Phase 7 — E2E Validation
Phase 7 is not a feature phase — it is a structured verification pass over the entire stack after all prior phases are merged.
**Scope (2 days):**
1. **Happy path end-to-end:** Run `originmain dev --target http://localhost:3000` against a real Next.js + Tailwind project. Open the canvas. Verify: artboard loads → fiber hook fires → routes discovered → artboards created → component selected → design panel shows computed CSS → edit border-radius → Code tab shows diff → Send to Agent → Claude Code edits file → hot reload → IMPLEMENTED status.
2. **Graceful degradation:** Verify each phase degrades cleanly when the phase below it is absent:
- CLI not running: canvas shows "set URL in Props" state; no crash
- Indexer offline (`--no-index`): Props tab hidden, Code tab shows "CLI indexer required", diff still works in approximate mode
- No design language: all deviation indicators hidden; token chips absent; no crash
3. **Protocol conformance:** Send a `FIBER_TREE_UPDATE`, `ELEMENT_STYLES`, `THUMBNAIL_READY`, and `SNAPSHOT_READY` from a mock iframe and verify the canvas handles each without error. Send malformed messages and verify they are silently ignored.
4. **Security smoke test:** Attempt path traversal on `GET /file?path=../../.env` — verify 403. Attempt to POST `register-indexer` with a non-localhost URL — verify rejection.
5. **Performance baseline:** Open 6 artboards simultaneously. Verify viewport culling activates (only ≤4 iframes mounted). Pan rapidly across the canvas — verify no layout jank, no missed culling transitions.
**Files to create:**
- `packages/app/src/__tests__/e2e/canvas-flow.test.ts` — Playwright test covering the happy path
- `packages/cli/src/__tests__/security.test.ts` — path traversal + register-indexer rejection tests
---
## 11. Implementation Phases — Summary
| Phase | Name | Duration | Depends on | Milestone |
@@ -2023,7 +2066,7 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
| 6 | Design Language System | 57 days | 2, 4, 5 | Upload JSON, see token chips in panel, deviation flags, agent writes `var(--token)` (agent context requires Phase 4 IntentMessage + Phase 5 Agent Bridge) |
| 7 | E2E Validation | 2 days | all | Full flow: select → edit → preview diff → send → agent applies → hot reload confirms |
**Total:** ~2533 engineering days across all phases.
**Total:** ~2535 engineering days across all phases.
---
@@ -43,7 +43,7 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
const {
selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
selectedComponentId,
} = useCanvas();
const selected = selectedArtboardId === id;
@@ -81,14 +81,20 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
selectComponent(nodeId, node ?? null);
}, [localFiberRoot, selectComponent, setComponentStyles]);
const handleComponentStylesUpdate = useCallback((nodeId: string, styles: Record<string, string>) => {
const handleComponentStylesUpdate = useCallback((
nodeId: string,
styles: Record<string, string>,
hasDirectText: boolean,
hasParagraphChildren: boolean,
) => {
// Guard against stale responses arriving after the user has already clicked
// a different component — only apply if artboard and node both still match.
const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState();
if (currentArtboard === id && currentId === nodeId) {
setComponentStyles(styles);
setComponentTextFlags(hasDirectText, hasParagraphChildren);
}
}, [id, setComponentStyles]);
}, [id, setComponentStyles, setComponentTextFlags]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
@@ -144,8 +150,14 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
// H-6 fix: `route` was omitted from the PATCH body, so every drag
// silently reset the artboard's SPA route back to undefined (root '/').
body: JSON.stringify({
metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}) },
metadata_jsonb: {
x: newX, y: newY, width, height,
...(renderUrl ? { renderUrl } : {}),
...(route ? { route } : {}),
},
}),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
@@ -27,8 +27,9 @@ export interface LiveArtboardProps {
onReady?: () => void;
onFiberTreeUpdate?: (root: FiberNode) => void;
onComponentSelected?: (nodeId: string) => void;
/** Called when the iframe responds with computed CSS properties for a selected element. */
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
/** Called when the iframe responds with computed CSS properties for a selected element.
* Also carries structural flags from the ELEMENT_STYLES message. */
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>, hasDirectText: boolean, hasParagraphChildren: boolean) => void;
/** Called when the iframe discovers routes in the running app. */
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
/** Called when READY fires but no React commits arrive within 4 s signals a
@@ -140,7 +141,7 @@ export function LiveArtboard({
onComponentSelected?.('');
break;
case 'ELEMENT_STYLES':
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
onComponentStylesUpdate?.(msg.nodeId, msg.styles, msg.hasDirectText, msg.hasParagraphChildren);
break;
case 'ROUTES_DISCOVERED':
onRoutesDiscovered?.(msg.routes);
@@ -164,6 +165,8 @@ export function LiveArtboard({
// simultaneous width + height patches from resize are both delivered.
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
const clearStyleEdits = useCanvas((s) => s.clearStyleEdits);
const childrenStyleEditQueue = useCanvas((s) => s.childrenStyleEditQueue);
const clearChildrenStyleEdits = useCanvas((s) => s.clearChildrenStyleEdits);
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
@@ -176,6 +179,16 @@ export function LiveArtboard({
clearStyleEdits(id);
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
useEffect(() => {
const mine = childrenStyleEditQueue.filter((e) => e.artboardId === id);
if (mine.length === 0 || !isReadyRef.current) return;
for (const e of mine) {
sendMessage('PATCH_CHILDREN_STYLE', { parentNodeId: e.parentNodeId, selector: e.selector, property: e.property, value: e.value });
}
clearChildrenStyleEdits(id);
}, [id, childrenStyleEditQueue, sendMessage, clearChildrenStyleEdits]);
useEffect(() => {
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine';
@@ -37,6 +38,11 @@ export function SelectionOverlay({
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit);
const { dispatchRemoveElement } = useCanvas();
// C-3 fix: domRect values come from getBoundingClientRect() inside the iframe —
// they are in the iframe's own unscaled coordinate space (0..frameWidth/Height).
// Mouse events on the overlay are in screen space, which is scaled by canvas zoom.
// We must divide by zoom to convert screen-space click coords to iframe-space.
const zoom = useViewport(s => s.zoom);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -48,8 +54,9 @@ export function SelectionOverlay({
}
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const clickX = e.clientX - overlayRect.left;
const clickY = e.clientY - overlayRect.top;
// Convert screen-space coords to iframe-space by dividing by zoom.
const clickX = (e.clientX - overlayRect.left) / zoom;
const clickY = (e.clientY - overlayRect.top) / zoom;
const hit = hitTestFiber(fiberRoot, clickX, clickY);
if (hit) {
@@ -61,19 +68,19 @@ export function SelectionOverlay({
onSelectionChange?.(null);
}
},
[fiberRoot, onSelectionChange]
[fiberRoot, onSelectionChange, zoom]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fiberRoot) return;
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - overlayRect.left;
const y = e.clientY - overlayRect.top;
const x = (e.clientX - overlayRect.left) / zoom;
const y = (e.clientY - overlayRect.top) / zoom;
const hit = hitTestFiber(fiberRoot, x, y);
setHoveredId(hit?.id ?? null);
},
[fiberRoot]
[fiberRoot, zoom]
);
const handleKeyDown = useCallback(
@@ -13,6 +13,7 @@ import { useViewport } from '@/store/viewport';
import { useTheme } from '@/store/theme';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useWalkthrough } from '@/store/walkthrough';
import { useIndexer } from '@/hooks/useIndexer';
interface AppChromeProps {
workspaceId?: string;
@@ -29,6 +30,9 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const CT = useCanvasTheme();
const startTour = useWalkthrough((s) => s.start);
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
useIndexer();
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
if (workspaceId && projectId) setContext(workspaceId, projectId);
@@ -0,0 +1,382 @@
'use client';
// ── Shared Design Panel Primitives ────────────────────────────────────────────
// Small, reusable input components for the Design Panel sections.
// All accept an `onPatch(property, value)` callback that flows up to
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook.
import { useState, useRef } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
// ── Number / CSS value helpers ────────────────────────────────────────────────
export function parseCssNum(val: string | undefined): string {
if (!val) return '';
const m = val.match(/^(-?[\d.]+)/);
return m?.[1] ?? '';
}
export function parseCssUnit(val: string | undefined): string {
if (!val) return 'px';
const m = val.match(/^-?[\d.]+(.*)$/);
return m?.[1]?.trim() ?? '';
}
export function rgbToHex(rgb: string): string {
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
return '#' + [m[1], m[2], m[3]]
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
.join('');
}
export function rgbaAlpha(val: string): string {
const m = val.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*([\d.]+))?\)/);
if (!m) return '100';
const a = m[1] !== undefined ? parseFloat(m[1]) : 1;
return String(Math.round(a * 100));
}
export function isTransparent(val: string | undefined): boolean {
if (!val) return true;
// M-1 fix: browsers differ in their getComputedStyle representation of
// transparent. Chrome: "rgba(0, 0, 0, 0)", Firefox: "rgba(0,0,0,0)",
// Safari: "transparent". Match all three with a regex.
return val === 'transparent' || /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(val);
}
// ── Section separator ─────────────────────────────────────────────────────────
export function HSep() {
const T = useCanvasTheme();
return <div style={{ height: 1, background: T.sep, flexShrink: 0 }} />;
}
// ── Section header with collapse toggle ──────────────────────────────────────
export function SectionHeader({
label,
expanded,
onToggle,
action,
}: {
label: string;
expanded: boolean;
onToggle: () => void;
action?: React.ReactNode;
}) {
const T = useCanvasTheme();
return (
<button
onClick={onToggle}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '7px 14px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
userSelect: 'none',
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.label,
flex: 1,
textAlign: 'left',
}}>
{label}
</span>
{action}
<span style={{ color: T.dim, fontSize: '0.55rem', marginLeft: 6 }}>
{expanded ? '▾' : '▸'}
</span>
</button>
);
}
// ── Field label ───────────────────────────────────────────────────────────────
export function FieldLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.04em',
userSelect: 'none',
}}>
{children}
</span>
);
}
// ── Numeric stepper input ─────────────────────────────────────────────────────
export function NumInput({
value,
propKey,
onPatch,
inputWidth = 60,
readOnly,
title,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
inputWidth?: number;
readOnly?: boolean;
title?: string;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
const [draft, setDraft] = useState(parseCssNum(value));
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setDraft(parseCssNum(value));
}
const commit = (v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
};
return (
<input
readOnly={readOnly}
title={title}
value={draft}
onChange={e => !readOnly && setDraft(e.target.value)}
onBlur={() => !readOnly && commit(draft)}
onKeyDown={e => {
if (readOnly) return;
if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
if (e.key === 'Escape') setDraft(parseCssNum(value));
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const n = parseFloat(draft) || 0;
const step = e.shiftKey ? 10 : 1;
const next = e.key === 'ArrowUp' ? n + step : n - step;
setDraft(String(next));
onPatch(propKey, `${next}${unit}`);
}
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: readOnly ? T.bgDeep : T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: readOnly ? T.dim : T.fg,
padding: '3px 6px',
width: inputWidth,
outline: 'none',
textAlign: 'right',
boxSizing: 'border-box',
cursor: readOnly ? 'default' : 'text',
}}
/>
);
}
// ── Plain text input ──────────────────────────────────────────────────────────
export function TextInput({
value,
propKey,
onPatch,
fullWidth,
placeholder,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
fullWidth?: boolean;
placeholder?: string;
}) {
const T = useCanvasTheme();
const [draft, setDraft] = useState(value);
const prevRef = useRef(value);
if (prevRef.current !== value) { prevRef.current = value; setDraft(value); }
return (
<input
value={draft}
placeholder={placeholder}
onChange={e => { setDraft(e.target.value); onPatch(propKey, e.target.value); }}
onBlur={() => onPatch(propKey, draft)}
onKeyDown={e => {
if (e.key === 'Enter') { onPatch(propKey, draft); e.currentTarget.blur(); }
if (e.key === 'Escape') { setDraft(value); onPatch(propKey, value); }
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: fullWidth ? '100%' : undefined,
outline: 'none',
boxSizing: 'border-box',
}}
/>
);
}
// ── Color swatch + hex input ──────────────────────────────────────────────────
export function ColorInput({
value,
propKey,
onPatch,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
}) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
const hex = value.startsWith('rgb') ? rgbToHex(value) : (value.startsWith('#') ? value : '#000000');
const [hexDraft, setHexDraft] = useState(hex.replace('#', ''));
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
}
const commitHex = (v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned);
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
<div
title="Pick color"
style={{
width: 20, height: 20, borderRadius: 3, flexShrink: 0,
background: hex, border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer', position: 'relative', overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef}
type="color"
value={hex}
onChange={e => onPatch(propKey, e.target.value)}
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
/>
</div>
<input
value={hexDraft.toUpperCase()}
maxLength={6}
onChange={e => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
setHexDraft(v);
if (v.length === 3 || v.length === 6) commitHex(v);
}}
onBlur={() => commitHex(hexDraft)}
onKeyDown={e => { if (e.key === 'Enter') commitHex(hexDraft); e.stopPropagation(); }}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: 60,
outline: 'none',
}}
/>
</div>
);
}
// ── Native styled select ──────────────────────────────────────────────────────
export function CssSelect({
value,
propKey,
options,
onPatch,
width,
}: {
value: string;
propKey: string;
options: Array<{ val: string; label: string }>;
onPatch: (prop: string, val: string) => void;
width?: number | string;
}) {
const T = useCanvasTheme();
return (
<select
value={value}
onChange={e => onPatch(propKey, e.target.value)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 5px',
cursor: 'pointer',
flex: width ? undefined : 1,
width: width,
outline: 'none',
}}
>
{options.map(o => <option key={o.val} value={o.val}>{o.label}</option>)}
</select>
);
}
// ── Icon toggle group ─────────────────────────────────────────────────────────
export function IconToggleGroup<T extends string>({
value,
options,
onPatch,
}: {
value: string;
options: Array<{ val: T; icon: string; title?: string }>;
onPatch: (val: T) => void;
}) {
const T = useCanvasTheme();
return (
<div style={{ display: 'flex', gap: 2 }}>
{options.map(o => (
<button
key={o.val}
title={o.title ?? o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{o.icon}
</button>
))}
</div>
);
}
@@ -10,6 +10,14 @@ import { useCanvasTheme } from '@/store/canvasTheme';
import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
import { FrameSection } from './sections/FrameSection';
import { LayoutSection } from './sections/LayoutSection';
import { FillSection } from './sections/FillSection';
import { StrokeSection } from './sections/StrokeSection';
import { EffectsSection } from './sections/EffectsSection';
import { TypographySection } from './sections/TypographySection';
import { BoxModelSection } from './sections/BoxModelSection';
import { ConstraintsSection } from './sections/ConstraintsSection';
const TYPE_COLORS: Record<string, string> = {
s: '#7DD3A8',
@@ -99,6 +107,7 @@ export function Inspector() {
<DesignTab
artboardId={selectedArtboardId}
componentId={selectedComponentId}
componentData={selectedComponentData}
styles={selectedComponentStyles}
/>
) : tab === 'props' ? (
@@ -523,14 +532,16 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
function DesignTab({
artboardId,
componentId,
componentData,
styles,
}: {
artboardId: string | null;
componentId: string | null;
componentData: FiberNode | null;
styles: Record<string, string> | null;
}) {
const T = useCanvasTheme();
const { patchStyleEdit } = useCanvas();
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas();
if (!artboardId) {
return (
@@ -571,239 +582,98 @@ function DesignTab({
patchStyleEdit(artboardId, componentId, prop, val);
};
const s = styles;
const hasFill = !isTransparent(s['background-color']);
const hasTextColor = !!s['color'] && !isTransparent(s['color']);
const hasTypography = !!(s['font-size'] || s['font-family']);
const isFlexLayout = s['display'] === 'flex' || s['display'] === 'inline-flex';
const hasBorder = !!(s['border-width'] && s['border-width'] !== '0px');
const patchChildren = (selector: string, prop: string, val: string) => {
if (!artboardId || !componentId) return;
patchChildrenStyleEdit(artboardId, componentId, selector, prop, val);
};
// ── Derive call-site display ──────────────────────────────────────
const callSite = componentData?.callSite;
const callSiteLabel = callSite
? (() => {
// Show the last two path segments for readability: "app/page.tsx:34"
const parts = callSite.fileName.replace(/\\/g, '/').split('/');
const short = parts.slice(-2).join('/');
return `${short}:${callSite.lineNumber}`;
})()
: null;
// ── Indexer status dot ────────────────────────────────────────────
const indexerDot = {
offline: { color: T.dim, title: 'CLI indexer offline' },
indexing: { color: '#FFBA7B', title: 'Indexing…' },
ready: { color: '#7DD3A8', title: 'Indexer ready' },
}[indexerStatus];
return (
<>
{/* ── Dimensions ────────────────────────────────────── */}
<div style={{ padding: '10px 14px 8px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel>
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel>
<NumInput value={s['height'] ?? '0px'} propKey="height" onPatch={patch} inputWidth={88} />
</div>
</div>
</div>
<HSep />
{/* ── Fill ─────────────────────────────────────────── */}
{hasFill && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Fill</DesignSectionLabel>
{/* ── Component identity header ────────────────────────────── */}
<div style={{
padding: '10px 14px 8px',
borderBottom: `1px solid ${T.sep}`,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>A%</FieldLabel>
<NumInput
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
propKey="_bgAlpha"
onPatch={(_, v) => {
const pct = parseFloat(v.replace('%', ''));
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
{/* Component name */}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.6875rem',
fontWeight: 600,
color: T.fg,
letterSpacing: '-0.01em',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{componentData?.name ?? componentId}
</span>
{/* Indexer dot */}
<div
title={indexerDot.title}
style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: indexerDot.color,
boxShadow: indexerStatus === 'ready' ? `0 0 5px ${indexerDot.color}` : 'none',
transition: 'background 0.3s',
}}
inputWidth={44}
/>
</div>
</div>
</div>
<HSep />
</>
{/* Call-site breadcrumb — "used in app/page.tsx:34" */}
{callSiteLabel && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.02em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={`${callSite?.fileName}:${callSite?.lineNumber}`}
>
{callSiteLabel}
</span>
)}
{/* ── Text colour ───────────────────────────────────── */}
{hasTextColor && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Text Color</DesignSectionLabel>
<ColorInput value={s['color'] ?? ''} propKey="color" onPatch={patch} />
</div>
<HSep />
</>
)}
{/* ── Typography ────────────────────────────────────── */}
{hasTypography && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Typography</DesignSectionLabel>
{s['font-family'] && (
<div style={{ marginBottom: 6 }}>
<TextInput value={s['font-family']} propKey="font-family" onPatch={patch} fullWidth />
</div>
)}
{/* Size / Weight / Line-height */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel>
<NumInput value={s['font-size'] ?? '14px'} propKey="font-size" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel>
<NumInput value={s['font-weight'] ?? '400'} propKey="font-weight" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel>
<NumInput value={s['line-height'] ?? 'normal'} propKey="line-height" onPatch={patch} />
</div>
</div>
{/* Letter-spacing + text-align toggles */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Track</FieldLabel>
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
</div>
<TextAlignToggle
value={s['text-align'] ?? 'left'}
onPatch={(v) => patch('text-align', v)}
/>
</div>
</div>
<HSep />
</>
)}
{/* ── Layout ────────────────────────────────────────── */}
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Layout</DesignSectionLabel>
{/* Display */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Display</FieldLabel>
<CssSelect
value={s['display'] ?? 'block'}
propKey="display"
options={[
{ val: 'block', label: 'block' },
{ val: 'flex', label: 'flex' },
{ val: 'inline-flex', label: 'inline-flex' },
{ val: 'grid', label: 'grid' },
{ val: 'inline-block', label: 'inline-block' },
{ val: 'inline', label: 'inline' },
{ val: 'none', label: 'none' },
]}
{/* ── Section components ───────────────────────────────────── */}
<FrameSection styles={styles} onPatch={patch} />
<ConstraintsSection styles={styles} onPatch={patch} />
<LayoutSection styles={styles} onPatch={patch} />
<FillSection styles={styles} onPatch={patch} />
<StrokeSection styles={styles} onPatch={patch} />
<TypographySection
styles={styles}
hasDirectText={selectedComponentHasDirectText}
hasParagraphChildren={selectedComponentHasParagraphChildren}
onPatch={patch}
onPatchChildren={patchChildren}
/>
</div>
{/* Flex controls */}
{isFlexLayout && (
<>
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'flex-end' }}>
<FlexDirToggle
value={s['flex-direction'] ?? 'row'}
onPatch={(v) => patch('flex-direction', v)}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Gap</FieldLabel>
<NumInput value={s['gap'] ?? '0px'} propKey="gap" onPatch={patch} inputWidth={48} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={s['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
{ val: 'baseline', label: 'baseline' },
]}
onPatch={patch}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Justify</FieldLabel>
<CssSelect
value={s['justify-content'] ?? 'flex-start'}
propKey="justify-content"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'space-between', label: 'between' },
{ val: 'space-around', label: 'around' },
{ val: 'space-evenly', label: 'evenly' },
]}
onPatch={patch}
/>
</div>
</div>
</>
)}
{/* Padding — 4-corner grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{[
{ label: '↑', prop: 'padding-top' },
{ label: '→', prop: 'padding-right' },
{ label: '↓', prop: 'padding-bottom' },
{ label: '←', prop: 'padding-left' },
].map(({ label, prop }) => (
<div key={prop} style={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
<FieldLabel>{label}</FieldLabel>
<NumInput value={s[prop] ?? '0px'} propKey={prop} onPatch={patch} inputWidth={38} />
</div>
))}
</div>
</div>
<HSep />
{/* ── Appearance ────────────────────────────────────── */}
<div style={{ padding: '8px 14px 10px' }}>
<DesignSectionLabel>Appearance</DesignSectionLabel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel>
<NumInput value={s['border-radius'] ?? '0px'} propKey="border-radius" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput value={s['opacity'] ?? '1'} propKey="opacity" onPatch={patch} inputWidth={88} />
</div>
</div>
{/* Border — only show if there's a visible border */}
{hasBorder && (
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<FieldLabel>Border Color</FieldLabel>
<div style={{ marginTop: 2 }}>
<ColorInput value={s['border-color'] ?? '#000000'} propKey="border-color" onPatch={patch} />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Width</FieldLabel>
<NumInput value={s['border-width'] ?? '0px'} propKey="border-width" onPatch={patch} inputWidth={44} />
</div>
</div>
)}
{/* Box shadow — if present */}
{s['box-shadow'] && s['box-shadow'] !== 'none' && (
<div style={{ marginTop: 6 }}>
<FieldLabel>Shadow</FieldLabel>
<div style={{ marginTop: 2 }}>
<TextInput value={s['box-shadow']} propKey="box-shadow" onPatch={patch} fullWidth />
</div>
</div>
)}
</div>
<EffectsSection styles={styles} onPatch={patch} />
<BoxModelSection styles={styles} onPatch={patch} />
</>
);
}
@@ -0,0 +1,237 @@
'use client';
// ── Box Model Section — Margin / Padding visual diagram ───────────────────────
// Renders a concentric-rectangle diagram (like browser DevTools' box model view).
// Outer ring = margin, middle ring = border, inner ring = padding, center = W×H.
// Each editable quad shows the current value; clicking focuses an inline input.
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { SectionHeader, HSep, parseCssNum, parseCssUnit } from '../DesignInputs';
interface BoxModelSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
// Parse a shorthand-or-individual CSS value for one side.
function getSide(styles: Record<string, string>, base: string, side: string): string {
const individual = styles[`${base}-${side}`];
if (individual) return individual;
return styles[base] ?? '0px';
}
// Tiny inline editable cell for a box model value.
function BoxCell({
prop,
value,
onPatch,
mini = false,
style: styleProp,
}: {
prop: string;
value: string;
onPatch: (prop: string, val: string) => void;
mini?: boolean;
style?: React.CSSProperties;
}) {
const T = useCanvasTheme();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const num = parseCssNum(value);
const unit = parseCssUnit(value);
const display = num || '0';
function commit(raw: string) {
const n = parseFloat(raw);
if (!isNaN(n)) onPatch(prop, `${n}${unit || 'px'}`);
setEditing(false);
}
if (editing) {
return (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={e => {
if (e.key === 'Enter') commit(draft);
if (e.key === 'Escape') setEditing(false);
}}
style={{
width: mini ? 28 : 36,
textAlign: 'center',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
background: T.bgDeep,
border: `1px solid ${T.accent}`,
borderRadius: 3,
color: T.fg,
outline: 'none',
padding: '1px 3px',
...styleProp,
}}
/>
);
}
return (
<span
onClick={() => { setEditing(true); setDraft(num || '0'); }}
title={`${prop}: ${value}`}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fg,
cursor: 'text',
minWidth: mini ? 20 : 28,
textAlign: 'center',
display: 'inline-block',
padding: '1px 2px',
borderRadius: 2,
transition: 'background 0.1s',
...styleProp,
}}
onMouseEnter={e => (e.currentTarget.style.background = T.hoverBg ?? 'rgba(255,255,255,0.06)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{display}
</span>
);
}
export function BoxModelSection({ styles, onPatch }: BoxModelSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(false);
// Margin values
const mt = getSide(styles, 'margin', 'top');
const mr = getSide(styles, 'margin', 'right');
const mb = getSide(styles, 'margin', 'bottom');
const ml = getSide(styles, 'margin', 'left');
// Padding values
const pt = getSide(styles, 'padding', 'top');
const pr = getSide(styles, 'padding', 'right');
const pb = getSide(styles, 'padding', 'bottom');
const pl = getSide(styles, 'padding', 'left');
// Border values (display only — editable via StrokeSection)
const bw = styles['border-width'] ?? '0px';
// Dimensions
const w = parseCssNum(styles['width'] ?? '0px') || '—';
const h = parseCssNum(styles['height'] ?? '0px') || '—';
// Shared ring styles
const ringBase: React.CSSProperties = {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const labelStyle: React.CSSProperties = {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.4375rem',
color: T.dim,
letterSpacing: '0.06em',
textTransform: 'uppercase',
position: 'absolute',
top: 4,
left: 6,
userSelect: 'none',
pointerEvents: 'none',
};
return (
<>
<SectionHeader label="Box Model" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '8px 14px 12px' }}>
{/* Outermost = margin */}
<div style={{
...ringBase,
background: 'rgba(255,200,100,0.07)',
border: `1px solid rgba(255,200,100,0.18)`,
borderRadius: 5,
padding: '18px 20px',
minHeight: 130,
}}>
<span style={labelStyle}>margin</span>
{/* Top margin */}
<BoxCell prop="margin-top" value={mt} onPatch={onPatch}
mini style={{ position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
{/* Bottom margin */}
<BoxCell prop="margin-bottom" value={mb} onPatch={onPatch}
mini style={{ position: 'absolute', bottom: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
{/* Left margin */}
<BoxCell prop="margin-left" value={ml} onPatch={onPatch}
mini style={{ position: 'absolute', left: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Right margin */}
<BoxCell prop="margin-right" value={mr} onPatch={onPatch}
mini style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Border ring */}
<div style={{
...ringBase,
background: 'rgba(130,170,255,0.07)',
border: `1px solid rgba(130,170,255,0.22)`,
borderRadius: 4,
width: '100%',
minHeight: 96,
padding: '14px 16px',
}}>
<span style={{ ...labelStyle, color: 'rgba(130,170,255,0.55)' }}>border</span>
{/* Border width — read-only hint */}
<span style={{
position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: 'rgba(130,170,255,0.55)',
}}>
{parseCssNum(bw) || '0'}
</span>
{/* Padding ring */}
<div style={{
...ringBase,
background: 'rgba(100,200,130,0.07)',
border: `1px solid rgba(100,200,130,0.22)`,
borderRadius: 3,
width: '100%',
minHeight: 60,
padding: '10px 12px',
}}>
<span style={{ ...labelStyle, color: 'rgba(100,200,130,0.55)' }}>padding</span>
<BoxCell prop="padding-top" value={pt} onPatch={onPatch}
mini style={{ position: 'absolute', top: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-bottom" value={pb} onPatch={onPatch}
mini style={{ position: 'absolute', bottom: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-left" value={pl} onPatch={onPatch}
mini style={{ position: 'absolute', left: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-right" value={pr} onPatch={onPatch}
mini style={{ position: 'absolute', right: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Content dimensions */}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fg,
opacity: 0.7,
whiteSpace: 'nowrap',
}}>
{w} × {h}
</span>
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,361 @@
'use client';
// ── Constraints Section ───────────────────────────────────────────────────────
// Figma-style constraint anchors: 3×3 grid for horizontal and vertical pinning.
// Only shown when the element's position is 'absolute' or 'fixed'.
//
// Horizontal options: Left | Center | Right | Left+Right | Scale
// Vertical options: Top | Center | Bottom | Top+Bottom | Scale
//
// Selecting a constraint writes the appropriate CSS properties:
// Left → left: Xpx, removes right
// Right → right: Xpx, removes left
// Center → left: 50%, transform: translateX(-50%)
// Left+Right → both left and right set
// Scale → width: X% (relative to parent)
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { SectionHeader, HSep } from '../DesignInputs';
type HConstraint = 'left' | 'center' | 'right' | 'left+right' | 'scale';
type VConstraint = 'top' | 'center' | 'bottom' | 'top+bottom' | 'scale';
interface ConstraintsSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
// ── Transform composition helpers ────────────────────────────────────────────
// These keep centering (translateX/Y) from clobbering other transforms such
// as CSS `rotate` or `scale` that may be set on the same element. (BUG-4)
/**
* Replace or insert a single CSS translate function inside an existing
* transform string without disturbing other functions (rotate, scale, etc.).
*/
function withTranslateFn(
existing: string,
fn: 'translateX' | 'translateY',
value: string,
): string {
const newFn = `${fn}(${value})`;
if (!existing || existing === 'none') return newFn;
const re = new RegExp(`${fn}\\([^)]*\\)`, 'g');
if (re.test(existing)) return existing.replace(re, newFn);
return `${existing} ${newFn}`;
}
/**
* Remove all translateX/Y functions from a transform string.
* Used when switching to a pin or scale constraint that shouldn't centre.
*/
function withoutTranslateFns(existing: string): string {
if (!existing || existing === 'none') return 'none';
const cleaned = existing.replace(/translate[XY]\([^)]*\)\s*/g, '').trim();
return cleaned || 'none';
}
// ── Constraint inference ──────────────────────────────────────────────────────
// NOTE: `styles` comes from getComputedStyle, so percentage values are
// resolved to pixels. We can't detect 'center' from left==='50%' (BUG-5).
// Instead we use the presence of a translateX/Y in the transform string as a
// proxy — applyH/applyV always add them when centering. This is still
// best-effort: after a page reload the inline style is gone and the element
// will appear as 'left'/'top' until the user re-applies a constraint.
/** Infer current H constraint from computed styles */
function inferHConstraint(styles: Record<string, string>): HConstraint {
const left = styles['left'] ?? 'auto';
const right = styles['right'] ?? 'auto';
const transform = styles['transform'] ?? '';
// left+right must be checked before center to handle the edge case where
// both sides are pinned and a translateX somehow also exists.
if (left !== 'auto' && right !== 'auto') return 'left+right';
// Detect center via translateX in the transform (set by applyH).
// The '50%' check is retained as a secondary signal for inline styles.
if (transform.includes('translateX') || left.includes('50%')) return 'center';
if (right !== 'auto' && left === 'auto') return 'right';
return 'left';
}
/** Infer current V constraint from computed styles */
function inferVConstraint(styles: Record<string, string>): VConstraint {
const top = styles['top'] ?? 'auto';
const bottom = styles['bottom'] ?? 'auto';
const transform = styles['transform'] ?? '';
if (top !== 'auto' && bottom !== 'auto') return 'top+bottom';
if (transform.includes('translateY') || top.includes('50%')) return 'center';
if (bottom !== 'auto' && top === 'auto') return 'bottom';
return 'top';
}
// A single dot in the 3×3 constraint grid.
function ConstraintDot({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
const T = useCanvasTheme();
return (
<button
onClick={onClick}
title={label}
aria-label={label}
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: active ? T.accent : 'transparent',
border: `1.5px solid ${active ? T.accent : 'rgba(255,255,255,0.22)'}`,
cursor: 'pointer',
padding: 0,
transition: 'background 0.1s, border-color 0.1s',
flexShrink: 0,
}}
/>
);
}
export function ConstraintsSection({ styles, onPatch }: ConstraintsSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(true);
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
if (!isPositioned) return null;
const hConstraint = inferHConstraint(styles);
const vConstraint = inferVConstraint(styles);
function applyH(c: HConstraint) {
const curLeft = styles['left'] ?? '0px';
const curRight = styles['right'] ?? '0px';
const curTransform = styles['transform'] ?? 'none';
switch (c) {
case 'left':
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
onPatch('right', 'auto');
// BUG-15 fix: clear any translateX that was set by a previous center constraint.
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'right':
onPatch('right', curRight === 'auto' ? '0px' : curRight);
onPatch('left', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'center':
onPatch('left', '50%');
onPatch('right', 'auto');
// BUG-4 fix: compose translateX into the existing transform rather than
// replacing it, so rotate/scale on the element are preserved.
onPatch('transform', withTranslateFn(curTransform, 'translateX', '-50%'));
break;
case 'left+right':
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
onPatch('right', curRight === 'auto' ? '0px' : curRight);
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'scale':
onPatch('width', '100%');
onPatch('left', '0px');
onPatch('right', 'auto');
// BUG-15 fix: clear translateX so a previously-centred element doesn't
// remain shifted after switching to scale mode.
onPatch('transform', withoutTranslateFns(curTransform));
break;
}
}
function applyV(c: VConstraint) {
const curTop = styles['top'] ?? '0px';
const curBottom = styles['bottom'] ?? '0px';
const curTransform = styles['transform'] ?? 'none';
switch (c) {
case 'top':
onPatch('top', curTop === 'auto' ? '0px' : curTop);
onPatch('bottom', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'bottom':
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
onPatch('top', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'center':
onPatch('top', '50%');
onPatch('bottom', 'auto');
// BUG-4 fix: compose translateY, preserving other transform functions.
onPatch('transform', withTranslateFn(curTransform, 'translateY', '-50%'));
break;
case 'top+bottom':
onPatch('top', curTop === 'auto' ? '0px' : curTop);
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'scale':
onPatch('height', '100%');
onPatch('top', '0px');
onPatch('bottom', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
}
}
// The 3×3 grid encodes:
// Row/col 0 = start (left/top)
// Row/col 1 = center
// Row/col 2 = end (right/bottom)
// Both H and V dots share the same grid; the horizontal line represents H constraints
// and the vertical line represents V constraints.
//
// TL T TR ← these dots are the 9 "anchor" positions
// L C R
// BL B BR
type GridPos = [number, number]; // [col, row]
const hPositions: Array<{ pos: GridPos; h: HConstraint; label: string }> = [
{ pos: [0, 1], h: 'left', label: 'Pin left' },
{ pos: [1, 1], h: 'center', label: 'Center horizontally' },
{ pos: [2, 1], h: 'right', label: 'Pin right' },
];
const vPositions: Array<{ pos: GridPos; v: VConstraint; label: string }> = [
{ pos: [1, 0], v: 'top', label: 'Pin top' },
{ pos: [1, 1], v: 'center', label: 'Center vertically' },
{ pos: [1, 2], v: 'bottom', label: 'Pin bottom' },
];
const cornerLabels: Record<string, string> = {
'0,0': 'Pin top-left', '1,0': '', '2,0': 'Pin top-right',
'0,1': '', '1,1': '', '2,1': '',
'0,2': 'Pin bottom-left', '1,2': '', '2,2': 'Pin bottom-right',
};
return (
<>
<SectionHeader label="Constraints" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '8px 14px 12px' }}>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
{/* 3×3 dot grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 10px)',
gridTemplateRows: 'repeat(3, 10px)',
gap: 7,
position: 'relative',
}}
>
{/* Guide lines inside the grid */}
<div style={{
position: 'absolute',
left: '50%', top: 4, bottom: 4,
width: 1, background: 'rgba(255,255,255,0.08)',
transform: 'translateX(-50%)',
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute',
top: '50%', left: 4, right: 4,
height: 1, background: 'rgba(255,255,255,0.08)',
transform: 'translateY(-50%)',
pointerEvents: 'none',
}} />
{/* Render 9 dots */}
{[0, 1, 2].flatMap(row =>
[0, 1, 2].map(col => {
const key = `${col},${row}`;
const hMatch = hPositions.find(p => p.pos[0] === col && p.pos[1] === row);
const vMatch = vPositions.find(p => p.pos[0] === col && p.pos[1] === row);
const isActiveH = hMatch ? hConstraint === hMatch.h : false;
const isActiveV = vMatch ? vConstraint === vMatch.v : false;
const isActive = isActiveH || isActiveV;
const label = hMatch?.label ?? vMatch?.label ?? cornerLabels[key] ?? '';
return (
<ConstraintDot
key={key}
active={isActive}
label={label}
onClick={() => {
if (hMatch) applyH(hMatch.h);
if (vMatch) applyV(vMatch.v);
}}
/>
);
})
)}
</div>
{/* Text labels for current constraints */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
}}>H</span>
<div style={{ display: 'flex', gap: 3 }}>
{(['left', 'center', 'right', 'left+right', 'scale'] as HConstraint[]).map(c => (
<button
key={c}
onClick={() => applyH(c)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '2px 5px',
borderRadius: 3,
border: `1px solid ${hConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
background: hConstraint === c ? `${T.accent}22` : 'transparent',
color: hConstraint === c ? T.accent : T.dim,
cursor: 'pointer',
transition: 'all 0.1s',
whiteSpace: 'nowrap',
}}
>
{c === 'left+right' ? '↔' : c === 'scale' ? '%' : c}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
}}>V</span>
<div style={{ display: 'flex', gap: 3 }}>
{(['top', 'center', 'bottom', 'top+bottom', 'scale'] as VConstraint[]).map(c => (
<button
key={c}
onClick={() => applyV(c)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '2px 5px',
borderRadius: 3,
border: `1px solid ${vConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
background: vConstraint === c ? `${T.accent}22` : 'transparent',
color: vConstraint === c ? T.accent : T.dim,
cursor: 'pointer',
transition: 'all 0.1s',
whiteSpace: 'nowrap',
}}
>
{c === 'top+bottom' ? '↕' : c === 'scale' ? '%' : c}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,132 @@
'use client';
// ── Effects Section — Box Shadow & Filters ────────────────────────────────────
// Surfaces box-shadow (drop shadow / inner shadow), filter: blur, backdrop-filter.
import { useState } from 'react';
import { NumInput, ColorInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
import { useCanvasTheme } from '@/store/canvasTheme';
// ── Filter helpers ────────────────────────────────────────────────────────────
// BUG-9 fix: naive .replace('blur(','') breaks for multi-function filter
// strings like "brightness(1.1) blur(4px)". Use regex-based extraction and
// surgical replacement instead.
/** Extract the numeric argument of blur() from a CSS filter string. */
function extractBlurValue(filter: string): string {
const m = filter.match(/\bblur\(([\d.]+[a-z%]*)\)/);
return m?.[1] ?? '0px';
}
/** Replace (or insert) the blur() function in a filter string, preserving others. */
function patchBlurInFilter(filter: string, newVal: string): string {
const fn = `blur(${newVal})`;
if (!filter || filter === 'none') return fn;
if (/\bblur\(/.test(filter)) return filter.replace(/\bblur\([^)]*\)/, fn);
return `${filter} ${fn}`;
}
interface EffectsSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function EffectsSection({ styles, onPatch }: EffectsSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(false);
const boxShadow = styles['box-shadow'] ?? '';
const filterBlur = styles['filter'] ?? '';
const backdropBlur = styles['backdrop-filter'] ?? '';
const hasEffect = boxShadow && boxShadow !== 'none'
|| filterBlur && filterBlur !== 'none'
|| backdropBlur && backdropBlur !== 'none';
return (
<>
<SectionHeader label="Effects" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Box shadow display */}
{boxShadow && boxShadow !== 'none' ? (
<div style={{ marginBottom: 8 }}>
<FieldLabel>Shadow</FieldLabel>
<div
style={{
marginTop: 4,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.fgMuted,
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
padding: '4px 6px',
wordBreak: 'break-all',
}}
>
{boxShadow}
</div>
<button
onClick={() => onPatch('box-shadow', 'none')}
style={{
marginTop: 4,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
background: 'transparent',
border: 'none',
color: 'rgba(255,80,80,0.7)',
cursor: 'pointer',
padding: 0,
}}
>
Remove shadow
</button>
</div>
) : (
<button
onClick={() => onPatch('box-shadow', '0 2px 8px rgba(0,0,0,0.3)')}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
marginBottom: 8,
}}
>
+ Add shadow
</button>
)}
{/* Layer blur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<FieldLabel>Layer blur</FieldLabel>
<NumInput
value={extractBlurValue(filterBlur)}
propKey="filter"
onPatch={(_, v) => onPatch('filter', patchBlurInFilter(filterBlur, v))}
inputWidth={52}
/>
</div>
{/* Background blur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FieldLabel>BG blur</FieldLabel>
<NumInput
value={extractBlurValue(backdropBlur)}
propKey="backdrop-filter"
onPatch={(_, v) => onPatch('backdrop-filter', patchBlurInFilter(backdropBlur, v))}
inputWidth={52}
/>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,64 @@
'use client';
// ── Fill Section — Background Color & Opacity ─────────────────────────────────
// Shows fill color, opacity, and a "no fill" empty state.
import { useState } from 'react';
import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
interface FillSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function FillSection({ styles, onPatch }: FillSectionProps) {
const [open, setOpen] = useState(true);
const bg = styles['background-color'] ?? '';
return (
<>
<SectionHeader label="Fill" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{isTransparent(bg) ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => onPatch('background-color', '#ffffff')}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
}}
>
+ Add fill
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ColorInput value={bg} propKey="background-color" onPatch={onPatch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput
value={styles['opacity'] !== undefined ? `${Math.round(parseFloat(styles['opacity'] ?? '1') * 100)}%` : '100%'}
propKey="_opacity"
onPatch={(_, v) => {
const pct = parseFloat(v.replace('%', ''));
if (!isNaN(pct)) onPatch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
}}
inputWidth={44}
/>
</div>
</div>
)}
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,141 @@
'use client';
// ── Frame Section — Position & Size ──────────────────────────────────────────
// Shows X, Y (read-only for static; editable for absolute/fixed), W, H,
// rotation, and corner radius.
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
// ── Rotation helpers ──────────────────────────────────────────────────────────
// We use the CSS Transforms Level 2 `rotate` property rather than the
// `transform` shorthand. This keeps rotation orthogonal to any
// translateX(-50%) centering applied by the Constraints section.
//
// For display purposes we also try to parse an existing `transform: matrix(…)`
// so pre-existing rotated elements show their angle on first selection.
/** Extract rotation degrees from the CSS `rotate` property or a `matrix()` transform. */
function readRotationDeg(styles: Record<string, string>): string {
// CSS Transforms Level 2: `rotate: 45deg` — use this if present.
const rotateProp = styles['rotate'];
if (rotateProp && rotateProp !== 'none') {
const m = rotateProp.match(/^(-?[\d.]+)deg$/);
if (m) return m[1] ?? '0';
}
// Fallback: extract angle from a computed matrix(a,b,c,d,tx,ty).
const transform = styles['transform'];
if (transform && transform.startsWith('matrix(')) {
const parts = transform.slice(7, -1).split(',');
const a = parseFloat(parts[0] ?? '1');
const b = parseFloat(parts[1] ?? '0');
const deg = Math.round(Math.atan2(b, a) * (180 / Math.PI));
return String(deg);
}
return '0';
}
interface FrameSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function FrameSection({ styles, onPatch }: FrameSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(true);
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
// Corner radius: parse uniform value
const borderRadius = styles['border-radius'] ?? '0px';
return (
<>
<SectionHeader label="Frame" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* X / Y */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>X</FieldLabel>
<NumInput
value={styles['left'] ?? '0px'}
propKey="left"
onPatch={onPatch}
inputWidth={80}
readOnly={!isPositioned}
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Y</FieldLabel>
<NumInput
value={styles['top'] ?? '0px'}
propKey="top"
onPatch={onPatch}
inputWidth={80}
readOnly={!isPositioned}
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
/>
</div>
</div>
{/* W / H */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel>
<NumInput value={styles['width'] ?? '0px'} propKey="width" onPatch={onPatch} inputWidth={80} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel>
<NumInput value={styles['height'] ?? '0px'} propKey="height" onPatch={onPatch} inputWidth={80} />
</div>
</div>
{/* Rotation writes to the `rotate` CSS property (Transforms Level 2)
so it doesn't clobber translateX(-50%) from Constraints centering. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Rotation °</FieldLabel>
<NumInput
value={readRotationDeg(styles) + 'deg'}
propKey="rotate"
onPatch={onPatch}
inputWidth={80}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel>
<NumInput value={borderRadius} propKey="border-radius" onPatch={onPatch} inputWidth={80} />
</div>
</div>
{/* Overflow / clip */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FieldLabel>Overflow</FieldLabel>
<select
value={styles['overflow'] ?? 'visible'}
onChange={e => onPatch('overflow', e.target.value)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 5px',
outline: 'none',
}}
>
<option value="visible">visible</option>
<option value="hidden">hidden</option>
<option value="auto">auto</option>
<option value="scroll">scroll</option>
</select>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,192 @@
'use client';
// ── Layout Section — Display / Flex / Grid / Padding ─────────────────────────
// Adapts to the element's display mode: block (collapsed), flex, grid.
import { useState, useEffect, useRef } from 'react';
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, IconToggleGroup, HSep } from '../DesignInputs';
interface LayoutSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
const display = styles['display'] ?? 'block';
// BUG-20 fix: useState only runs its initialiser once. When the user selects
// a different component the `display` prop changes but `open` would stay
// stale. We sync it on display changes while preserving explicit user
// toggles: if display changes (new component selected), auto-open for
// flex/grid, auto-close for block.
const [open, setOpen] = useState(display === 'flex' || display === 'grid' || display === 'inline-flex');
const prevDisplayRef = useRef(display);
useEffect(() => {
if (prevDisplayRef.current !== display) {
prevDisplayRef.current = display;
setOpen(display === 'flex' || display === 'grid' || display === 'inline-flex' || display === 'inline-grid');
}
}, [display]);
const isFlex = display === 'flex' || display === 'inline-flex';
const isGrid = display === 'grid' || display === 'inline-grid';
return (
<>
<SectionHeader label="Layout" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Display mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<FieldLabel>Display</FieldLabel>
<CssSelect
value={display}
propKey="display"
options={[
{ val: 'block', label: 'block' },
{ val: 'flex', label: 'flex' },
{ val: 'inline-flex', label: 'inline-flex' },
{ val: 'grid', label: 'grid' },
{ val: 'inline-grid', label: 'inline-grid' },
{ val: 'inline-block', label: 'inline-block' },
{ val: 'inline', label: 'inline' },
{ val: 'none', label: 'none' },
]}
onPatch={onPatch}
/>
</div>
{/* Flex-specific controls */}
{isFlex && (
<>
{/* Direction + Wrap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Direction</FieldLabel>
<IconToggleGroup
value={styles['flex-direction'] ?? 'row'}
options={[
{ val: 'row', icon: '→', title: 'Row' },
{ val: 'column', icon: '↓', title: 'Column' },
{ val: 'row-reverse', icon: '←', title: 'Row reverse' },
{ val: 'column-reverse', icon: '↑', title: 'Column reverse' },
]}
onPatch={v => onPatch('flex-direction', v)}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Wrap</FieldLabel>
<IconToggleGroup
value={styles['flex-wrap'] ?? 'nowrap'}
options={[
{ val: 'nowrap', icon: '⟷', title: 'No wrap' },
{ val: 'wrap', icon: '↩', title: 'Wrap' },
]}
onPatch={v => onPatch('flex-wrap', v)}
/>
</div>
{/* Align items */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={styles['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
{ val: 'baseline', label: 'baseline' },
]}
onPatch={onPatch}
/>
</div>
{/* Justify content */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Justify</FieldLabel>
<CssSelect
value={styles['justify-content'] ?? 'flex-start'}
propKey="justify-content"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'space-between', label: 'between' },
{ val: 'space-around', label: 'around' },
{ val: 'space-evenly', label: 'evenly' },
]}
onPatch={onPatch}
/>
</div>
{/* Gap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Gap</FieldLabel>
<NumInput value={styles['gap'] ?? '0px'} propKey="gap" onPatch={onPatch} inputWidth={60} />
</div>
</>
)}
{/* Grid-specific controls */}
{isGrid && (
<>
<div style={{ marginBottom: 6 }}>
<FieldLabel>Columns</FieldLabel>
<TextInput value={styles['grid-template-columns'] ?? ''} propKey="grid-template-columns" onPatch={onPatch} fullWidth placeholder="1fr 1fr" />
</div>
<div style={{ marginBottom: 6 }}>
<FieldLabel>Rows</FieldLabel>
<TextInput value={styles['grid-template-rows'] ?? ''} propKey="grid-template-rows" onPatch={onPatch} fullWidth placeholder="auto" />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Col gap</FieldLabel>
<NumInput value={styles['column-gap'] ?? '0px'} propKey="column-gap" onPatch={onPatch} inputWidth={60} />
<FieldLabel>Row gap</FieldLabel>
<NumInput value={styles['row-gap'] ?? '0px'} propKey="row-gap" onPatch={onPatch} inputWidth={60} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={styles['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
]}
onPatch={onPatch}
/>
</div>
</>
)}
{/* Padding (always shown) */}
<div style={{ marginTop: 8 }}>
<FieldLabel>Padding</FieldLabel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 6px', marginTop: 4 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Top</FieldLabel>
<NumInput value={styles['padding-top'] ?? '0px'} propKey="padding-top" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Right</FieldLabel>
<NumInput value={styles['padding-right'] ?? '0px'} propKey="padding-right" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Bottom</FieldLabel>
<NumInput value={styles['padding-bottom'] ?? '0px'} propKey="padding-bottom" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Left</FieldLabel>
<NumInput value={styles['padding-left'] ?? '0px'} propKey="padding-left" onPatch={onPatch} inputWidth={72} />
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,77 @@
'use client';
// ── Stroke Section — Border ────────────────────────────────────────────────────
import { useState } from 'react';
import { ColorInput, NumInput, FieldLabel, SectionHeader, CssSelect, HSep } from '../DesignInputs';
interface StrokeSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function StrokeSection({ styles, onPatch }: StrokeSectionProps) {
const [open, setOpen] = useState(false);
// M-3 fix: getComputedStyle returns `border-width` as a 4-value shorthand
// (e.g., "0px 0px 0px 0px" or "1px 1px 1px 1px"), not a single value.
// Read the more reliable `border-top-width` longhand for the presence check,
// and display the top width (uniform borders are by far the common case).
const bw = styles['border-top-width'] ?? styles['border-width'] ?? '0px';
// A border exists when any individual side width is non-zero.
const hasBorder = bw !== '' && bw !== '0px' && !bw.split(' ').every(v => v === '0px' || v === '0');
return (
<>
<SectionHeader label="Stroke" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{!hasBorder ? (
<button
onClick={() => { onPatch('border-width', '1px'); onPatch('border-style', 'solid'); onPatch('border-color', 'rgba(0,0,0,1)'); }}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
}}
>
+ Add stroke
</button>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<ColorInput value={styles['border-color'] ?? ''} propKey="border-color" onPatch={onPatch} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Width</FieldLabel>
<NumInput value={bw} propKey="border-width" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Style</FieldLabel>
<CssSelect
value={styles['border-style'] ?? 'solid'}
propKey="border-style"
options={[
{ val: 'solid', label: 'Solid' },
{ val: 'dashed', label: 'Dashed' },
{ val: 'dotted', label: 'Dotted' },
{ val: 'none', label: 'None' },
]}
onPatch={onPatch}
/>
</div>
</div>
</>
)}
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,138 @@
'use client';
// ── Typography Section ────────────────────────────────────────────────────────
// Shown only when the element has direct text content (hasDirectText === true).
// Includes font family, size, weight, line height, letter spacing, paragraph
// spacing (when hasParagraphChildren), text-align, decoration, transform, color.
import { useState } from 'react';
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, ColorInput, IconToggleGroup, HSep } from '../DesignInputs';
interface TypographySectionProps {
styles: Record<string, string>;
hasDirectText: boolean;
hasParagraphChildren: boolean;
/** Fires PATCH_ELEMENT_STYLE for normal properties */
onPatch: (prop: string, val: string) => void;
/** Fires PATCH_CHILDREN_STYLE for paragraph-spacing */
onPatchChildren: (selector: string, prop: string, val: string) => void;
}
export function TypographySection({
styles,
hasDirectText,
hasParagraphChildren,
onPatch,
onPatchChildren,
}: TypographySectionProps) {
const [open, setOpen] = useState(true);
// Only show this section when the element has direct text content
if (!hasDirectText) return null;
return (
<>
<SectionHeader label="Typography" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Font family */}
<div style={{ marginBottom: 6 }}>
<FieldLabel>Family</FieldLabel>
<TextInput
value={styles['font-family'] ?? ''}
propKey="font-family"
onPatch={onPatch}
fullWidth
/>
</div>
{/* Size / Weight / Line height */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px 6px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel>
<NumInput value={styles['font-size'] ?? '14px'} propKey="font-size" onPatch={onPatch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel>
<NumInput value={styles['font-weight'] ?? '400'} propKey="font-weight" onPatch={onPatch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel>
<NumInput value={styles['line-height'] ?? 'normal'} propKey="line-height" onPatch={onPatch} />
</div>
</div>
{/* Letter spacing + text align */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Tracking</FieldLabel>
<NumInput value={styles['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={onPatch} inputWidth={52} />
</div>
<IconToggleGroup
value={styles['text-align'] ?? 'left'}
options={[
{ val: 'left', icon: 'L', title: 'Left' },
{ val: 'center', icon: 'C', title: 'Center' },
{ val: 'right', icon: 'R', title: 'Right' },
{ val: 'justify', icon: 'J', title: 'Justify' },
]}
onPatch={v => onPatch('text-align', v)}
/>
</div>
{/* Color */}
<div style={{ marginBottom: 6 }}>
<FieldLabel>Color</FieldLabel>
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} />
</div>
{/* Decoration + Transform */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<div style={{ flex: 1 }}>
<FieldLabel>Decoration</FieldLabel>
<CssSelect
value={styles['text-decoration'] ?? 'none'}
propKey="text-decoration"
options={[
{ val: 'none', label: 'None' },
{ val: 'underline', label: 'Underline' },
{ val: 'line-through', label: 'Strikethrough' },
{ val: 'overline', label: 'Overline' },
]}
onPatch={onPatch}
/>
</div>
<div style={{ flex: 1 }}>
<FieldLabel>Transform</FieldLabel>
<CssSelect
value={styles['text-transform'] ?? 'none'}
propKey="text-transform"
options={[
{ val: 'none', label: 'None' },
{ val: 'uppercase', label: 'Uppercase' },
{ val: 'lowercase', label: 'Lowercase' },
{ val: 'capitalize', label: 'Capitalize' },
]}
onPatch={onPatch}
/>
</div>
</div>
{/* Paragraph spacing — only when <p> children exist */}
{hasParagraphChildren && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Paragraph spacing (applies to &lt;p&gt; children)</FieldLabel>
<NumInput
value={styles['margin-bottom'] ?? '0px'}
propKey="p-margin-bottom"
onPatch={(_, val) => onPatchChildren('p', 'margin-bottom', val)}
inputWidth={72}
/>
</div>
)}
</div>
)}
<HSep />
</>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
// ── useIndexer ────────────────────────────────────────────────────────────────
// Connects the canvas app to the CLI AST indexer server.
//
// On mount it:
// 1. Reads window.__OM_INDEX_URL__ injected by the CLI proxy. If absent,
// the indexer stays 'offline' and the hook is a no-op.
// 2. Fetches GET /health to hydrate projectMeta (framework, tailwind, etc.)
// and sets indexerStatus → 'ready'.
// 3. Opens a GET /events SSE subscription that updates indexerStatus to
// 'indexing' during re-scans and back to 'ready' when done.
//
// Exports fetchComponents(name) and fetchFile(path) for use by the Props tab
// and future Code tab.
import { useEffect, useRef, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import type { ProjectMeta } from '@/store/canvas';
interface ComponentEntry {
name: string;
filePath: string;
line: number;
cssImports: string[];
}
interface IndexerHealthResponse {
status: string;
projectMeta: ProjectMeta;
}
declare global {
interface Window {
__OM_INDEX_URL__?: string;
}
}
export function useIndexer() {
const { setIndexerStatus, setProjectMeta } = useCanvas();
const baseUrlRef = useRef<string | null>(null);
// ── 1. Detect indexer URL and fetch /health ───────────────────────────────
useEffect(() => {
const base = typeof window !== 'undefined' ? (window.__OM_INDEX_URL__ ?? null) : null;
if (!base) {
setIndexerStatus('offline');
return;
}
// Store for use by SSE effect and fetch helpers. Both effects run in the
// same render cycle so the ref is populated before the SSE effect opens.
baseUrlRef.current = base;
let cancelled = false;
async function fetchHealth() {
try {
const res = await fetch(`${base}/health`);
if (!res.ok) throw new Error(`/health ${res.status}`);
const data = await res.json() as IndexerHealthResponse;
if (!cancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
} catch (err) {
if (!cancelled) {
console.warn('[useIndexer] /health failed — indexer offline', err);
setIndexerStatus('offline');
}
}
}
void fetchHealth();
return () => { cancelled = true; };
}, [setIndexerStatus, setProjectMeta]);
// ── 2. SSE subscription for live re-index events ───────────────────────────
useEffect(() => {
// BUG-11 fix: read from ref, not window, for consistency and to avoid
// a second window access after the first effect already resolved the URL.
const base = baseUrlRef.current;
if (!base) return;
const es = new EventSource(`${base}/events`);
// BUG-1 fix: `onopen` fires on EVERY connection, including the very first
// one. We must distinguish initial open (health already fetched by the
// sibling useEffect above) from a *reconnection* after an error. Calling
// /health again on the initial open produces two concurrent fetches racing
// to set projectMeta/indexerStatus, and the second fetch has no cleanup.
let isFirstOpen = true;
// Track whether an onopen-triggered health fetch is in-flight so that the
// effect cleanup can cancel it if the component unmounts before it resolves.
let reopenCancelled = false;
es.addEventListener('INDEX_START', () => {
setIndexerStatus('indexing');
});
es.addEventListener('INDEX_UPDATED', () => {
setIndexerStatus('ready');
});
es.onerror = () => {
// Connection dropped — mark offline. The browser will auto-reconnect;
// onopen will fire again and we will re-sync health at that point.
setIndexerStatus('offline');
};
es.onopen = () => {
if (isFirstOpen) {
// Initial connection: health was already fetched by the sibling effect.
isFirstOpen = false;
return;
}
// Reconnection after an error — re-fetch /health to sync projectMeta
// (the CLI may have been restarted with a different project).
reopenCancelled = false;
void fetch(`${base}/health`)
.then(r => r.json() as Promise<IndexerHealthResponse>)
.then(data => {
if (!reopenCancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
})
.catch(() => { if (!reopenCancelled) setIndexerStatus('offline'); });
};
return () => {
es.close();
reopenCancelled = true;
};
}, [setIndexerStatus, setProjectMeta]);
// ── 3. Data-fetching helpers exposed to consuming components ───────────────
/** Fetch all component entries matching a display name from the index. */
const fetchComponents = useCallback(async (name: string): Promise<ComponentEntry[]> => {
const base = baseUrlRef.current;
if (!base) return [];
try {
const res = await fetch(`${base}/components?name=${encodeURIComponent(name)}`);
if (!res.ok) return [];
return await res.json() as ComponentEntry[];
} catch {
return [];
}
}, []);
/** Fetch the raw source text of a file by absolute path. */
const fetchFile = useCallback(async (filePath: string): Promise<string | null> => {
const base = baseUrlRef.current;
if (!base) return null;
try {
const res = await fetch(`${base}/file?path=${encodeURIComponent(filePath)}`);
if (!res.ok) return null;
return await res.text();
} catch {
return null;
}
}, []);
return { fetchComponents, fetchFile };
}
+63 -1
View File
@@ -1,6 +1,14 @@
import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer';
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
export interface ProjectMeta {
framework: 'next' | 'vite' | 'remix' | 'generic';
tailwind: boolean;
cssModules: boolean;
styledComponents: boolean;
}
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
interface CanvasStore {
@@ -36,6 +44,13 @@ interface CanvasStore {
selectedComponentStyles: Record<string, string> | null;
setComponentStyles: (styles: Record<string, string> | null) => void;
/** True if the selected element has a direct TEXT_NODE child (gates Typography section). */
selectedComponentHasDirectText: boolean;
/** True if the selected element has at least one direct <p> child (gates paragraph spacing). */
selectedComponentHasParagraphChildren: boolean;
/** Set both structural text flags together — always called alongside setComponentStyles. */
setComponentTextFlags: (hasDirectText: boolean, hasParagraphChildren: boolean) => void;
// ── Style edit queue ────────────────────────────────────────────────────────
// The Design tab and resize handles push patches here; each owning
// LiveArtboard drains entries addressed to it, then removes them.
@@ -45,10 +60,29 @@ interface CanvasStore {
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdits: (artboardId: string) => void;
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
childrenStyleEditQueue: Array<{ artboardId: string; parentNodeId: string; selector: string; property: string; value: string }>;
patchChildrenStyleEdit: (artboardId: string, parentNodeId: string, selector: string, property: string, value: string) => void;
clearChildrenStyleEdits: (artboardId: string) => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
clearRemoveElement: () => void;
// ── Selected element mode (Phase 2) ─────────────────────────────────────────
/** 'component' = capitalized React component; 'element' = lowercase DOM tag; null = nothing selected */
selectedElementMode: 'component' | 'element' | null;
setSelectedElementMode: (mode: 'component' | 'element' | null) => void;
// ── CLI AST indexer integration (Phase 3) ────────────────────────────────────
/** Status of the CLI AST indexer; drives Props tab and Code tab behavior */
indexerStatus: 'offline' | 'indexing' | 'ready';
setIndexerStatus: (status: 'offline' | 'indexing' | 'ready') => void;
/** Project metadata fetched from GET /health on CLI connection */
projectMeta: ProjectMeta | null;
setProjectMeta: (meta: ProjectMeta | null) => void;
}
export const useCanvas = create<CanvasStore>((set) => ({
@@ -78,19 +112,47 @@ export const useCanvas = create<CanvasStore>((set) => ({
selectedComponentId: null,
selectedComponentData: null,
selectComponent: (id, data) =>
set({ selectedComponentId: id, selectedComponentData: data, selectedComponentStyles: null }),
// BUG-2 fix: reset text flags alongside styles so a component without
// direct text doesn't inherit the previous selection's Typography section.
set({
selectedComponentId: id,
selectedComponentData: data,
selectedComponentStyles: null,
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
}),
selectedComponentStyles: null,
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
setComponentTextFlags: (hasDirectText, hasParagraphChildren) =>
set({ selectedComponentHasDirectText: hasDirectText, selectedComponentHasParagraphChildren: hasParagraphChildren }),
styleEditQueue: [],
patchStyleEdit: (artboardId, nodeId, property, value) =>
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
clearStyleEdits: (artboardId) =>
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== artboardId) })),
childrenStyleEditQueue: [],
patchChildrenStyleEdit: (artboardId, parentNodeId, selector, property, value) =>
set((s) => ({ childrenStyleEditQueue: [...s.childrenStyleEditQueue, { artboardId, parentNodeId, selector, property, value }] })),
clearChildrenStyleEdits: (artboardId) =>
set((s) => ({ childrenStyleEditQueue: s.childrenStyleEditQueue.filter((e) => e.artboardId !== artboardId) })),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
set({ removeElementEvent: { artboardId, nodeId } }),
clearRemoveElement: () => set({ removeElementEvent: null }),
selectedElementMode: null,
setSelectedElementMode: (mode) => set({ selectedElementMode: mode }),
indexerStatus: 'offline',
setIndexerStatus: (status) => set({ indexerStatus: status }),
projectMeta: null,
setProjectMeta: (meta) => set({ projectMeta: meta }),
}));
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -22,7 +22,10 @@
"typecheck": "tsc --noEmit",
"build": "node build.mjs"
},
"dependencies": {},
"dependencies": {
"chokidar": "^5.0.0",
"tinyglobby": "^0.2.0"
},
"devDependencies": {
"@originmain/renderer": "workspace:*",
"@types/node": "^22.0.0",
@@ -30,7 +33,7 @@
"typescript": "^5.5.0"
},
"engines": {
"node": ">=22"
"node": ">=18"
},
"license": "MIT",
"publishConfig": {
+78 -18
View File
@@ -3,40 +3,53 @@
// ── Originmain CLI ───────────────────────────────────────────────────────────
// Usage:
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
// [--index-port 4171] [--no-index]
//
// Starts a reverse proxy that enables live React component inspection
// in Originmain artboards. See RENDERING-ARCHITECTURE.md for details.
// Starts a reverse proxy + optional AST indexer for live React component
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
import { parseArgs } from 'node:util';
import { resolve } from 'node:path';
import { startProxy } from './proxy.js';
import { Indexer } from './indexer.js';
import { startIndexServer } from './index-server.js';
import { detectProjectMeta } from './detect-framework.js';
const DEFAULT_PORT = 4170;
const DEFAULT_INDEX_PORT = 4171;
function printUsage(): void {
console.log(`
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
Usage:
originmain dev --target <url> [--port <number>]
originmain dev --target <url> [options]
Options:
--target, -t Target dev server URL (required)
Example: http://localhost:3000
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
--index-port AST indexer API port (default: ${DEFAULT_INDEX_PORT})
--no-index Disable the AST indexer (Props/Code tabs degraded)
--help, -h Show this help
Example:
Environment variables:
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
Examples:
npx @originmain/cli dev --target http://localhost:3000
npx @originmain/cli dev --target http://localhost:3000 --no-index
`);
}
function main(): void {
// Parse arguments
async function main(): Promise<void> {
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
target: { type: 'string', short: 't' },
port: { type: 'string', short: 'p' },
'index-port': { type: 'string' },
'no-index': { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
@@ -47,7 +60,6 @@ function main(): void {
if (values.help || !command) {
printUsage();
// --help is a success; missing command is a usage error.
process.exit(values.help ? 0 : 1);
}
@@ -56,18 +68,16 @@ function main(): void {
process.exit(1);
}
// Validate --target
// ── Validate --target ─────────────────────────────────────────────────────
const target = values.target;
if (!target || typeof target !== 'string') {
console.error(' Error: --target is required.\n Example: originmain dev --target http://localhost:3000');
process.exit(1);
}
// Validate target URL
let targetUrl: URL;
try {
targetUrl = new URL(target);
} catch {
try { targetUrl = new URL(target); }
catch {
console.error(` Error: Invalid target URL: ${target}`);
process.exit(1);
}
@@ -77,20 +87,67 @@ function main(): void {
process.exit(1);
}
// Parse port
// ── Parse ports ───────────────────────────────────────────────────────────
const port = values.port ? parseInt(values.port as string, 10) : DEFAULT_PORT;
if (Number.isNaN(port) || port < 1 || port > 65535) {
console.error(` Error: Invalid port: ${values.port}`);
process.exit(1);
}
// Start proxy
const proxy = startProxy({ target, port });
const indexPortRaw = values['index-port'] as string | undefined;
const indexPort = indexPortRaw ? parseInt(indexPortRaw, 10) : DEFAULT_INDEX_PORT;
if (Number.isNaN(indexPort) || indexPort < 1 || indexPort > 65535) {
console.error(` Error: Invalid index port: ${indexPortRaw}`);
process.exit(1);
}
// Graceful shutdown
const noIndex = values['no-index'] === true;
// ── Agent Bridge URL ──────────────────────────────────────────────────────
let bridgeUrl = process.env['ORIGINMAIN_BRIDGE_URL'];
if (!bridgeUrl) {
// Try ~/.originmain/config.json
try {
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
const cfgPath = resolve(homeDir, '.originmain', 'config.json');
const { readFileSync } = await import('node:fs');
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
if (typeof cfg['bridgeUrl'] === 'string') bridgeUrl = cfg['bridgeUrl'];
} catch { /* config not present */ }
if (!bridgeUrl) {
bridgeUrl = 'http://localhost:4172';
console.log(` \x1b[2mUsing default Agent Bridge URL: ${bridgeUrl}\x1b[0m`);
}
}
// ── Start AST indexer (unless --no-index) ─────────────────────────────────
const projectRoot = process.cwd();
let indexServer: { close: () => void } | null = null;
if (!noIndex) {
const projectMeta = await detectProjectMeta(projectRoot);
const indexer = new Indexer(projectRoot);
// Start watching in background (non-blocking)
indexer.watch().catch((err: Error) => {
console.error(`[originmain indexer] Watch error: ${err.message}`);
});
indexServer = startIndexServer({ indexer, projectMeta, projectRoot, port: indexPort });
} else {
console.log(' \x1b[2mAST indexer disabled (--no-index)\x1b[0m');
}
// ── Start the reverse proxy ───────────────────────────────────────────────
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
const proxy = startProxy({ target, port, indexUrl });
// ── Graceful shutdown ─────────────────────────────────────────────────────
function shutdown(): void {
console.log('\n Shutting down proxy...');
console.log('\n Shutting down...');
proxy.close();
indexServer?.close();
process.exit(0);
}
@@ -98,4 +155,7 @@ function main(): void {
process.on('SIGTERM', shutdown);
}
main();
main().catch((err: unknown) => {
console.error(' Fatal error:', err);
process.exit(1);
});
+123
View File
@@ -0,0 +1,123 @@
// ── Framework & CSS Strategy Detection ───────────────────────────────────────
// Canonical detection logic for the Originmain CLI — used by indexer.ts,
// index-server.ts, and isolation-server.ts. Import from here; do not duplicate.
//
// Detection is best-effort and parse-only (no module resolution).
// Priority: explicit config files > package.json dependency names.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { glob } from 'tinyglobby';
export type Framework = 'next' | 'vite' | 'remix' | 'generic';
export interface ProjectMeta {
framework: Framework;
tailwind: boolean;
cssModules: boolean;
styledComponents: boolean;
}
/** Read and JSON-parse package.json from projectRoot, returning {} on any error. */
function readPackageJson(projectRoot: string): Record<string, unknown> {
try {
const raw = readFileSync(join(projectRoot, 'package.json'), 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
/** Return true if any of the given patterns exist as a file in projectRoot. */
function anyFileExists(projectRoot: string, patterns: string[]): boolean {
return patterns.some((p) => existsSync(join(projectRoot, p)));
}
/** Return true if name appears in the combined deps+devDeps of the package. */
function hasDep(pkg: Record<string, unknown>, ...names: string[]): boolean {
const deps = (pkg['dependencies'] ?? {}) as Record<string, unknown>;
const devDeps = (pkg['devDependencies'] ?? {}) as Record<string, unknown>;
return names.some((n) => n in deps || n in devDeps);
}
/**
* Detect the JS framework used in projectRoot.
*
* Priority order:
* 1. `next` in package.json dependencies 'next'
* 2. remix.config.* in root 'remix'
* 3. vite.config.* in root 'vite'
* 4. `vite` in devDependencies 'vite'
* 5. fallback 'generic'
*/
export function detectFramework(projectRoot: string): Framework {
const pkg = readPackageJson(projectRoot);
if (hasDep(pkg, 'next')) return 'next';
if (anyFileExists(projectRoot, [
'remix.config.js', 'remix.config.ts', 'remix.config.mjs', 'remix.config.cjs',
])) return 'remix';
if (anyFileExists(projectRoot, [
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs',
])) return 'vite';
if (hasDep(pkg, 'vite')) return 'vite';
return 'generic';
}
/**
* Detect CSS strategy flags:
* tailwind tailwind.config.* exists OR 'tailwindcss' in deps
* cssModules any *.module.css exists in projectRoot (checked via glob)
* styledComponents 'styled-components' OR '@emotion/react' in deps
*
* `cssModules` detection uses a glob walk and may be slow on large projects;
* callers should invoke this once at startup and cache the result.
*/
export async function detectCssStrategy(projectRoot: string): Promise<{
tailwind: boolean;
cssModules: boolean;
styledComponents: boolean;
}> {
const pkg = readPackageJson(projectRoot);
const tailwind = hasDep(pkg, 'tailwindcss') || anyFileExists(projectRoot, [
'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs', 'tailwind.config.cjs',
]);
const styledComponents = hasDep(
pkg,
'styled-components', '@emotion/react', '@emotion/styled',
);
// Glob walk for *.module.css — skip node_modules. Uses tinyglobby for
// cross-platform support on Node 20 LTS (node:fs/promises glob is Node 22+).
let cssModules = false;
try {
const matches = await glob('**/*.module.css', {
cwd: projectRoot,
ignore: ['**/node_modules/**', '**/.git/**'],
onlyFiles: true,
// Stop early: we only need one match.
// tinyglobby doesn't have a native limit, but the search is fast enough.
});
cssModules = matches.length > 0;
} catch {
cssModules = false;
}
return { tailwind, cssModules, styledComponents };
}
/**
* Detect all project metadata in one call.
* Returns synchronously for framework; async for CSS strategy.
*/
export async function detectProjectMeta(projectRoot: string): Promise<ProjectMeta> {
const framework = detectFramework(projectRoot);
const css = await detectCssStrategy(projectRoot);
return { framework, ...css };
}
+246
View File
@@ -0,0 +1,246 @@
// ── AST Index HTTP Server ─────────────────────────────────────────────────────
// Exposes the component index built by the Indexer class over a local HTTP API.
// Runs on port 4171 (or --index-port N). Localhost-only; no auth required.
//
// Endpoints:
// GET /health → status + indexed count + projectMeta
// GET /components → ComponentEntry[] (all)
// GET /components?name=Card → ComponentEntry[] (fuzzy by name)
// GET /components?file=src/… → ComponentEntry[] (by file path)
// GET /file?path=src/… → { content, lines } (with path security)
// GET /events → text/event-stream (SSE for index updates)
// POST /reindex → trigger full rescan
import { createServer } from 'node:http';
import { readFileSync, realpathSync, existsSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Indexer } from './indexer.js';
import type { ProjectMeta } from './detect-framework.js';
// ── File security config ────────────────────────────────────────────────────
/** Only these extensions are allowed through GET /file. */
const ALLOWED_EXTENSIONS = new Set([
'.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.json',
]);
/** Path patterns that are always blocked, regardless of extension. */
const BLOCKED_PATH_RE = /(\/(\.git|node_modules)\/)|(password|secret|credential|token|private)/i;
/** Filename patterns that are never served. */
const BLOCKED_FILENAME_RE = /^\.env(\.|$)|(\.pem|\.key|\.p12|\.pfx|\.jks|\.crt|\.cer|\.der|\.secret|\.secrets)$/i;
function isPathSafe(
projectRoot: string,
requestedPath: string,
): { safe: boolean; absolute: string } {
// Step 1: URL-decode
let decoded: string;
try { decoded = decodeURIComponent(requestedPath); }
catch { return { safe: false, absolute: '' }; }
// Step 2: Resolve to absolute (lexical — does NOT follow symlinks yet)
const absolute = resolve(projectRoot, decoded);
// Step 3: Must be within projectRoot (+ sep prevents prefix confusion).
// Check lexical path first as a fast pre-filter.
if (!absolute.startsWith(projectRoot + sep)) {
return { safe: false, absolute };
}
// C-2 fix: resolve() is lexical; a symlink inside the project root could
// point outside it (e.g., src/secrets -> /etc). Call realpathSync on the
// *parent directory* (which must exist) to canonicalise without requiring
// the file itself to exist yet, then re-apply the prefix check.
try {
if (existsSync(absolute)) {
const real = realpathSync(absolute);
if (!real.startsWith(projectRoot + sep)) {
return { safe: false, absolute };
}
}
} catch {
// realpathSync can fail for broken symlinks — treat as unsafe.
return { safe: false, absolute };
}
// Step 4: Extension check
const lastDot = absolute.lastIndexOf('.');
const ext = lastDot >= 0 ? absolute.slice(lastDot) : '';
if (!ALLOWED_EXTENSIONS.has(ext)) return { safe: false, absolute };
// Step 5: Blocked path / filename patterns
if (BLOCKED_PATH_RE.test(absolute)) return { safe: false, absolute };
const fileName = absolute.slice(absolute.lastIndexOf(sep) + 1);
if (BLOCKED_FILENAME_RE.test(fileName)) return { safe: false, absolute };
return { safe: true, absolute };
}
// ── CORS headers ────────────────────────────────────────────────────────────
const CORS: Record<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-headers': 'content-type',
};
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...CORS });
res.end(json);
}
function sendError(res: ServerResponse, status: number, message: string): void {
sendJson(res, status, { error: message });
}
// ── SSE helpers ─────────────────────────────────────────────────────────────
/** Active SSE connections; used to broadcast index update events. */
let sseClients: ServerResponse[] = [];
function broadcastSse(event: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(event)}\n\n`;
sseClients = sseClients.filter((res) => {
try { res.write(data); return true; }
catch { return false; }
});
}
/**
* Start the index HTTP server.
* Returns a `close()` method for graceful shutdown.
*/
export function startIndexServer(opts: {
indexer: Indexer;
projectMeta: ProjectMeta;
projectRoot: string;
port: number;
}): { close: () => void } {
const { indexer, projectMeta, projectRoot, port } = opts;
// Broadcast index updates to SSE clients
indexer.on('update', (event: Record<string, unknown>) => {
broadcastSse({ type: 'INDEX_UPDATED', ...event });
});
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
const path = url.pathname;
const method = req.method?.toUpperCase() ?? 'GET';
// CORS preflight
if (method === 'OPTIONS') {
res.writeHead(204, CORS);
res.end();
return;
}
// ── GET /health ────────────────────────────────────────────────────────
if (path === '/health' && method === 'GET') {
sendJson(res, 200, {
status: indexer.status,
indexed: indexer.componentCount(),
lastScan: indexer.lastScan,
projectRoot,
projectMeta,
});
return;
}
// ── GET /components ────────────────────────────────────────────────────
if (path === '/components' && method === 'GET') {
const nameParam = url.searchParams.get('name');
const fileParam = url.searchParams.get('file');
const results = indexer.query({
...(nameParam != null && { name: nameParam }),
...(fileParam != null && { file: fileParam }),
});
sendJson(res, 200, results);
return;
}
// ── GET /file ──────────────────────────────────────────────────────────
if (path === '/file' && method === 'GET') {
const requestedPath = url.searchParams.get('path') ?? '';
console.log(`[originmain indexer] File request: ${requestedPath}`);
if (!requestedPath) {
sendError(res, 400, 'Missing ?path= parameter');
return;
}
const { safe, absolute } = isPathSafe(projectRoot, requestedPath);
if (!safe) {
console.warn(`[originmain indexer] Blocked file request: ${requestedPath} (resolved: ${absolute})`);
sendError(res, 403, 'Access denied');
return;
}
try {
const content = readFileSync(absolute, 'utf-8');
const lines = content.split('\n').length;
sendJson(res, 200, { content, lines });
} catch {
sendError(res, 404, 'File not found');
}
return;
}
// ── GET /events (SSE) ──────────────────────────────────────────────────
if (path === '/events' && method === 'GET') {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive',
...CORS,
});
// Heartbeat comment to keep connection alive through proxies
res.write(': connected\n\n');
sseClients.push(res);
// Send current status immediately on connect
res.write(`data: ${JSON.stringify({ type: 'STATUS', status: indexer.status, indexed: indexer.componentCount() })}\n\n`);
req.on('close', () => {
sseClients = sseClients.filter((c) => c !== res);
});
return;
}
// ── POST /reindex ──────────────────────────────────────────────────────
if (path === '/reindex' && method === 'POST') {
indexer.fullScan().catch((err: Error) => {
console.error(`[originmain indexer] Rescan failed: ${err.message}`);
});
sendJson(res, 202, { message: 'Rescan started' });
return;
}
// 404
sendError(res, 404, `Unknown endpoint: ${path}`);
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n \x1b[31mError:\x1b[0m Index port ${port} is already in use.`);
console.error(` Try: originmain dev --target ... --index-port ${port + 1}\n`);
process.exit(1);
}
throw err;
});
server.listen(port, '127.0.0.1', () => {
console.log(` \x1b[2mAST indexer API .............. http://localhost:${port}\x1b[0m`);
});
return {
close() {
sseClients.forEach((res) => { try { res.end(); } catch { /* ignore */ } });
sseClients = [];
server.close();
},
};
}
+391
View File
@@ -0,0 +1,391 @@
// -- CLI AST Indexer ------------------------------------------------------
// Walks a React TypeScript project and builds a component index using
// TypeScript's compiler API in parse-only mode (no type-checking, no
// tsconfig program). Fast enough for incremental watch updates.
//
// What is indexed per file:
// - Named/default exported functions whose bodies contain at least one JSX node
// - Their first parameter type annotation (Props type, opaque - not expanded)
// - var(--token) references in JSX attributes and template literals
// - CSS/SCSS/module imports (for CSS file change -> re-index logic)
//
// What is NOT indexed:
// - node_modules, files > 500KB
// - Full type resolution (requires full ts.createProgram, deferred post-Phase 7)
import { readFileSync, statSync } from 'node:fs';
import { resolve, relative, extname, join } from 'node:path';
import { EventEmitter } from 'node:events';
import * as ts from 'typescript';
import chokidar from 'chokidar';
import { glob } from 'tinyglobby';
export interface PropEntry {
name: string;
type: string; // "string | undefined" -- parse-only, may be opaque type name
optional: boolean;
}
export interface ComponentEntry {
name: string;
definitionFile: string; // absolute path
relativeFile: string; // relative to project root: "src/components/Card.tsx"
lineNumber: number; // 1-indexed line of the export declaration
isDefaultExport: boolean;
props: PropEntry[];
tokensUsed: string[]; // CSS custom properties: ["--color-primary"]
cssImports: string[]; // relative paths of CSS/SCSS/module imports
lastIndexed: number; // Date.now()
}
export type IndexerStatus = 'idle' | 'indexing' | 'ready' | 'error';
// -- CSS token regex --------------------------------------------------------
// Matches var(--any-valid-custom-property-name)
const CSS_TOKEN_RE = /var\((--[-\w]+)\)/g;
// -- CSS import extensions --------------------------------------------------
const CSS_EXTS = new Set(['.css', '.scss', '.sass', '.less', '.module.css', '.module.scss']);
function isCssImport(path: string): boolean {
const ext = path.slice(path.lastIndexOf('.'));
return CSS_EXTS.has(ext) || path.includes('.module.');
}
// -- JSX detection ----------------------------------------------------------
// Returns true if the given AST node (or any descendant) is a JSX element/fragment.
function containsJsx(node: ts.Node): boolean {
if (
node.kind === ts.SyntaxKind.JsxElement ||
node.kind === ts.SyntaxKind.JsxSelfClosingElement ||
node.kind === ts.SyntaxKind.JsxFragment
) return true;
let found = false;
ts.forEachChild(node, (child) => {
if (found) return;
if (containsJsx(child)) found = true;
});
return found;
}
// -- CSS token extraction ---------------------------------------------------
// Scans the full source text for var(--token) references.
function extractTokens(sourceText: string): string[] {
const tokens = new Set<string>();
let m: RegExpExecArray | null;
CSS_TOKEN_RE.lastIndex = 0;
while ((m = CSS_TOKEN_RE.exec(sourceText)) !== null) {
tokens.add(m[1] as string);
}
return [...tokens];
}
// -- Prop extraction (parse-only) ------------------------------------------
// Extracts prop names + types from the first parameter type annotation.
// Handles inline object types: ({ color, size }: { color: string; size: number })
// For opaque types (imported or aliased), returns a single entry with the type name.
function extractProps(param: ts.ParameterDeclaration, sourceFile: ts.SourceFile): PropEntry[] {
if (!param.type) return [];
const typeNode = param.type;
// Inline type literal: { color: string; size?: number }
if (ts.isTypeLiteralNode(typeNode)) {
return typeNode.members
.filter(ts.isPropertySignature)
.map((m) => ({
name: m.name.getText(sourceFile),
type: m.type ? m.type.getText(sourceFile) : 'unknown',
optional: !!m.questionToken,
}));
}
// Opaque type reference or destructured pattern: return a single entry
const typeName = typeNode.getText(sourceFile);
const paramName = param.name.getText(sourceFile);
return [{
name: paramName.startsWith('{') ? 'props' : paramName,
type: typeName,
optional: !!param.questionToken,
}];
}
// -- Get line number for a node --------------------------------------------
function getLine(node: ts.Node, sourceFile: ts.SourceFile): number {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
return line + 1; // 0-indexed to 1-indexed
}
// -- Parse a single source file -> ComponentEntry[] -----------------------
function parseFile(filePath: string, projectRoot: string): ComponentEntry[] {
// Skip files > 500KB
try {
const stat = statSync(filePath);
if (stat.size > 500 * 1024) {
console.warn(`[originmain indexer] Skipping large file (${Math.round(stat.size / 1024)}KB): ${filePath}`);
return [];
}
} catch { return []; }
let source: string;
try { source = readFileSync(filePath, 'utf-8'); }
catch { return []; }
const ext = extname(filePath);
const scriptKind =
ext === '.tsx' ? ts.ScriptKind.TSX :
ext === '.ts' ? ts.ScriptKind.TS :
ext === '.jsx' ? ts.ScriptKind.JSX :
ts.ScriptKind.JS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
scriptKind,
);
const relativeFile = relative(projectRoot, filePath).replace(/\\/g, '/');
const tokensUsed = extractTokens(source);
const now = Date.now();
// Collect CSS imports
const cssImports: string[] = [];
sourceFile.statements.forEach((stmt) => {
if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
const importPath = stmt.moduleSpecifier.text;
if (isCssImport(importPath)) cssImports.push(importPath);
}
});
const entries: ComponentEntry[] = [];
function tryExtract(
name: string,
params: ts.NodeArray<ts.ParameterDeclaration>,
body: ts.Block | ts.Expression | undefined,
isDefault: boolean,
declarationNode: ts.Node,
): void {
if (!name || !/^[A-Z]/.test(name)) return;
if (!body) return;
if (!containsJsx(body)) return;
const props = params.length > 0 && params[0] ? extractProps(params[0], sourceFile) : [];
entries.push({
name,
definitionFile: filePath,
relativeFile,
lineNumber: getLine(declarationNode, sourceFile),
isDefaultExport: isDefault,
props,
tokensUsed,
cssImports,
lastIndexed: now,
});
}
sourceFile.statements.forEach((stmt) => {
// export function MyComponent(...) { ... }
if (
ts.isFunctionDeclaration(stmt) &&
stmt.name &&
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)
) {
tryExtract(stmt.name.text, stmt.parameters, stmt.body, false, stmt);
}
// export default function MyComponent(...) { ... }
if (
ts.isFunctionDeclaration(stmt) &&
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) &&
stmt.body
) {
const name = stmt.name?.text ?? 'Default';
tryExtract(name, stmt.parameters, stmt.body, true, stmt);
}
// export const MyComponent = (...) => ...
// export const MyComponent = function(...) { ... }
if (
ts.isVariableStatement(stmt) &&
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
) {
stmt.declarationList.declarations.forEach((decl) => {
if (!ts.isIdentifier(decl.name) || !decl.initializer) return;
const name = decl.name.text;
if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
tryExtract(name, decl.initializer.parameters, decl.initializer.body, false, stmt);
}
});
}
// export default <arrow or function expression>
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
const expr = stmt.expression;
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
const name = (ts.isFunctionExpression(expr) && expr.name) ? expr.name.text : 'Default';
tryExtract(name, expr.parameters, expr.body, true, stmt);
}
}
});
return entries;
}
// -- Indexer class ----------------------------------------------------------
export class Indexer extends EventEmitter {
private projectRoot: string;
private components = new Map<string, ComponentEntry[]>(); // filePath -> entries
private cssToComponents = new Map<string, Set<string>>(); // cssPath -> set of component filePaths
private watcher: ReturnType<typeof chokidar.watch> | null = null;
status: IndexerStatus = 'idle';
lastScan = 0;
constructor(projectRoot: string) {
super();
this.projectRoot = projectRoot;
}
/** Full scan of all .ts/.tsx/.js/.jsx files in the project root (excluding node_modules). */
async fullScan(): Promise<void> {
this.status = 'indexing';
this.emit('status', this.status);
this.components.clear();
this.cssToComponents.clear();
const files = await this._collectFiles();
for (const f of files) {
this._indexFile(f);
}
this.status = 'ready';
this.lastScan = Date.now();
this.emit('status', this.status);
this.emit('ready', { indexed: this.componentCount() });
console.log(`[originmain indexer] Indexed ${this.componentCount()} components from ${files.length} files`);
}
/** Incrementally re-index a single file (called on file change events). */
reindexFile(filePath: string): void {
this._indexFile(filePath);
this.emit('update', { file: relative(this.projectRoot, filePath).replace(/\\/g, '/') });
}
/** Start the file watcher. Calls fullScan() first. */
async watch(): Promise<void> {
await this.fullScan();
this.watcher = chokidar.watch(this.projectRoot, {
ignored: /(node_modules|\.git)/,
persistent: true,
ignoreInitial: true,
});
this.watcher.on('change', (filePath) => {
const ext = extname(filePath);
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
console.log(`[originmain indexer] Re-indexing ${relative(this.projectRoot, filePath)}`);
this.reindexFile(filePath);
} else if (CSS_EXTS.has(ext) || filePath.includes('.module.')) {
this._onCssFileChange(filePath);
}
});
this.watcher.on('add', (filePath) => {
const ext = extname(filePath);
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
this.reindexFile(filePath);
}
});
this.watcher.on('unlink', (filePath) => {
this.components.delete(filePath);
this.emit('update', { file: relative(this.projectRoot, filePath).replace(/\\/g, '/') });
});
}
/** Stop the watcher. */
async stop(): Promise<void> {
await this.watcher?.close();
}
/** All component entries, flattened. */
allComponents(): ComponentEntry[] {
return [...this.components.values()].flat();
}
componentCount(): number {
return this.allComponents().length;
}
/**
* Query by name (substring / case-insensitive fuzzy) or by file path.
* Returns up to 20 results.
*/
query(opts: { name?: string; file?: string }): ComponentEntry[] {
const all = this.allComponents();
if (opts.file) return all.filter((c) => c.relativeFile.includes(opts.file!));
if (opts.name) {
const lower = opts.name.toLowerCase();
return all.filter((c) => c.name.toLowerCase().includes(lower)).slice(0, 20);
}
return all;
}
// -- Private helpers -------------------------------------------------------
private _indexFile(filePath: string): void {
try {
const entries = parseFile(filePath, this.projectRoot);
this.components.set(filePath, entries);
// Build reverse CSS -> component map for incremental CSS updates
for (const entry of entries) {
for (const cssRel of entry.cssImports) {
const cssAbs = resolve(filePath, '..', cssRel);
let set = this.cssToComponents.get(cssAbs);
if (!set) { set = new Set(); this.cssToComponents.set(cssAbs, set); }
set.add(filePath);
}
}
} catch (err) {
console.warn(`[originmain indexer] Parse error in ${filePath}: ${String(err)}`);
this.components.set(filePath, []);
}
}
private _onCssFileChange(cssPath: string): void {
const affected = this.cssToComponents.get(cssPath);
if (!affected) return;
for (const componentFile of affected) {
this.reindexFile(componentFile);
}
const rel = relative(this.projectRoot, cssPath).replace(/\\/g, '/');
this.emit('update', { file: rel, reason: 'css-tokens' });
}
private async _collectFiles(): Promise<string[]> {
// tinyglobby works on Node 18+, unlike node:fs/promises glob (Node 22+).
// This was the root cause of M-6/M-7: Node 20 LTS users got a silent
// empty index because the dynamic import of node:fs/promises glob threw.
try {
const matches = await glob('**/*.{ts,tsx,js,jsx}', {
cwd: this.projectRoot,
ignore: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/.next/**',
],
onlyFiles: true,
absolute: false,
});
return matches.map((f: string) => join(this.projectRoot, f));
} catch (err) {
console.warn(`[originmain indexer] File collection failed: ${String(err)}`);
return [];
}
}
}
+34 -30
View File
@@ -1,28 +1,40 @@
// ── HTML Injection ───────────────────────────────────────────────────────────
// -- HTML Injection ----------------------------------------------------------
// Injects the Originmain fiber hook <script> into an HTML response body.
// The script must appear BEFORE any other scripts so that
// __REACT_DEVTOOLS_GLOBAL_HOOK__ is installed before React evaluates.
//
// Also injects window.__OM_INDEX_URL__ (AST indexer API) and
// window.__OM_ISO_BASE__ (isolation artboard base path) so the canvas can
// discover the indexer without any out-of-band coordination.
import { buildProxyFiberHookScript } from '@originmain/renderer';
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
let cachedScriptTag: string | undefined;
let cachedFiberTag: string | undefined;
function getScriptTag(): string {
if (cachedScriptTag === undefined) {
cachedScriptTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
function getFiberTag(): string {
if (cachedFiberTag === undefined) {
cachedFiberTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
}
return cachedScriptTag;
return cachedFiberTag;
}
/**
* Build the bridge config <script> tag.
* Not cached because indexUrl may vary per process invocation.
*/
function getBridgeConfigTag(indexUrl: string | null | undefined): string {
const indexUrlJson = indexUrl ? JSON.stringify(indexUrl) : 'null';
return (
`<script data-originmain-bridge-config>` +
`window.__OM_INDEX_URL__=${indexUrlJson};` +
`window.__OM_ISO_BASE__="/__om_isolation__";` +
`</script>`
);
}
/**
* Strip inline Content-Security-Policy meta tags from HTML.
*
* The proxy already removes the CSP response header, but some frameworks
* (e.g. Next.js with a custom _document) also embed CSP inside a meta tag.
* A meta CSP applies to the entire document including scripts parsed before
* it so our injected hook can be silently blocked even though it runs first.
* Removing these tags lets the hook execute freely inside the sandboxed iframe.
*/
function stripMetaCsp(html: string): string {
return html.replace(
@@ -32,34 +44,26 @@ function stripMetaCsp(html: string): string {
}
/**
* Inject the fiber hook script into an HTML string.
*
* Steps:
* 1. Strip any inline Content-Security-Policy meta tags that could block the
* injected script (the CSP response header is stripped by the proxy itself).
* 2. Insert the hook script immediately after the opening <head> tag
* (preferred), after <html> (fallback), or prepend to the document (final
* fallback). Placing it first ensures __REACT_DEVTOOLS_GLOBAL_HOOK__ is
* registered before React's module body runs.
* Inject the fiber hook + bridge config scripts into an HTML string.
*/
export function injectFiberHook(html: string): string {
const tag = getScriptTag();
export function injectFiberHook(html: string, indexUrl?: string | null): string {
const injection = getBridgeConfigTag(indexUrl) + getFiberTag();
const cleaned = stripMetaCsp(html);
// Try after <head>
const headMatch = /<head[^>]*>/i.exec(cleaned);
if (headMatch) {
const headMatch = cleaned.match(/<head[^>]*>/i);
if (headMatch?.index !== undefined) {
const insertAt = headMatch.index + headMatch[0].length;
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
return cleaned.slice(0, insertAt) + injection + cleaned.slice(insertAt);
}
// Try after <html>
const htmlMatch = /<html[^>]*>/i.exec(cleaned);
if (htmlMatch) {
const htmlMatch = cleaned.match(/<html[^>]*>/i);
if (htmlMatch?.index !== undefined) {
const insertAt = htmlMatch.index + htmlMatch[0].length;
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
return cleaned.slice(0, insertAt) + injection + cleaned.slice(insertAt);
}
// Final fallback: prepend
return tag + cleaned;
return injection + cleaned;
}
+38
View File
@@ -0,0 +1,38 @@
// ── Isolation Server (Phase 3 stub) ──────────────────────────────────────────
// Serves `/__om_isolation__` wrapper pages that render a single component in
// isolation (component artboard type). Full implementation ships in Phase 3.
//
// Current status: 501 stub that tells the user Phase 3 is required.
// The proxy delegates all /__om_isolation__ requests here.
import type { IncomingMessage, ServerResponse } from 'node:http';
const STUB_BODY = [
'<!DOCTYPE html>',
'<html lang="en">',
'<head><meta charset="UTF-8" /><title>Isolation artboard — not yet available</title>',
'<style>body{margin:0;background:#0d0d11;color:rgba(255,255,255,0.6);',
'font:13px/1.6 ui-monospace,monospace;display:flex;align-items:center;',
'justify-content:center;height:100vh;text-align:center;}</style></head>',
'<body>',
'<div>',
' <p style="font-size:1.1rem;color:rgba(255,255,255,0.85)">',
' Isolation artboards require the CLI AST indexer',
' </p>',
' <p>Start <code style="color:#7EB8FF">originmain dev</code> without ',
' <code style="color:#7EB8FF">--no-index</code> to enable isolation frames.</p>',
'</div>',
'</body></html>',
].join('\n');
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
export function handleIsolationRequest(
_req: IncomingMessage,
res: ServerResponse,
): void {
res.writeHead(501, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
res.end(STUB_BODY);
}
+20 -2
View File
@@ -15,12 +15,15 @@ import type { IncomingMessage, ServerResponse,
RequestOptions, ClientRequest } from 'node:http';
import type { Socket } from 'node:net';
import { injectFiberHook } from './inject.js';
import { handleIsolationRequest } from './isolation-server.js';
export interface ProxyOptions {
/** Target dev server URL, e.g. "http://localhost:3000" */
target: string;
/** Port for the proxy to listen on (default: 4170) */
port: number;
/** URL of the AST indexer API (null when --no-index). Injected as window.__OM_INDEX_URL__ */
indexUrl?: string | null;
}
/** Headers to strip from proxied responses (case-insensitive). */
@@ -66,6 +69,12 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10);
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
// ── Intercept /__om_isolation__/* requests ────────────────────────────
if (clientReq.url?.startsWith('/__om_isolation__')) {
handleIsolationRequest(clientReq, clientRes);
return;
}
// ── Build the outgoing request headers ────────────────────────────────
const outHeaders: Record<string, string | string[]> = {};
@@ -160,7 +169,7 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
proxyRes.on('end', () => {
const rawHtml = Buffer.concat(chunks).toString('utf-8');
const injectedHtml = injectFiberHook(rawHtml);
const injectedHtml = injectFiberHook(rawHtml, opts.indexUrl);
const body = Buffer.from(injectedHtml, 'utf-8');
// Correct Content-Length and drop Transfer-Encoding: chunked.
@@ -249,13 +258,18 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
// ── Start listening ───────────────────────────────────────────────────────
server.listen(opts.port, () => {
// H-5 fix: bind to loopback only — the proxy strips security headers and
// injects the fiber hook, so it must never be reachable from the LAN.
server.listen(opts.port, '127.0.0.1', () => {
const proxyUrl = `http://localhost:${opts.port}`;
console.log('');
console.log(' \x1b[36m\x1b[1mOriginmain\x1b[0m proxy running');
console.log('');
console.log(` Target: ${opts.target}`);
console.log(` Proxy: \x1b[1m${proxyUrl}\x1b[0m`);
if (opts.indexUrl) {
console.log(` Indexer: \x1b[1m${opts.indexUrl}\x1b[0m`);
}
console.log('');
console.log(' Paste the proxy URL into your Originmain artboard\'s');
console.log(' "Connect app" field to enable live rendering.');
@@ -263,6 +277,10 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
console.log(' \x1b[2mFiber hook injection ........ active\x1b[0m');
console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m');
console.log(' \x1b[2mWebSocket passthrough ....... active\x1b[0m');
console.log(opts.indexUrl
? ' \x1b[2mAST indexer ................. active\x1b[0m'
: ' \x1b[2mAST indexer ................. disabled (--no-index)\x1b[0m',
);
if (isHttps) {
console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m');
}
+29
View File
@@ -0,0 +1,29 @@
// Ambient declaration for tinyglobby (0.2.16 ships its types as
// `./dist/index.d.cts` per package.json but that file isn't actually in the
// tarball). Cover only the surface we use; widen if more API surface lands.
declare module 'tinyglobby' {
export interface GlobOptions {
cwd?: string;
ignore?: string | string[];
onlyFiles?: boolean;
onlyDirectories?: boolean;
absolute?: boolean;
dot?: boolean;
expandDirectories?: boolean;
followSymbolicLinks?: boolean;
caseSensitiveMatch?: boolean;
deep?: number;
patterns?: string | string[];
}
export function glob(
patterns: string | string[],
options?: GlobOptions,
): Promise<string[]>;
export function globSync(
patterns: string | string[],
options?: GlobOptions,
): string[];
}
+277 -24
View File
@@ -138,6 +138,22 @@ export function buildProxyFiberHookScript(): string {
var selectedNodeId = null; // currently highlighted component
var highlightEl = null; // the blue-ring DOM overlay element
// ── Style override persistence (H-7 fix) ─────────────────────────────────
// Inline styles set by PATCH_ELEMENT_STYLE are wiped by React on the next
// reconciliation of the patched component. To make patches survive, we
// mirror them into an external stylesheet keyed by a data-om-id attribute
// that we re-tag onto the DOM on every commit.
//
// styleOverrides[nodeId][property] = value
// childrenStyleOverrides[parentId][selector][property] = value
//
// After each commit we (a) rewrite data-om-id on every nodeMap element so
// the selectors still match, and (b) rebuild the <style> sheet text.
// Cascade wins over component styles because every rule is !important.
var styleOverrides = {}; // nodeId -> { property -> value }
var childrenStyleOverrides = {}; // parentNodeId -> { selector -> { property -> value } }
var overrideStyleEl = null; // the <style id="__om_overrides__"> element
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg) {
try {
@@ -172,6 +188,10 @@ export function buildProxyFiberHookScript(): string {
fiberMap = new WeakMap();
var tree = serializeFiber(root.current, '');
// H-7 fix: re-tag DOM with data-om-id and reapply the override sheet
// BEFORE telling the host the tree updated, so the visual is consistent
// by the time the host repaints its inspector.
reapplyOverrides();
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (layout may shift).
if (selectedNodeId) updateHighlight();
@@ -183,12 +203,19 @@ export function buildProxyFiberHookScript(): string {
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
//
// siblingIndex is the position among VISIBLE (named) siblings at the same
// flattened level — NOT fiber.index. Using fiber.index caused H-2: two
// same-named components living under different transparent wrappers
// (Fragment, Context.Provider, React.memo) both had fiber.index === 0
// relative to their own React parent, so when collectChildren flattened
// them into the same visible level they collided to the same nodeId.
//
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
// are transparent — their children are collected directly into their parent's
// children array. Returning only the first named child (the old approach)
// caused entire subtrees to vanish from the tree.
function serializeFiber(fiber, parentId) {
function serializeFiber(fiber, parentId, siblingIndex) {
if (!fiber) return null;
var name = getDisplayName(fiber);
if (!name) {
@@ -202,11 +229,27 @@ export function buildProxyFiberHookScript(): string {
return { id: '__root__', name: '__root__', props: {}, children: children };
}
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(siblingIndex || 0);
var rect = getDomRect(fiber);
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
if (rect) node.domRect = rect;
// ── _debugSource extraction (Phase 1) ────────────────────────────────────
// _debugSource is set by React's dev build on every fiber created from JSX.
// It points to the JSX call site (the file that wrote <ComponentName />),
// NOT the definition file. Only present when __DEV__ === true.
// We only read it for function/class components (typeof fiber.type === 'function')
// to avoid bogus call-site data on host elements (div, span, etc.).
try {
if (fiber._debugSource && typeof fiber.type === 'function') {
var ds = fiber._debugSource;
if (ds.fileName && typeof ds.lineNumber === 'number') {
node.callSite = { fileName: ds.fileName, lineNumber: ds.lineNumber };
if (typeof ds.columnNumber === 'number') node.callSite.columnNumber = ds.columnNumber;
}
}
} catch (e) { /* _debugSource access can throw in some SSR contexts */ }
// Register in both maps for O(1) lookup.
nodeMap[nodeId] = { domRect: rect || null, fiber: fiber };
fiberMap.set(fiber, nodeId);
@@ -230,18 +273,28 @@ export function buildProxyFiberHookScript(): string {
// Collect all NAMED descendants of fiber.child into out[], transparently
// flattening unnamed intermediates (Fragments, Providers, wrappers).
function collectChildren(fiber, parentId, out) {
//
// The sibling counter (counterRef.n) is per-VISIBLE-LEVEL, so it survives
// the recursion into transparent wrappers — that's how H-2 (nodeId
// collision across same-named siblings under different wrappers) is
// prevented. Pass the same counterRef to recursive calls.
function collectChildren(fiber, parentId, out, counterRef) {
if (!counterRef) counterRef = { n: 0 };
var child = fiber.child;
while (child) {
var name = getDisplayName(child);
if (name) {
var serialized = serializeFiber(child, parentId);
if (serialized) out.push(serialized);
var serialized = serializeFiber(child, parentId, counterRef.n);
if (serialized) {
out.push(serialized);
counterRef.n += 1;
}
} else {
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
// flatten its children directly into our level — they share parentId.
// flatten its children directly into our level — they share parentId
// AND the visible sibling counter, so a Fragment never resets indices.
fiberMap.set(child, null); // mark as non-selectable
collectChildren(child, parentId, out);
collectChildren(child, parentId, out, counterRef);
}
child = child.sibling;
}
@@ -267,6 +320,142 @@ export function buildProxyFiberHookScript(): string {
return null;
}
// ── Style override sheet (H-7 fix) ───────────────────────────────────────
// CSS attribute selector values are quoted, so "/" and ":" inside the
// nodeId need no escaping. We do escape backslash and double-quote
// defensively in case future ID schemes introduce them.
function escapeAttrValue(s) {
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// CSS property names from the inspector are already lowercase kebab-case.
// Block anything that isn't a CSS-safe identifier so a malicious patch
// payload can't inject rule terminators or other declarations.
function isSafeCssProp(p) {
return typeof p === 'string' && /^-?[a-z][a-z0-9-]*$/.test(p);
}
// Block stylesheet-breaking characters in the value: braces, semicolons,
// angle brackets, backslashes, line breaks, and CSS comment markers. Inline
// setProperty (used for the immediate paint) is already safe — this is
// strictly to keep the override stylesheet text well-formed.
function isSafeCssValue(v) {
if (typeof v !== 'string' || v.length > 500) return false;
if (/[{};<>\\\n\r]/.test(v)) return false;
if (v.indexOf('/*') !== -1 || v.indexOf('*/') !== -1) return false;
return true;
}
function ensureOverrideStyleEl() {
if (overrideStyleEl && overrideStyleEl.parentNode) return overrideStyleEl;
var el = document.getElementById('__om_overrides__');
if (!el) {
el = document.createElement('style');
el.id = '__om_overrides__';
el.setAttribute('data-originmain', 'overrides');
// Append to <head> so it lives past component remounts.
(document.head || document.documentElement).appendChild(el);
}
overrideStyleEl = el;
return el;
}
function tagDomWithIds() {
for (var id in nodeMap) {
if (!Object.prototype.hasOwnProperty.call(nodeMap, id)) continue;
var info = nodeMap[id];
if (!info) continue;
var el = findDomElement(info.fiber);
if (el && typeof el.setAttribute === 'function') {
// Idempotent: only write if the attribute is missing or stale.
if (el.getAttribute('data-om-id') !== id) el.setAttribute('data-om-id', id);
}
}
}
function buildOverrideCss() {
var parts = [];
for (var id in styleOverrides) {
if (!Object.prototype.hasOwnProperty.call(styleOverrides, id)) continue;
var props = styleOverrides[id];
var decls = [];
for (var p in props) {
if (!Object.prototype.hasOwnProperty.call(props, p)) continue;
if (!isSafeCssProp(p) || !isSafeCssValue(props[p])) continue;
decls.push(p + ':' + props[p] + ' !important');
}
if (decls.length) {
parts.push('[data-om-id="' + escapeAttrValue(id) + '"]{' + decls.join(';') + '}');
}
}
for (var pid in childrenStyleOverrides) {
if (!Object.prototype.hasOwnProperty.call(childrenStyleOverrides, pid)) continue;
var bySel = childrenStyleOverrides[pid];
for (var sel in bySel) {
if (!Object.prototype.hasOwnProperty.call(bySel, sel)) continue;
if (!/^[a-z][a-z0-9-]*$/i.test(sel)) continue;
var cprops = bySel[sel];
var cdecls = [];
for (var cp in cprops) {
if (!Object.prototype.hasOwnProperty.call(cprops, cp)) continue;
if (!isSafeCssProp(cp) || !isSafeCssValue(cprops[cp])) continue;
cdecls.push(cp + ':' + cprops[cp] + ' !important');
}
if (cdecls.length) {
parts.push(
'[data-om-id="' + escapeAttrValue(pid) + '"]>' + sel +
'{' + cdecls.join(';') + '}'
);
}
}
}
return parts.join('\n');
}
function reapplyOverrides() {
var hasAny = false;
for (var k in styleOverrides) { hasAny = true; break; }
if (!hasAny) {
for (var k2 in childrenStyleOverrides) { hasAny = true; break; }
}
// Cheap exit only when nothing has ever been recorded AND no sheet exists.
// If the sheet exists with stale rules (e.g. user just cleared the last
// override), we must run through to write the empty CSS back.
if (!hasAny && !overrideStyleEl) return;
if (hasAny) tagDomWithIds();
var el = ensureOverrideStyleEl();
var css = hasAny ? buildOverrideCss() : '';
if (el.textContent !== css) el.textContent = css;
}
function recordStyleOverride(nodeId, property, value) {
if (!isSafeCssProp(property)) return;
var bag = styleOverrides[nodeId];
if (!bag) bag = styleOverrides[nodeId] = {};
if (value === '' || value == null) {
delete bag[property];
var hasAnyProp = false;
for (var p in bag) { hasAnyProp = true; break; }
if (!hasAnyProp) delete styleOverrides[nodeId];
} else if (isSafeCssValue(String(value))) {
bag[property] = String(value);
}
}
function recordChildrenStyleOverride(parentId, selector, property, value) {
if (!/^[a-z][a-z0-9-]*$/i.test(selector)) return;
if (!isSafeCssProp(property)) return;
var bySel = childrenStyleOverrides[parentId];
if (!bySel) bySel = childrenStyleOverrides[parentId] = {};
var bag = bySel[selector];
if (!bag) bag = bySel[selector] = {};
if (value === '' || value == null) {
delete bag[property];
} else if (isSafeCssValue(String(value))) {
bag[property] = String(value);
}
}
function serializeProps(props) {
if (!props || typeof props !== 'object') return {};
var out = {};
@@ -415,26 +604,85 @@ export function buildProxyFiberHookScript(): string {
var styleProps = [
'width','height','background-color','color','font-size',
'font-family','font-weight','line-height','letter-spacing','text-align',
'display','flex-direction','gap','align-items','justify-content',
'display','flex-direction','flex-wrap','gap','row-gap','column-gap',
'align-items','align-content','justify-content','justify-items',
'padding-top','padding-right','padding-bottom','padding-left',
'margin-top','margin-right','margin-bottom','margin-left',
'border-radius','border-width','border-color','border-style',
'opacity','box-shadow','overflow','position',
'border-radius','border-top-left-radius','border-top-right-radius',
'border-bottom-right-radius','border-bottom-left-radius',
'border-width','border-color','border-style',
'opacity','box-shadow','filter','backdrop-filter',
'overflow','position','left','top','right','bottom',
'transform','text-decoration','text-transform',
'grid-template-columns','grid-template-rows',
];
var styles = {};
styleProps.forEach(function(p) { styles[p] = cs.getPropertyValue(p); });
post({ type: 'ELEMENT_STYLES', nodeId: msg.nodeId, styles: styles });
// ── Structural flags for Typography section (Phase 2) ──────────
// hasDirectText: does the element have a direct text node with content?
var hasDirectText = false;
var nodes = rEl.childNodes;
for (var ci = 0; ci < nodes.length; ci++) {
if (nodes[ci].nodeType === 3 && nodes[ci].textContent.trim().length > 0) {
hasDirectText = true;
break;
}
}
// hasParagraphChildren: does the element have at least one direct <p> child?
var hasParagraphChildren = rEl.querySelector(':scope > p') !== null;
post({
type: 'ELEMENT_STYLES',
nodeId: msg.nodeId,
styles: styles,
hasDirectText: hasDirectText,
hasParagraphChildren: hasParagraphChildren,
});
}
}
}
break;
case 'PATCH_ELEMENT_STYLE':
if (typeof msg.nodeId === 'string' && typeof msg.property === 'string') {
// C-4 fix: (msg.value || '') is falsy for 0, which removes the property.
// Use explicit null check so zero values are applied correctly.
var pVal = msg.value != null ? String(msg.value) : '';
// H-7 fix: record into the override stylesheet FIRST so the patch
// survives the next React reconciliation. Then also apply inline
// for an immediate, no-flicker visual update on this paint.
recordStyleOverride(msg.nodeId, msg.property, pVal);
var pInfo = nodeMap[msg.nodeId];
if (pInfo) {
var pEl = findDomElement(pInfo.fiber);
if (pEl) pEl.style.setProperty(msg.property, String(msg.value || ''));
if (pEl) pEl.style.setProperty(msg.property, pVal);
}
reapplyOverrides();
}
break;
case 'PATCH_CHILDREN_STYLE':
// Apply a CSS property to all matching direct children of a node.
// Used for paragraph-spacing: patches margin-bottom on each direct <p> child.
if (typeof msg.parentNodeId === 'string' && typeof msg.property === 'string') {
if (typeof msg.selector !== 'string') break;
// C-5 security fix: validate selector against a simple element-name
// allowlist before passing to querySelectorAll. An unconstrained
// msg.selector could be crafted to inject CSS or throw a DOMException.
if (!/^[a-z][a-z0-9-]*$/i.test(msg.selector)) break;
var pcVal = msg.value != null ? String(msg.value) : '';
// H-7 fix: persist children-style patches the same way.
recordChildrenStyleOverride(msg.parentNodeId, msg.selector, msg.property, pcVal);
var pcInfo = nodeMap[msg.parentNodeId];
if (pcInfo) {
var pcEl = findDomElement(pcInfo.fiber);
if (pcEl) {
var children = pcEl.querySelectorAll(':scope > ' + msg.selector);
for (var ci2 = 0; ci2 < children.length; ci2++) {
children[ci2].style.setProperty(msg.property, pcVal);
}
}
}
reapplyOverrides();
}
break;
case 'REMOVE_ELEMENT':
@@ -500,8 +748,9 @@ export function buildProxyFiberHookScript(): string {
if (!path || typeof path !== 'string') return;
// Skip hash fragments, external links that slipped through, and dynamic segments
if (path.charAt(0) !== '/') return;
// Normalise: strip trailing slash except for root
var norm = path.length > 1 ? path.replace(/\\/+$/, '') : '/';
// M-4 fix: the original regex /\\/+$/ matched a literal backslash, not a
// forward slash. Correct regex is /\/+$/.
var norm = path.length > 1 ? path.replace(/\/+$/, '') : '/';
if (seen[norm]) return;
seen[norm] = true;
var label = (hint && typeof hint === 'string' && hint.trim().slice(0, 50)) || humanLabel(norm);
@@ -512,29 +761,33 @@ export function buildProxyFiberHookScript(): string {
addRoute(window.location.pathname, document.title || undefined);
// ② Next.js Pages Router — __NEXT_DATA__ contains the full page list in build id manifest
// H-1 fix: replaced synchronous XHR (deprecated, blocks main thread) with
// async fetch. Route discovery happens in a setTimeout callback so async is safe.
(function() {
try {
var nextData = window.__NEXT_DATA__;
if (nextData && nextData.buildId) {
// Fetch the pages manifest — available at /_next/static/{buildId}/_buildManifest.js
var manifestUrl = '/_next/static/' + nextData.buildId + '/_buildManifest.js';
var xhr = new XMLHttpRequest();
xhr.open('GET', manifestUrl, false); // sync — runs at init time before user interaction
xhr.send();
if (xhr.status === 200) {
// Manifest exposes self.__BUILD_MANIFEST = { sortedPages: [...] }
var match = xhr.responseText.match(/sortedPages\\s*:\\s*(\\[[^\\]]+\\])/);
fetch(manifestUrl).then(function(res) {
if (!res.ok) return;
return res.text();
}).then(function(text) {
if (!text) return;
var match = text.match(/sortedPages\s*:\s*(\[[^\]]+\])/);
if (match) {
try {
var pages = JSON.parse(match[1]);
pages.forEach(function(p) {
// Skip catch-all and dynamic segments for now; static routes only
if (p.indexOf('[') === -1) addRoute(p);
});
// Re-post now that we have more routes from the manifest.
post({ type: 'ROUTES_DISCOVERED', routes: routes.slice() });
} catch (e) { /* parse failed */ }
}
}).catch(function() { /* manifest unavailable */ });
}
}
} catch (e) { /* Next.js not present or manifest unavailable */ }
} catch (e) { /* Next.js not present */ }
})();
// ③ <a href> same-origin links from the rendered DOM
var anchors = document.querySelectorAll('a[href]');
+22 -2
View File
@@ -13,6 +13,14 @@ export interface FiberNode {
props: Record<string, unknown>;
children: FiberNode[];
domRect?: DOMRectLike;
/** JSX call site: the file + line where <ComponentName /> was written (not its definition).
* Only present in React dev builds (__DEV__ = true). Absent in production.
* Renamed from `sourceFile` to `callSite` for accuracy see Phase 1 §4.2. */
callSite?: {
fileName: string;
lineNumber: number;
columnNumber?: number;
};
}
export interface DOMRectLike {
@@ -34,6 +42,9 @@ export type HostMessage =
/** Apply a single CSS property override directly to the component's DOM element.
* Non-destructive sets inline style only; source files are unchanged. */
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
/** Apply a CSS property to all matching direct children of a node.
* Used for paragraph-spacing: patches margin-bottom on each direct <p> child. */
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
/** Hide a component's DOM element (sets display:none). Non-destructive. */
| { type: 'REMOVE_ELEMENT'; nodeId: string };
@@ -51,8 +62,17 @@ export type RendererMessage =
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
| { type: 'COMPONENT_DESELECTED' }
| { type: 'ERROR'; message: string }
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }
/** Response to REQUEST_ELEMENT_STYLES computed CSS properties for the node.
* Also includes structural flags used by the Typography section of the Design Panel. */
| {
type: 'ELEMENT_STYLES';
nodeId: string;
styles: Record<string, string>;
/** true if the DOM element has a direct TEXT_NODE child with non-whitespace content */
hasDirectText: boolean;
/** true if the DOM element has at least one direct <p> child */
hasParagraphChildren: boolean;
}
/** All discoverable routes found in the running app sent once after READY
* and again after each SPA navigation. */
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
+21
View File
@@ -151,6 +151,13 @@ importers:
version: 5.9.3
packages/cli:
dependencies:
chokidar:
specifier: ^5.0.0
version: 5.0.0
tinyglobby:
specifier: ^0.2.0
version: 0.2.16
devDependencies:
'@originmain/renderer':
specifier: workspace:*
@@ -1978,6 +1985,10 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -2479,6 +2490,10 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -4888,6 +4903,10 @@ snapshots:
check-error@2.1.3: {}
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
client-only@0.0.1: {}
color-convert@2.0.1:
@@ -5420,6 +5439,8 @@ snapshots:
react@19.2.5: {}
readdirp@5.0.0: {}
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0