made tiny updates
This commit is contained in:
+57
-14
@@ -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/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/store/canvas.ts` | Add `canvasTransform`, `artboardFrames`, `createArtboard`, `deleteArtboard`, `updateArtboardPosition`, `setArtboardDevicePreset` |
|
||||||
| `packages/app/src/hooks/useArtboards.ts` | Extend to load new schema fields |
|
| `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 |
|
| 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.
|
**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`:**
|
**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.
|
- 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
|
- 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`.
|
- 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).
|
`--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
|
### 6.6 Files to Create / Change
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
@@ -1369,7 +1377,7 @@ The `CodeTabFooter` shows:
|
|||||||
- `status === 'IMPLEMENTED'`: green "✓ Applied" badge
|
- `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: '...'`)
|
- `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
|
### 7.5 Files to Create / Change
|
||||||
|
|
||||||
@@ -1498,7 +1506,7 @@ originmain dev starts
|
|||||||
|
|
||||||
Claude Code sessions receive this immediately after `push_intent` is called.
|
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:
|
`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.
|
`update_diff_status` updates the `status` column.
|
||||||
`get_pending_diffs` queries `WHERE status = 'EXPORTED'`.
|
`get_pending_diffs` queries `WHERE status = 'EXPORTED'`.
|
||||||
|
|
||||||
### 8.5 Files to Create / Change
|
### 8.6 Files to Create / Change
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -1629,7 +1637,16 @@ interface DesignToken {
|
|||||||
- Format A/B: `{ "color": { "primary": ... } }` → `--color-primary`
|
- Format A/B: `{ "color": { "primary": ... } }` → `--color-primary`
|
||||||
- Format C: key is already the CSS custom property name (used as-is)
|
- Format C: key is already the CSS custom property name (used as-is)
|
||||||
- Nested paths: `{ "color": { "brand": { "500": ... } } }` → `--color-brand-500`
|
- 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:**
|
**Human label derivation:**
|
||||||
Path segments joined with ` / `: `"color" + "primary"` → `"Color / Primary"`.
|
Path segments joined with ` / `: `"color" + "primary"` → `"Color / Primary"`.
|
||||||
@@ -1835,12 +1852,13 @@ CREATE TABLE design_languages (
|
|||||||
-- Version history: keep the last 10 versions
|
-- Version history: keep the last 10 versions
|
||||||
CREATE TABLE design_language_versions (
|
CREATE TABLE design_language_versions (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
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,
|
version integer NOT NULL,
|
||||||
raw_json jsonb NOT NULL,
|
raw_json jsonb NOT NULL,
|
||||||
normalized jsonb NOT NULL,
|
normalized jsonb NOT NULL,
|
||||||
source_format text 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
|
-- 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 │
|
│ │ │ FIBER_TREE_UPDATE │
|
||||||
│ │◄───────────────────────────────────│
|
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
|
||||||
│ │ │ user clicks │
|
│ │ │ user clicks │
|
||||||
│ │ COMPONENT_SELECTED + ELEMENT_STYLES│
|
│ │ COMPONENT_SELECTED + ELEMENT_STYLES│
|
||||||
│ │◄───────────────────────────────────│
|
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
|
||||||
│ │ │ panel renders, │
|
│ │ │ panel renders, │
|
||||||
│ │ │ deviation check │
|
│ │ │ deviation check │
|
||||||
│ │ │ against tokens │
|
│ │ │ against tokens │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ user adjusts │
|
│ │ │ user adjusts │
|
||||||
│ │ PATCH_ELEMENT_STYLE (DOM preview) │
|
│ │◄───────────────────────────────────│ PATCH_ELEMENT_STYLE (DOM preview)
|
||||||
│ │───────────────────────────────────►│
|
│ │ │ │ (postMessage: canvas→iframe)
|
||||||
│ │ │ "Preview Code" │
|
│ │ │ "Preview Code" │
|
||||||
│ │ │ GET /file?path=… │
|
│ │ │ GET /file?path=… │
|
||||||
│ │ │◄──────────────────│
|
│ │ │◄──────────────────│
|
||||||
@@ -2002,7 +2020,7 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
|
|||||||
│ hot reload │ │ │
|
│ hot reload │ │ │
|
||||||
│◄──────────────────│ │ │
|
│◄──────────────────│ │ │
|
||||||
│ │ FIBER_TREE_UPDATE (post-edit) │
|
│ │ FIBER_TREE_UPDATE (post-edit) │
|
||||||
│ │◄───────────────────────────────────│
|
│ │───────────────────────────────────►│ (postMessage: iframe→canvas)
|
||||||
│ │ │ update_diff_status(IMPLEMENTED)
|
│ │ │ update_diff_status(IMPLEMENTED)
|
||||||
│ │ │ │◄─────────────────│
|
│ │ │ │◄─────────────────│
|
||||||
│ │ │ canvas shows ✓ │
|
│ │ │ 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
|
## 11. Implementation Phases — Summary
|
||||||
|
|
||||||
| Phase | Name | Duration | Depends on | Milestone |
|
| 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 | 5–7 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) |
|
| 6 | Design Language System | 5–7 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 |
|
| 7 | E2E Validation | 2 days | all | Full flow: select → edit → preview diff → send → agent applies → hot reload confirms |
|
||||||
|
|
||||||
**Total:** ~25–33 engineering days across all phases.
|
**Total:** ~25–35 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) {
|
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
|
||||||
const {
|
const {
|
||||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
|
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
} = useCanvas();
|
} = useCanvas();
|
||||||
const selected = selectedArtboardId === id;
|
const selected = selectedArtboardId === id;
|
||||||
@@ -81,14 +81,20 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
selectComponent(nodeId, node ?? null);
|
selectComponent(nodeId, node ?? null);
|
||||||
}, [localFiberRoot, selectComponent, setComponentStyles]);
|
}, [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
|
// Guard against stale responses arriving after the user has already clicked
|
||||||
// a different component — only apply if artboard and node both still match.
|
// a different component — only apply if artboard and node both still match.
|
||||||
const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState();
|
const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState();
|
||||||
if (currentArtboard === id && currentId === nodeId) {
|
if (currentArtboard === id && currentId === nodeId) {
|
||||||
setComponentStyles(styles);
|
setComponentStyles(styles);
|
||||||
|
setComponentTextFlags(hasDirectText, hasParagraphChildren);
|
||||||
}
|
}
|
||||||
}, [id, setComponentStyles]);
|
}, [id, setComponentStyles, setComponentTextFlags]);
|
||||||
|
|
||||||
// ── Drag to reposition ─────────────────────────────────────────────────────
|
// ── Drag to reposition ─────────────────────────────────────────────────────
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
@@ -144,8 +150,14 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
fetch(`/api/artboards/${id}`, {
|
fetch(`/api/artboards/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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({
|
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(() => {
|
}).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export interface LiveArtboardProps {
|
|||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
onFiberTreeUpdate?: (root: FiberNode) => void;
|
onFiberTreeUpdate?: (root: FiberNode) => void;
|
||||||
onComponentSelected?: (nodeId: string) => void;
|
onComponentSelected?: (nodeId: string) => void;
|
||||||
/** Called when the iframe responds with computed CSS properties for a selected element. */
|
/** Called when the iframe responds with computed CSS properties for a selected element.
|
||||||
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
|
* 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. */
|
/** Called when the iframe discovers routes in the running app. */
|
||||||
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
|
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
|
||||||
/** Called when READY fires but no React commits arrive within 4 s — signals a
|
/** Called when READY fires but no React commits arrive within 4 s — signals a
|
||||||
@@ -140,7 +141,7 @@ export function LiveArtboard({
|
|||||||
onComponentSelected?.('');
|
onComponentSelected?.('');
|
||||||
break;
|
break;
|
||||||
case 'ELEMENT_STYLES':
|
case 'ELEMENT_STYLES':
|
||||||
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
|
onComponentStylesUpdate?.(msg.nodeId, msg.styles, msg.hasDirectText, msg.hasParagraphChildren);
|
||||||
break;
|
break;
|
||||||
case 'ROUTES_DISCOVERED':
|
case 'ROUTES_DISCOVERED':
|
||||||
onRoutesDiscovered?.(msg.routes);
|
onRoutesDiscovered?.(msg.routes);
|
||||||
@@ -164,6 +165,8 @@ export function LiveArtboard({
|
|||||||
// simultaneous width + height patches from resize are both delivered.
|
// simultaneous width + height patches from resize are both delivered.
|
||||||
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
|
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
|
||||||
const clearStyleEdits = useCanvas((s) => s.clearStyleEdits);
|
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 removeElementEvent = useCanvas((s) => s.removeElementEvent);
|
||||||
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
|
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
|
||||||
|
|
||||||
@@ -176,6 +179,16 @@ export function LiveArtboard({
|
|||||||
clearStyleEdits(id);
|
clearStyleEdits(id);
|
||||||
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
|
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
|
||||||
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
|
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useHistory } from '@/store/history';
|
import { useHistory } from '@/store/history';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
|
import { useViewport } from '@/store/viewport';
|
||||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||||
import type { PropChange } from '@originmain/diff-engine';
|
import type { PropChange } from '@originmain/diff-engine';
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ export function SelectionOverlay({
|
|||||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
const pushEdit = useHistory(s => s.pushEdit);
|
const pushEdit = useHistory(s => s.pushEdit);
|
||||||
const { dispatchRemoveElement } = useCanvas();
|
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(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -48,8 +54,9 @@ export function SelectionOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
const clickX = e.clientX - overlayRect.left;
|
// Convert screen-space coords to iframe-space by dividing by zoom.
|
||||||
const clickY = e.clientY - overlayRect.top;
|
const clickX = (e.clientX - overlayRect.left) / zoom;
|
||||||
|
const clickY = (e.clientY - overlayRect.top) / zoom;
|
||||||
|
|
||||||
const hit = hitTestFiber(fiberRoot, clickX, clickY);
|
const hit = hitTestFiber(fiberRoot, clickX, clickY);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
@@ -61,19 +68,19 @@ export function SelectionOverlay({
|
|||||||
onSelectionChange?.(null);
|
onSelectionChange?.(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fiberRoot, onSelectionChange]
|
[fiberRoot, onSelectionChange, zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!fiberRoot) return;
|
if (!fiberRoot) return;
|
||||||
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
const x = e.clientX - overlayRect.left;
|
const x = (e.clientX - overlayRect.left) / zoom;
|
||||||
const y = e.clientY - overlayRect.top;
|
const y = (e.clientY - overlayRect.top) / zoom;
|
||||||
const hit = hitTestFiber(fiberRoot, x, y);
|
const hit = hitTestFiber(fiberRoot, x, y);
|
||||||
setHoveredId(hit?.id ?? null);
|
setHoveredId(hit?.id ?? null);
|
||||||
},
|
},
|
||||||
[fiberRoot]
|
[fiberRoot, zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useViewport } from '@/store/viewport';
|
|||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { useWalkthrough } from '@/store/walkthrough';
|
import { useWalkthrough } from '@/store/walkthrough';
|
||||||
|
import { useIndexer } from '@/hooks/useIndexer';
|
||||||
|
|
||||||
interface AppChromeProps {
|
interface AppChromeProps {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -29,6 +30,9 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
const CT = useCanvasTheme();
|
const CT = useCanvasTheme();
|
||||||
const startTour = useWalkthrough((s) => s.start);
|
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.
|
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspaceId && projectId) setContext(workspaceId, projectId);
|
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 { PropChange } from '@originmain/diff-engine';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
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> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
s: '#7DD3A8',
|
s: '#7DD3A8',
|
||||||
@@ -99,6 +107,7 @@ export function Inspector() {
|
|||||||
<DesignTab
|
<DesignTab
|
||||||
artboardId={selectedArtboardId}
|
artboardId={selectedArtboardId}
|
||||||
componentId={selectedComponentId}
|
componentId={selectedComponentId}
|
||||||
|
componentData={selectedComponentData}
|
||||||
styles={selectedComponentStyles}
|
styles={selectedComponentStyles}
|
||||||
/>
|
/>
|
||||||
) : tab === 'props' ? (
|
) : tab === 'props' ? (
|
||||||
@@ -523,14 +532,16 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
|
|||||||
function DesignTab({
|
function DesignTab({
|
||||||
artboardId,
|
artboardId,
|
||||||
componentId,
|
componentId,
|
||||||
|
componentData,
|
||||||
styles,
|
styles,
|
||||||
}: {
|
}: {
|
||||||
artboardId: string | null;
|
artboardId: string | null;
|
||||||
componentId: string | null;
|
componentId: string | null;
|
||||||
|
componentData: FiberNode | null;
|
||||||
styles: Record<string, string> | null;
|
styles: Record<string, string> | null;
|
||||||
}) {
|
}) {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
const { patchStyleEdit } = useCanvas();
|
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas();
|
||||||
|
|
||||||
if (!artboardId) {
|
if (!artboardId) {
|
||||||
return (
|
return (
|
||||||
@@ -571,239 +582,98 @@ function DesignTab({
|
|||||||
patchStyleEdit(artboardId, componentId, prop, val);
|
patchStyleEdit(artboardId, componentId, prop, val);
|
||||||
};
|
};
|
||||||
|
|
||||||
const s = styles;
|
const patchChildren = (selector: string, prop: string, val: string) => {
|
||||||
const hasFill = !isTransparent(s['background-color']);
|
if (!artboardId || !componentId) return;
|
||||||
const hasTextColor = !!s['color'] && !isTransparent(s['color']);
|
patchChildrenStyleEdit(artboardId, componentId, selector, prop, val);
|
||||||
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');
|
// ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Dimensions ────────────────────────────────────── */}
|
{/* ── Component identity header ────────────────────────────── */}
|
||||||
<div style={{ padding: '10px 14px 8px' }}>
|
<div style={{
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
|
padding: '10px 14px 8px',
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
borderBottom: `1px solid ${T.sep}`,
|
||||||
<FieldLabel>W</FieldLabel>
|
display: 'flex',
|
||||||
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
|
flexDirection: 'column',
|
||||||
</div>
|
gap: 4,
|
||||||
<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>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
|
{/* Component name */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
|
<span style={{
|
||||||
<FieldLabel>A%</FieldLabel>
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
<NumInput
|
fontSize: '0.6875rem',
|
||||||
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
|
fontWeight: 600,
|
||||||
propKey="_bgAlpha"
|
color: T.fg,
|
||||||
onPatch={(_, v) => {
|
letterSpacing: '-0.01em',
|
||||||
const pct = parseFloat(v.replace('%', ''));
|
flex: 1,
|
||||||
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
|
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>
|
{/* Call-site breadcrumb — "used in app/page.tsx:34" */}
|
||||||
</div>
|
{callSiteLabel && (
|
||||||
<HSep />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Letter-spacing + text-align toggles */}
|
{/* ── Section components ───────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
<FrameSection styles={styles} onPatch={patch} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<ConstraintsSection styles={styles} onPatch={patch} />
|
||||||
<FieldLabel>Track</FieldLabel>
|
<LayoutSection styles={styles} onPatch={patch} />
|
||||||
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
|
<FillSection styles={styles} onPatch={patch} />
|
||||||
</div>
|
<StrokeSection styles={styles} onPatch={patch} />
|
||||||
<TextAlignToggle
|
<TypographySection
|
||||||
value={s['text-align'] ?? 'left'}
|
styles={styles}
|
||||||
onPatch={(v) => patch('text-align', v)}
|
hasDirectText={selectedComponentHasDirectText}
|
||||||
/>
|
hasParagraphChildren={selectedComponentHasParagraphChildren}
|
||||||
</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' },
|
|
||||||
]}
|
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
|
onPatchChildren={patchChildren}
|
||||||
/>
|
/>
|
||||||
</div>
|
<EffectsSection styles={styles} onPatch={patch} />
|
||||||
|
<BoxModelSection styles={styles} onPatch={patch} />
|
||||||
{/* 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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <p> children)</FieldLabel>
|
||||||
|
<NumInput
|
||||||
|
value={styles['margin-bottom'] ?? '0px'}
|
||||||
|
propKey="p-margin-bottom"
|
||||||
|
onPatch={(_, val) => onPatchChildren('p', 'margin-bottom', val)}
|
||||||
|
inputWidth={72}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<HSep />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
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';
|
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
|
||||||
|
|
||||||
interface CanvasStore {
|
interface CanvasStore {
|
||||||
@@ -36,6 +44,13 @@ interface CanvasStore {
|
|||||||
selectedComponentStyles: Record<string, string> | null;
|
selectedComponentStyles: Record<string, string> | null;
|
||||||
setComponentStyles: (styles: Record<string, string> | null) => void;
|
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 ────────────────────────────────────────────────────────
|
// ── Style edit queue ────────────────────────────────────────────────────────
|
||||||
// The Design tab and resize handles push patches here; each owning
|
// The Design tab and resize handles push patches here; each owning
|
||||||
// LiveArtboard drains entries addressed to it, then removes them.
|
// 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;
|
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
|
||||||
clearStyleEdits: (artboardId: 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 ─────────────────────────────────────────────────
|
// ── Element removal mailbox ─────────────────────────────────────────────────
|
||||||
removeElementEvent: { artboardId: string; nodeId: string } | null;
|
removeElementEvent: { artboardId: string; nodeId: string } | null;
|
||||||
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
|
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
|
||||||
clearRemoveElement: () => 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) => ({
|
export const useCanvas = create<CanvasStore>((set) => ({
|
||||||
@@ -78,19 +112,47 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
|||||||
selectedComponentId: null,
|
selectedComponentId: null,
|
||||||
selectedComponentData: null,
|
selectedComponentData: null,
|
||||||
selectComponent: (id, data) =>
|
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,
|
selectedComponentStyles: null,
|
||||||
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
|
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
|
||||||
|
|
||||||
|
selectedComponentHasDirectText: false,
|
||||||
|
selectedComponentHasParagraphChildren: false,
|
||||||
|
setComponentTextFlags: (hasDirectText, hasParagraphChildren) =>
|
||||||
|
set({ selectedComponentHasDirectText: hasDirectText, selectedComponentHasParagraphChildren: hasParagraphChildren }),
|
||||||
|
|
||||||
styleEditQueue: [],
|
styleEditQueue: [],
|
||||||
patchStyleEdit: (artboardId, nodeId, property, value) =>
|
patchStyleEdit: (artboardId, nodeId, property, value) =>
|
||||||
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
|
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
|
||||||
clearStyleEdits: (artboardId) =>
|
clearStyleEdits: (artboardId) =>
|
||||||
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== 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,
|
removeElementEvent: null,
|
||||||
dispatchRemoveElement: (artboardId, nodeId) =>
|
dispatchRemoveElement: (artboardId, nodeId) =>
|
||||||
set({ removeElementEvent: { artboardId, nodeId } }),
|
set({ removeElementEvent: { artboardId, nodeId } }),
|
||||||
clearRemoveElement: () => set({ removeElementEvent: null }),
|
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
@@ -22,7 +22,10 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "node build.mjs"
|
"build": "node build.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"tinyglobby": "^0.2.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@originmain/renderer": "workspace:*",
|
"@originmain/renderer": "workspace:*",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
@@ -30,7 +33,7 @@
|
|||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
+78
-18
@@ -3,40 +3,53 @@
|
|||||||
// ── Originmain CLI ───────────────────────────────────────────────────────────
|
// ── Originmain CLI ───────────────────────────────────────────────────────────
|
||||||
// Usage:
|
// Usage:
|
||||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
// 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
|
// Starts a reverse proxy + optional AST indexer for live React component
|
||||||
// in Originmain artboards. See RENDERING-ARCHITECTURE.md for details.
|
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
|
||||||
|
|
||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { startProxy } from './proxy.js';
|
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_PORT = 4170;
|
||||||
|
const DEFAULT_INDEX_PORT = 4171;
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`
|
console.log(`
|
||||||
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
originmain dev --target <url> [--port <number>]
|
originmain dev --target <url> [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--target, -t Target dev server URL (required)
|
--target, -t Target dev server URL (required)
|
||||||
Example: http://localhost:3000
|
Example: http://localhost:3000
|
||||||
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
|
--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
|
--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
|
||||||
|
npx @originmain/cli dev --target http://localhost:3000 --no-index
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): void {
|
async function main(): Promise<void> {
|
||||||
// Parse arguments
|
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
options: {
|
options: {
|
||||||
target: { type: 'string', short: 't' },
|
target: { type: 'string', short: 't' },
|
||||||
port: { type: 'string', short: 'p' },
|
port: { type: 'string', short: 'p' },
|
||||||
|
'index-port': { type: 'string' },
|
||||||
|
'no-index': { type: 'boolean' },
|
||||||
help: { type: 'boolean', short: 'h' },
|
help: { type: 'boolean', short: 'h' },
|
||||||
},
|
},
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -47,7 +60,6 @@ function main(): void {
|
|||||||
|
|
||||||
if (values.help || !command) {
|
if (values.help || !command) {
|
||||||
printUsage();
|
printUsage();
|
||||||
// --help is a success; missing command is a usage error.
|
|
||||||
process.exit(values.help ? 0 : 1);
|
process.exit(values.help ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,18 +68,16 @@ function main(): void {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate --target
|
// ── Validate --target ─────────────────────────────────────────────────────
|
||||||
const target = values.target;
|
const target = values.target;
|
||||||
if (!target || typeof target !== 'string') {
|
if (!target || typeof target !== 'string') {
|
||||||
console.error(' Error: --target is required.\n Example: originmain dev --target http://localhost:3000');
|
console.error(' Error: --target is required.\n Example: originmain dev --target http://localhost:3000');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate target URL
|
|
||||||
let targetUrl: URL;
|
let targetUrl: URL;
|
||||||
try {
|
try { targetUrl = new URL(target); }
|
||||||
targetUrl = new URL(target);
|
catch {
|
||||||
} catch {
|
|
||||||
console.error(` Error: Invalid target URL: ${target}`);
|
console.error(` Error: Invalid target URL: ${target}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -77,20 +87,67 @@ function main(): void {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse port
|
// ── Parse ports ───────────────────────────────────────────────────────────
|
||||||
const port = values.port ? parseInt(values.port as string, 10) : DEFAULT_PORT;
|
const port = values.port ? parseInt(values.port as string, 10) : DEFAULT_PORT;
|
||||||
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||||
console.error(` Error: Invalid port: ${values.port}`);
|
console.error(` Error: Invalid port: ${values.port}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start proxy
|
const indexPortRaw = values['index-port'] as string | undefined;
|
||||||
const proxy = startProxy({ target, port });
|
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 {
|
function shutdown(): void {
|
||||||
console.log('\n Shutting down proxy...');
|
console.log('\n Shutting down...');
|
||||||
proxy.close();
|
proxy.close();
|
||||||
|
indexServer?.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,4 +155,7 @@ function main(): void {
|
|||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main().catch((err: unknown) => {
|
||||||
|
console.error(' Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -1,28 +1,40 @@
|
|||||||
// ── HTML Injection ───────────────────────────────────────────────────────────
|
// -- HTML Injection ----------------------------------------------------------
|
||||||
// Injects the Originmain fiber hook <script> into an HTML response body.
|
// Injects the Originmain fiber hook <script> into an HTML response body.
|
||||||
// The script must appear BEFORE any other scripts so that
|
// The script must appear BEFORE any other scripts so that
|
||||||
// __REACT_DEVTOOLS_GLOBAL_HOOK__ is installed before React evaluates.
|
// __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';
|
import { buildProxyFiberHookScript } from '@originmain/renderer';
|
||||||
|
|
||||||
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
|
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
|
||||||
let cachedScriptTag: string | undefined;
|
let cachedFiberTag: string | undefined;
|
||||||
|
|
||||||
function getScriptTag(): string {
|
function getFiberTag(): string {
|
||||||
if (cachedScriptTag === undefined) {
|
if (cachedFiberTag === undefined) {
|
||||||
cachedScriptTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
|
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.
|
* 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 {
|
function stripMetaCsp(html: string): string {
|
||||||
return html.replace(
|
return html.replace(
|
||||||
@@ -32,34 +44,26 @@ function stripMetaCsp(html: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject the fiber hook script into an HTML string.
|
* Inject the fiber hook + bridge config scripts 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.
|
|
||||||
*/
|
*/
|
||||||
export function injectFiberHook(html: string): string {
|
export function injectFiberHook(html: string, indexUrl?: string | null): string {
|
||||||
const tag = getScriptTag();
|
const injection = getBridgeConfigTag(indexUrl) + getFiberTag();
|
||||||
const cleaned = stripMetaCsp(html);
|
const cleaned = stripMetaCsp(html);
|
||||||
|
|
||||||
// Try after <head>
|
// Try after <head>
|
||||||
const headMatch = /<head[^>]*>/i.exec(cleaned);
|
const headMatch = cleaned.match(/<head[^>]*>/i);
|
||||||
if (headMatch) {
|
if (headMatch?.index !== undefined) {
|
||||||
const insertAt = headMatch.index + headMatch[0].length;
|
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>
|
// Try after <html>
|
||||||
const htmlMatch = /<html[^>]*>/i.exec(cleaned);
|
const htmlMatch = cleaned.match(/<html[^>]*>/i);
|
||||||
if (htmlMatch) {
|
if (htmlMatch?.index !== undefined) {
|
||||||
const insertAt = htmlMatch.index + htmlMatch[0].length;
|
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
|
// Final fallback: prepend
|
||||||
return tag + cleaned;
|
return injection + cleaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,12 +15,15 @@ import type { IncomingMessage, ServerResponse,
|
|||||||
RequestOptions, ClientRequest } from 'node:http';
|
RequestOptions, ClientRequest } from 'node:http';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
import { injectFiberHook } from './inject.js';
|
import { injectFiberHook } from './inject.js';
|
||||||
|
import { handleIsolationRequest } from './isolation-server.js';
|
||||||
|
|
||||||
export interface ProxyOptions {
|
export interface ProxyOptions {
|
||||||
/** Target dev server URL, e.g. "http://localhost:3000" */
|
/** Target dev server URL, e.g. "http://localhost:3000" */
|
||||||
target: string;
|
target: string;
|
||||||
/** Port for the proxy to listen on (default: 4170) */
|
/** Port for the proxy to listen on (default: 4170) */
|
||||||
port: number;
|
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). */
|
/** 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 targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10);
|
||||||
|
|
||||||
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
|
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 ────────────────────────────────
|
// ── Build the outgoing request headers ────────────────────────────────
|
||||||
|
|
||||||
const outHeaders: Record<string, string | string[]> = {};
|
const outHeaders: Record<string, string | string[]> = {};
|
||||||
@@ -160,7 +169,7 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
|||||||
|
|
||||||
proxyRes.on('end', () => {
|
proxyRes.on('end', () => {
|
||||||
const rawHtml = Buffer.concat(chunks).toString('utf-8');
|
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');
|
const body = Buffer.from(injectedHtml, 'utf-8');
|
||||||
|
|
||||||
// Correct Content-Length and drop Transfer-Encoding: chunked.
|
// Correct Content-Length and drop Transfer-Encoding: chunked.
|
||||||
@@ -249,13 +258,18 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
|||||||
|
|
||||||
// ── Start listening ───────────────────────────────────────────────────────
|
// ── 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}`;
|
const proxyUrl = `http://localhost:${opts.port}`;
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' \x1b[36m\x1b[1mOriginmain\x1b[0m proxy running');
|
console.log(' \x1b[36m\x1b[1mOriginmain\x1b[0m proxy running');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(` Target: ${opts.target}`);
|
console.log(` Target: ${opts.target}`);
|
||||||
console.log(` Proxy: \x1b[1m${proxyUrl}\x1b[0m`);
|
console.log(` Proxy: \x1b[1m${proxyUrl}\x1b[0m`);
|
||||||
|
if (opts.indexUrl) {
|
||||||
|
console.log(` Indexer: \x1b[1m${opts.indexUrl}\x1b[0m`);
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' Paste the proxy URL into your Originmain artboard\'s');
|
console.log(' Paste the proxy URL into your Originmain artboard\'s');
|
||||||
console.log(' "Connect app" field to enable live rendering.');
|
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[2mFiber hook injection ........ active\x1b[0m');
|
||||||
console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m');
|
console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m');
|
||||||
console.log(' \x1b[2mWebSocket passthrough ....... 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) {
|
if (isHttps) {
|
||||||
console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m');
|
console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+29
@@ -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[];
|
||||||
|
}
|
||||||
@@ -138,6 +138,22 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
var selectedNodeId = null; // currently highlighted component
|
var selectedNodeId = null; // currently highlighted component
|
||||||
var highlightEl = null; // the blue-ring DOM overlay element
|
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 ────────────────────────────────────────────────────
|
// ── postMessage helper ────────────────────────────────────────────────────
|
||||||
function post(msg) {
|
function post(msg) {
|
||||||
try {
|
try {
|
||||||
@@ -172,6 +188,10 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
fiberMap = new WeakMap();
|
fiberMap = new WeakMap();
|
||||||
|
|
||||||
var tree = serializeFiber(root.current, '');
|
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 });
|
post({ type: 'FIBER_TREE_UPDATE', root: tree });
|
||||||
// Re-sync the highlight ring after each React commit (layout may shift).
|
// Re-sync the highlight ring after each React commit (layout may shift).
|
||||||
if (selectedNodeId) updateHighlight();
|
if (selectedNodeId) updateHighlight();
|
||||||
@@ -183,12 +203,19 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
|
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
|
||||||
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
|
// 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)
|
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
|
||||||
// are transparent — their children are collected directly into their parent's
|
// are transparent — their children are collected directly into their parent's
|
||||||
// children array. Returning only the first named child (the old approach)
|
// children array. Returning only the first named child (the old approach)
|
||||||
// caused entire subtrees to vanish from the tree.
|
// caused entire subtrees to vanish from the tree.
|
||||||
|
|
||||||
function serializeFiber(fiber, parentId) {
|
function serializeFiber(fiber, parentId, siblingIndex) {
|
||||||
if (!fiber) return null;
|
if (!fiber) return null;
|
||||||
var name = getDisplayName(fiber);
|
var name = getDisplayName(fiber);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -202,11 +229,27 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
return { id: '__root__', name: '__root__', props: {}, children: children };
|
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 rect = getDomRect(fiber);
|
||||||
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
|
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
|
||||||
if (rect) node.domRect = rect;
|
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.
|
// Register in both maps for O(1) lookup.
|
||||||
nodeMap[nodeId] = { domRect: rect || null, fiber: fiber };
|
nodeMap[nodeId] = { domRect: rect || null, fiber: fiber };
|
||||||
fiberMap.set(fiber, nodeId);
|
fiberMap.set(fiber, nodeId);
|
||||||
@@ -230,18 +273,28 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
|
|
||||||
// Collect all NAMED descendants of fiber.child into out[], transparently
|
// Collect all NAMED descendants of fiber.child into out[], transparently
|
||||||
// flattening unnamed intermediates (Fragments, Providers, wrappers).
|
// 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;
|
var child = fiber.child;
|
||||||
while (child) {
|
while (child) {
|
||||||
var name = getDisplayName(child);
|
var name = getDisplayName(child);
|
||||||
if (name) {
|
if (name) {
|
||||||
var serialized = serializeFiber(child, parentId);
|
var serialized = serializeFiber(child, parentId, counterRef.n);
|
||||||
if (serialized) out.push(serialized);
|
if (serialized) {
|
||||||
|
out.push(serialized);
|
||||||
|
counterRef.n += 1;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
|
// 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
|
fiberMap.set(child, null); // mark as non-selectable
|
||||||
collectChildren(child, parentId, out);
|
collectChildren(child, parentId, out, counterRef);
|
||||||
}
|
}
|
||||||
child = child.sibling;
|
child = child.sibling;
|
||||||
}
|
}
|
||||||
@@ -267,6 +320,142 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
return null;
|
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) {
|
function serializeProps(props) {
|
||||||
if (!props || typeof props !== 'object') return {};
|
if (!props || typeof props !== 'object') return {};
|
||||||
var out = {};
|
var out = {};
|
||||||
@@ -415,26 +604,85 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
var styleProps = [
|
var styleProps = [
|
||||||
'width','height','background-color','color','font-size',
|
'width','height','background-color','color','font-size',
|
||||||
'font-family','font-weight','line-height','letter-spacing','text-align',
|
'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',
|
'padding-top','padding-right','padding-bottom','padding-left',
|
||||||
'margin-top','margin-right','margin-bottom','margin-left',
|
'margin-top','margin-right','margin-bottom','margin-left',
|
||||||
'border-radius','border-width','border-color','border-style',
|
'border-radius','border-top-left-radius','border-top-right-radius',
|
||||||
'opacity','box-shadow','overflow','position',
|
'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 = {};
|
var styles = {};
|
||||||
styleProps.forEach(function(p) { styles[p] = cs.getPropertyValue(p); });
|
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;
|
break;
|
||||||
case 'PATCH_ELEMENT_STYLE':
|
case 'PATCH_ELEMENT_STYLE':
|
||||||
if (typeof msg.nodeId === 'string' && typeof msg.property === 'string') {
|
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];
|
var pInfo = nodeMap[msg.nodeId];
|
||||||
if (pInfo) {
|
if (pInfo) {
|
||||||
var pEl = findDomElement(pInfo.fiber);
|
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;
|
break;
|
||||||
case 'REMOVE_ELEMENT':
|
case 'REMOVE_ELEMENT':
|
||||||
@@ -500,8 +748,9 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
if (!path || typeof path !== 'string') return;
|
if (!path || typeof path !== 'string') return;
|
||||||
// Skip hash fragments, external links that slipped through, and dynamic segments
|
// Skip hash fragments, external links that slipped through, and dynamic segments
|
||||||
if (path.charAt(0) !== '/') return;
|
if (path.charAt(0) !== '/') return;
|
||||||
// Normalise: strip trailing slash except for root
|
// M-4 fix: the original regex /\\/+$/ matched a literal backslash, not a
|
||||||
var norm = path.length > 1 ? path.replace(/\\/+$/, '') : '/';
|
// forward slash. Correct regex is /\/+$/.
|
||||||
|
var norm = path.length > 1 ? path.replace(/\/+$/, '') : '/';
|
||||||
if (seen[norm]) return;
|
if (seen[norm]) return;
|
||||||
seen[norm] = true;
|
seen[norm] = true;
|
||||||
var label = (hint && typeof hint === 'string' && hint.trim().slice(0, 50)) || humanLabel(norm);
|
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);
|
addRoute(window.location.pathname, document.title || undefined);
|
||||||
|
|
||||||
// ② Next.js Pages Router — __NEXT_DATA__ contains the full page list in build id manifest
|
// ② 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 {
|
try {
|
||||||
var nextData = window.__NEXT_DATA__;
|
var nextData = window.__NEXT_DATA__;
|
||||||
if (nextData && nextData.buildId) {
|
if (nextData && nextData.buildId) {
|
||||||
// Fetch the pages manifest — available at /_next/static/{buildId}/_buildManifest.js
|
|
||||||
var manifestUrl = '/_next/static/' + nextData.buildId + '/_buildManifest.js';
|
var manifestUrl = '/_next/static/' + nextData.buildId + '/_buildManifest.js';
|
||||||
var xhr = new XMLHttpRequest();
|
fetch(manifestUrl).then(function(res) {
|
||||||
xhr.open('GET', manifestUrl, false); // sync — runs at init time before user interaction
|
if (!res.ok) return;
|
||||||
xhr.send();
|
return res.text();
|
||||||
if (xhr.status === 200) {
|
}).then(function(text) {
|
||||||
// Manifest exposes self.__BUILD_MANIFEST = { sortedPages: [...] }
|
if (!text) return;
|
||||||
var match = xhr.responseText.match(/sortedPages\\s*:\\s*(\\[[^\\]]+\\])/);
|
var match = text.match(/sortedPages\s*:\s*(\[[^\]]+\])/);
|
||||||
if (match) {
|
if (match) {
|
||||||
try {
|
try {
|
||||||
var pages = JSON.parse(match[1]);
|
var pages = JSON.parse(match[1]);
|
||||||
pages.forEach(function(p) {
|
pages.forEach(function(p) {
|
||||||
// Skip catch-all and dynamic segments for now; static routes only
|
|
||||||
if (p.indexOf('[') === -1) addRoute(p);
|
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 (e) { /* parse failed */ }
|
||||||
}
|
}
|
||||||
|
}).catch(function() { /* manifest unavailable */ });
|
||||||
}
|
}
|
||||||
}
|
} catch (e) { /* Next.js not present */ }
|
||||||
} catch (e) { /* Next.js not present or manifest unavailable */ }
|
})();
|
||||||
|
|
||||||
// ③ <a href> same-origin links from the rendered DOM
|
// ③ <a href> same-origin links from the rendered DOM
|
||||||
var anchors = document.querySelectorAll('a[href]');
|
var anchors = document.querySelectorAll('a[href]');
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export interface FiberNode {
|
|||||||
props: Record<string, unknown>;
|
props: Record<string, unknown>;
|
||||||
children: FiberNode[];
|
children: FiberNode[];
|
||||||
domRect?: DOMRectLike;
|
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 {
|
export interface DOMRectLike {
|
||||||
@@ -34,6 +42,9 @@ export type HostMessage =
|
|||||||
/** Apply a single CSS property override directly to the component's DOM element.
|
/** Apply a single CSS property override directly to the component's DOM element.
|
||||||
* Non-destructive — sets inline style only; source files are unchanged. */
|
* Non-destructive — sets inline style only; source files are unchanged. */
|
||||||
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
|
| { 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. */
|
/** Hide a component's DOM element (sets display:none). Non-destructive. */
|
||||||
| { type: 'REMOVE_ELEMENT'; nodeId: string };
|
| { type: 'REMOVE_ELEMENT'; nodeId: string };
|
||||||
|
|
||||||
@@ -51,8 +62,17 @@ export type RendererMessage =
|
|||||||
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
|
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
|
||||||
| { type: 'COMPONENT_DESELECTED' }
|
| { type: 'COMPONENT_DESELECTED' }
|
||||||
| { type: 'ERROR'; message: string }
|
| { type: 'ERROR'; message: string }
|
||||||
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
|
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node.
|
||||||
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }
|
* 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
|
/** All discoverable routes found in the running app — sent once after READY
|
||||||
* and again after each SPA navigation. */
|
* and again after each SPA navigation. */
|
||||||
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
|
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
|
||||||
|
|||||||
Generated
+21
@@ -151,6 +151,13 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/cli:
|
packages/cli:
|
||||||
|
dependencies:
|
||||||
|
chokidar:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
|
tinyglobby:
|
||||||
|
specifier: ^0.2.0
|
||||||
|
version: 0.2.16
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@originmain/renderer':
|
'@originmain/renderer':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -1978,6 +1985,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
@@ -2479,6 +2490,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readdirp@5.0.0:
|
||||||
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
regex-recursion@6.0.2:
|
regex-recursion@6.0.2:
|
||||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||||
|
|
||||||
@@ -4888,6 +4903,10 @@ snapshots:
|
|||||||
|
|
||||||
check-error@2.1.3: {}
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
readdirp: 5.0.0
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
@@ -5420,6 +5439,8 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.5: {}
|
react@19.2.5: {}
|
||||||
|
|
||||||
|
readdirp@5.0.0: {}
|
||||||
|
|
||||||
regex-recursion@6.0.2:
|
regex-recursion@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user